149 lines
18 KiB
JavaScript
149 lines
18 KiB
JavaScript
'use strict';
|
||||
|
|
// v1.6.35 (요구 1) 도메인 준비 프로세스 — 장바구니, 이미 가진 도메인 등록(네임서버 이전 / 직접 DNS 관리), 네임서버 변경 확인·레코드 잠금, 필요한 DNS 설정·확인, 단계별 상태, 서브 도메인 금지.
|
|||
|
|
// 모의 등록 업체·모의 DNS 공급자·모의 리졸버 — 네트워크·비용 없음. 실행: npm test
|
|||
|
|
const test = require('node:test'); const assert = require('node:assert/strict');
|
|||
|
|
const fs = require('fs'); const path = require('path'); const os = require('os');
|
|||
|
|
const ROOT = path.join(__dirname, '..');
|
|||
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-dom-')); process.env.DATA_ROOT = tmp; process.env.DATA_DIR = path.join(tmp, 'sites'); process.env.PUBLIC_BASE_URL = 'https://webmaker.example.com';
|
|||
|
|
const dz = require(ROOT + '/lib/dnszone'); const registrar = require(ROOT + '/lib/registrar'); const settings = require(ROOT + '/lib/settings');
|
|||
|
|
|
|||
|
|
// ---- 모의 DNS 공급자: 영역·레코드를 메모리에 둔다 ----
|
|||
|
|
const zones = {};
|
|||
|
|
dz.PROVIDERS.mock = { id: 'mock', ready: () => true, listZones: async () => Object.keys(zones).map((n) => ({ name: n })), getZone: async (z) => zones[z] || null,
|
|||
|
|
createZone: async (z) => { zones[z] = zones[z] || { records: [] }; return { id: 'z-' + z, name: z, nameServers: ['ns1.mock-dns.net', 'ns2.mock-dns.net'] }; },
|
|||
|
|
deleteZone: async (z) => { const existed = !!zones[z]; delete zones[z]; return { existed }; }, listRecords: async (z) => (zones[z] ? zones[z].records.slice() : []),
|
|||
|
|
updateRecords: async (z, { add = [], remove = [] }) => { const zz = zones[z] || (zones[z] = { records: [] }); zz.records = zz.records.filter((r) => !remove.some((x) => x.type === r.type && (x.name || '') === (r.name || '') && String(x.value) === String(r.value))); zz.records.push(...add); return { ok: true }; },
|
|||
|
|
nameServers: async () => ['ns1.mock-dns.net', 'ns2.mock-dns.net'] };
|
|||
|
|
dz.IDS.push('mock'); dz.LABELS.mock = 'Mock DNS';
|
|||
|
|
// ---- 모의 등록 업체: 가격은 USD, 호출마다 바꿀 수 있다 ----
|
|||
|
|
const prices = { 'shop.example': 12, 'brand.example': 30 }; const taken = new Set(['taken.example']);
|
|||
|
|
registrar.PROVIDERS.mock = { id: 'mock', ready: () => true, needsContact: false, split: (f) => { if (!/^[a-z0-9-]+\.[a-z]+$/.test(f)) throw Object.assign(new Error('형식'), { status: 400 }); return f.split('.'); },
|
|||
|
|
check: async (list) => list.map((d) => ({ domain: d, status: taken.has(d) ? 'active' : 'free', premium: false, price: taken.has(d) ? null : { currency: 'USD', amount: prices[d] || 10 } })),
|
|||
|
|
price: async (f) => ({ currency: 'USD', amount: prices[f] || 10 }), createCustomer: async () => 'h1', createDomain: async (f) => ({ id: 'op-' + f, status: 'ACT', expiresAt: '2027-01-01' }), renewDomain: async () => ({}), getDomain: async () => ({ expiresAt: '2027-01-01' }) };
|
|||
|
|
registrar.IDS.push('mock');
|
|||
|
|
settings.save({ fx: { mode: 'manual', manualRate: 1000, spreadPct: 0 }, domains: { registrar: 'mock', dnsProvider: 'mock', platformDomain: 'example.com', targetIp: '203.0.113.10', marginPct: 200 } });
|
|||
|
|
const domains = require(ROOT + '/lib/domains');
|
|||
|
|
const user = { id: 'u1', role: 'user', accountType: 'domain' };
|
|||
|
|
const err = (code) => Object.assign(new Error(code), { code });
|
|||
|
|
/** 모의 리졸버: m.ns(영역별 NS), m.a(이름→IP[]), m.mx, m.txt */
|
|||
|
|
const resolverFor = (m) => ({
|
|||
|
|
resolveNs: async (z) => { const v = m.ns && m.ns[z]; if (v instanceof Error) throw v; if (!v) throw err('ENOTFOUND'); return v; },
|
|||
|
|
resolveSoa: async (z) => { if (m.ns && m.ns[z]) return {}; throw err('ENOTFOUND'); },
|
|||
|
|
resolve4: async (n) => { if (m.a && m.a[n]) return m.a[n]; throw err('ENODATA'); }, resolve6: async () => { throw err('ENODATA'); }, resolveCname: async (n) => { if (m.cname && m.cname[n]) return m.cname[n]; throw err('ENODATA'); },
|
|||
|
|
resolveTxt: async (n) => { if (m.txt && m.txt[n]) return m.txt[n]; throw err('ENODATA'); }, resolveMx: async (n) => { if (m.mx && m.mx[n]) return m.mx[n]; throw err('ENODATA'); },
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('관리자 설정: 서브 도메인 등록 금지', async () => {
|
|||
|
|
settings.save({ domains: { allowSubdomain: false } }); assert.equal(domains.config().allowSubdomain, false);
|
|||
|
|
await assert.rejects(domains.checkSubdomain('shop'), (e) => e.status === 403 && /서브 도메인 등록을 받지 않습니다/.test(e.message));
|
|||
|
|
settings.save({ domains: { allowSubdomain: true } }); const r = await domains.checkSubdomain('shop'); assert.equal(r.available, false, '예약 이름'); assert.equal((await domains.checkSubdomain('mystore')).available, true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('장바구니: 담기(가격 저장) → 가격 다시 받기(변동 표시) → 빼기 → 구매하면 빠짐', async () => {
|
|||
|
|
let c = await domains.cartAdd(user, 'shop.example'); assert.equal(c.length, 1); assert.equal(c[0].available, true); assert.equal(c[0].quote.costKrw, 24000, '12 USD × 1000 × 200%');
|
|||
|
|
c = await domains.cartAdd(user, 'taken.example'); assert.equal(c.length, 2); assert.equal(c[1].available, false); assert.equal(c[1].quote, null);
|
|||
|
|
await domains.cartAdd(user, 'shop.example'); assert.equal(domains.cart(user.id).length, 2, '같은 도메인은 한 번만');
|
|||
|
|
prices['shop.example'] = 15; const r = await domains.cartRefresh(user); const s = r.find((i) => i.domain === 'shop.example'); assert.equal(s.priceChanged, true); assert.equal(s.prevKrw, 24000); assert.equal(s.quote.costKrw, 30000);
|
|||
|
|
domains.cartRemove(user, 'taken.example'); assert.equal(domains.cart(user.id).length, 1);
|
|||
|
|
const bought = await domains.purchase(user, 'shop.example', { period: 1, expectedKrw: 30000 }).catch((e) => e);
|
|||
|
|
// 캐시 잔액이 없어 402 — 장바구니는 남는다 (구매 성공 때만 빠진다)
|
|||
|
|
assert.ok(bought instanceof Error && bought.status === 402, '잔액 없음 → 402: ' + (bought.message || ''));
|
|||
|
|
assert.equal(domains.cart(user.id).length, 1);
|
|||
|
|
require(ROOT + '/lib/credits').apply({ userId: user.id, type: 'add', amount: 100000, memo: 'test', by: 'test' });
|
|||
|
|
const d = await domains.purchase(user, 'shop.example', { period: 1, expectedKrw: 30000 }); assert.equal(d.type, 'registered'); assert.equal(domains.cart(user.id).length, 0, '구매한 도메인은 장바구니에서 빠짐');
|
|||
|
|
assert.ok(zones['shop.example'] && zones['shop.example'].records.some((x) => x.type === 'A' && x.value === '203.0.113.10'), '구매 도메인 영역에 사이트 A 레코드');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('이미 가진 도메인 — 소유 확인 없음·미등록 도메인은 거부(이유 설명)', async () => {
|
|||
|
|
await assert.rejects(domains.addExternal(user, 'mine.example', { mode: 'ns', resolver: resolverFor({ ns: { 'mine.example': ['ns1.old.net'] } }) }), (e) => e.status === 409 && /이미 구매\(소유\)했는지/.test(e.message));
|
|||
|
|
await assert.rejects(domains.addExternal(user, 'nothere.example', { mode: 'ns', owned: true, resolver: resolverFor({}) }), (e) => e.status === 409 && /NXDOMAIN|등록되어 있지 않거나/.test(e.message));
|
|||
|
|
await assert.rejects(domains.addExternal(user, 'dead.example', { mode: 'manual', owned: true, resolver: resolverFor({ ns: { 'dead.example': err('ESERVFAIL') } }) }), /SERVFAIL|응답을 주지 못했/);
|
|||
|
|
await assert.rejects(domains.addExternal(user, 'bad name', { mode: 'ns', owned: true }), /올바르지 않습니다/);
|
|||
|
|
await assert.rejects(domains.addExternal(user, 'shop.example', { mode: 'ns', owned: true }), (e) => e.status === 409 && /이미 이 서비스에 등록된/.test(e.message));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('네임서버 이전: 레코드 옮김 → 네임서버 안내 → 잠금 → 사용자 확인 → 전파 중 → 완료(잠금 해제)', async () => {
|
|||
|
|
const live = { ns: { 'mine.example': ['ns1.old.net.', 'ns2.old.net'] }, a: { 'mine.example': ['198.51.100.1'], 'www.mine.example': ['198.51.100.1'], 'blog.mine.example': ['198.51.100.2'] }, mx: { 'mine.example': [{ exchange: 'mail.old.net.', priority: 10 }] }, txt: { 'mine.example': [['v=spf1 -all']] } };
|
|||
|
|
const r = await domains.addExternal(user, 'www.mine.example', { mode: 'ns', owned: true, subdomains: 'blog, shop', resolver: resolverFor(live) });
|
|||
|
|
assert.equal(r.domain.name, 'mine.example', 'www 는 떼어낸다'); assert.equal(r.domain.type, 'external'); assert.equal(r.domain.mode, 'ns'); assert.equal(r.next, 'ns-change');
|
|||
|
|
assert.deepEqual(r.nameServers, ['ns1.mock-dns.net', 'ns2.mock-dns.net']); assert.deepEqual(r.currentNs, ['ns1.old.net', 'ns2.old.net']);
|
|||
|
|
assert.equal(r.imported.length, 5, '@A·wwwA·blogA·MX·TXT'); assert.ok(r.imported.some((x) => x.type === 'MX' && x.value === 'mail.old.net' && x.prio === 10));
|
|||
|
|
assert.equal(zones['mine.example'].records.length, 5, '모의 공급자 영역에 들어감'); assert.match(r.message, /네임서버를 아래 목록으로 바꾸세요|네임서버/);
|
|||
|
|
assert.equal(r.domain.dnsLocked, true); assert.equal(r.domain.pending.kind, 'transfer');
|
|||
|
|
const id = r.domain.id;
|
|||
|
|
// 잠금: 레코드 수정 불가 (읽기는 가능)
|
|||
|
|
await assert.rejects(domains.addRecord(user, id, { type: 'A', name: 'x', value: '203.0.113.9' }), (e) => e.status === 409 && /DNS 변경 진행 중/.test(e.message));
|
|||
|
|
assert.equal((await domains.records(user, id)).length, 5);
|
|||
|
|
// 사용자 확인 전: 공용 DNS 는 아직 옛 NS → ns-change 안내
|
|||
|
|
domains._setResolver(resolverFor(live));
|
|||
|
|
let t = await domains.transferCheck(user, id, {}); assert.equal(t.done, false); assert.equal(t.step, 'ns-change'); assert.match(t.message, /아직 바뀌지 않았습니다/);
|
|||
|
|
// 사용자가 "바꿨다" 확인했지만 아직 옛 NS → propagating(진행 중)
|
|||
|
|
t = await domains.transferCheck(user, id, { confirmed: true }); assert.equal(t.done, false); assert.equal(t.step, 'propagating'); assert.match(t.message, /DNS 변경 진행 중/);
|
|||
|
|
let p = await domains.propagation(id); assert.equal(p.pending, true); assert.match(p.message, /DNS 변경 진행 중/);
|
|||
|
|
// 일부만 바뀜 → 여전히 진행 중
|
|||
|
|
domains._setResolver(resolverFor({ ...live, ns: { 'mine.example': ['ns1.mock-dns.net', 'ns2.old.net'] } }));
|
|||
|
|
t = await domains.transferCheck(user, id, {}); assert.equal(t.step, 'propagating'); assert.match(t.message, /일부만/);
|
|||
|
|
// 발행 게이트는 이전 전파 중에도 막지 않는다(배포 판정이 dns-wait 로 알림)
|
|||
|
|
assert.equal(await domains.gate({ domains: ['mine.example', 'www.mine.example'] }), null);
|
|||
|
|
// 모두 바뀜 → 완료, 잠금 해제, 레코드 수정 가능
|
|||
|
|
domains._setResolver(resolverFor({ ...live, ns: { 'mine.example': ['NS2.mock-dns.net.', 'ns1.mock-dns.net'] } }));
|
|||
|
|
t = await domains.transferCheck(user, id, {}); assert.equal(t.done, true); assert.equal(t.step, 'done'); assert.match(t.message, /확인되었습니다/);
|
|||
|
|
const rec = domains.listFor(user.id).find((x) => x.id === id); assert.equal(rec.dnsLocked, false); assert.equal(rec.pending, null); assert.equal(rec.transfer.step, 'done');
|
|||
|
|
await domains.addRecord(user, id, { type: 'A', name: 'x', value: '203.0.113.9' }); assert.ok(zones['mine.example'].records.some((x) => x.name === 'x'));
|
|||
|
|
// 사이트 연결: 영역의 @·www A 가 사이트 IP 로 바뀐다
|
|||
|
|
const site = { id: 'a1b2c3d4e5f60718a1b2c3d4e5f60718', ownerId: user.id, title: 'T', deployments: [] };
|
|||
|
|
const c = await domains.connect(user, id, site); assert.deepEqual(c.domains, ['mine.example', 'www.mine.example']); assert.equal(c.dns, 'auto');
|
|||
|
|
const roots = zones['mine.example'].records.filter((x) => x.type === 'A' && (x.name || '') === ''); assert.deepEqual(roots.map((x) => x.value), ['203.0.113.10'], '옛 A 는 교체');
|
|||
|
|
// 이미 우리 네임서버를 가리키는 도메인은 이전 절차 없이 바로 사용
|
|||
|
|
const r2 = await domains.addExternal(user, 'ready.example', { mode: 'ns', owned: true, resolver: resolverFor({ ns: { 'ready.example': ['ns1.mock-dns.net', 'ns2.mock-dns.net'] } }) });
|
|||
|
|
assert.equal(r2.next, 'connect'); assert.equal(r2.domain.dnsLocked, false); assert.equal(r2.domain.transfer.step, 'done');
|
|||
|
|
const aud = dz.auditList(20); assert.ok(aud.some((a) => a.action === 'zone.transfer-start' && a.zone === 'mine.example')); assert.ok(aud.some((a) => a.action === 'zone.transfer-done' && a.zone === 'mine.example'));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('직접 DNS 관리: 필요한 레코드 안내 → 확인 실패(배포 불가 안내) → 반영 후 확인 성공 → 상태', async () => {
|
|||
|
|
const r = await domains.addExternal(user, 'manual.example', { mode: 'manual', owned: true, resolver: resolverFor({ ns: { 'manual.example': ['ns1.their.net'] } }) });
|
|||
|
|
assert.equal(r.domain.mode, 'manual'); assert.equal(r.next, 'manual-records'); const id = r.domain.id;
|
|||
|
|
await assert.rejects(domains.records(user, id), (e) => e.status === 409, '수동 도메인은 영역 레코드 없음');
|
|||
|
|
await assert.rejects(domains.addRecord(user, id, { type: 'A', name: '', value: '1.2.3.4' }), (e) => e.status === 409);
|
|||
|
|
const req = await domains.requiredRecords(user, id, {}); assert.equal(req.ip, '203.0.113.10');
|
|||
|
|
assert.deepEqual(req.records.map((x) => `${x.type} ${x.host}`), ['A @', 'A www']); assert.ok(req.mailNote, '메일용은 선택');
|
|||
|
|
const reqm = await domains.requiredRecords(user, id, { mail: true }); assert.ok(reqm.mailNote && /MX 호스트|메일 서버 주소/.test(reqm.mailNote), 'MX 호스트 미설정이면 이유를 안내: ' + reqm.mailNote);
|
|||
|
|
// 아직 반영 안 됨 → 실패 + 배포 불가 안내
|
|||
|
|
domains._setResolver(resolverFor({ a: { 'manual.example': ['198.51.100.9'] } }));
|
|||
|
|
let v = await domains.verifyManual(user, id, { confirmed: true }); assert.equal(v.ok, false); assert.match(v.message, /DNS 변경이 완료되지 않았거나 변경된 내용이 아직 반영되지 않았습니다/); assert.match(v.message, /배포가 정상적으로 되지 않습니다/);
|
|||
|
|
assert.deepEqual(v.records.map((x) => x.ok), [false, false]); assert.deepEqual(v.records[0].found, ['198.51.100.9']);
|
|||
|
|
let rec = domains.listFor(user.id).find((x) => x.id === id); assert.ok(rec.manual.confirmedAt); assert.equal(rec.manual.verifiedAt, null); assert.equal(rec.manual.siteOk, false);
|
|||
|
|
// 사이트 연결 안내도 "확인되기 전에는 배포되지 않음"
|
|||
|
|
const site = { id: 'b1b2c3d4e5f60718a1b2c3d4e5f60718', ownerId: user.id, title: 'M', deployments: [] };
|
|||
|
|
const c = await domains.connect(user, id, site); assert.equal(c.dns, 'manual'); assert.match(c.guide, /확인되기 전에는 이 주소로 배포되지 않습니다/);
|
|||
|
|
// 상태: DNS 설정 실패 단계가 이유를 말한다
|
|||
|
|
let st = await domains.status(user, id, site); assert.equal(st.ok, false); const zoneStep = st.steps.find((s) => s.key === 'zone'); assert.equal(zoneStep.ok, false); assert.match(zoneStep.detail, /반영되지 않았습니다/);
|
|||
|
|
assert.equal(st.steps.find((s) => s.key === 'deploy').ok, false); assert.match(st.steps.find((s) => s.key === 'deploy').detail, /아직 발행하지 않았습니다/);
|
|||
|
|
// 반영됨 → 성공
|
|||
|
|
domains._setResolver(resolverFor({ a: { 'manual.example': ['203.0.113.10'], 'www.manual.example': ['203.0.113.10'] } }));
|
|||
|
|
v = await domains.verifyManual(user, id, { confirmed: true }); assert.equal(v.ok, true); assert.match(v.message, /배포할 수 있습니다/);
|
|||
|
|
rec = domains.listFor(user.id).find((x) => x.id === id); assert.ok(rec.manual.verifiedAt); assert.equal(rec.manual.siteOk, true);
|
|||
|
|
site.deployments = [{ at: new Date().toISOString(), ok: true, phase: 'dns-wait', phaseDetail: '도메인이 아직 이 서버를 가리키지 않습니다' }];
|
|||
|
|
st = await domains.status(user, id, site); assert.equal(st.steps.find((s) => s.key === 'zone').ok, true); assert.equal(st.steps.find((s) => s.key === 'site-dns').ok, true);
|
|||
|
|
const dep = st.steps.find((s) => s.key === 'deploy'); assert.equal(dep.state, 'wait'); assert.match(dep.detail, /DNS 설정 대기/); assert.match(st.summary, /진행 중/);
|
|||
|
|
site.deployments = [{ at: new Date().toISOString(), ok: true, phase: 'ready' }];
|
|||
|
|
st = await domains.status(user, id, site); assert.equal(st.steps.find((s) => s.key === 'deploy').ok, true); assert.match(st.summary, /사용할 준비가 되었습니다/);
|
|||
|
|
// 해제: 외부 도메인은 지울 수 있다
|
|||
|
|
const rel = domains.release(user, id); assert.equal(rel.ok, true); assert.ok(!domains.listFor(user.id).some((x) => x.id === id));
|
|||
|
|
domains._setResolver(null);
|
|||
|
|
});
|
|||
|
|
test('v1.7.2 사이트 연결: 하위 이름(host) 선택 → 그 A 레코드만 · 연결 끊기 → 사이트 주소 목록에서 제거', async () => {
|
|||
|
|
const site = { id: 'c'.repeat(32), ownerId: 'u1', title: 'Shop', domains: [] };
|
|||
|
|
const list0 = domains.listFor('u1'); const bought = list0.find((d) => d.type === 'registered') || null;
|
|||
|
|
let d = bought; if (!d) { await domains.saveContact(user, { name: 'A', email: 'a@example.com', phone: '+82.1012345678', address: 'x', city: 'y', zip: '1', country: 'KR' }).catch(() => {}); const r = await domains.purchase(user, 'brand.example', { period: 1 }); d = r.domain || r; }
|
|||
|
|
const r1 = await domains.connect(user, d.id, site, { host: 'shop' });
|
|||
|
|
assert.deepEqual(r1.domains, ['shop.' + d.name]); assert.equal(r1.host, 'shop'); assert.deepEqual(site.domains, ['shop.' + d.name]);
|
|||
|
|
assert.ok(zones[d.name].records.some((r) => r.type === 'A' && r.name === 'shop'), '하위 이름 A 레코드 생성');
|
|||
|
|
const pubD = domains.listFor('u1').find((x) => x.id === d.id); assert.equal(pubD.host, 'shop'); assert.deepEqual(pubD.siteNames, ['shop.' + d.name]);
|
|||
|
|
await assert.rejects(domains.connect(user, d.id, site, { host: 'Bad Name' }), /하위 이름은/);
|
|||
|
|
await assert.rejects(domains.connect(user, d.id, site, { host: 'www' }), /기본/);
|
|||
|
|
const r2 = await domains.connect(user, d.id, site, {}); assert.deepEqual(r2.domains, [d.name, 'www.' + d.name], '비우면 @·www');
|
|||
|
|
const r3 = domains.disconnect(user, d.id, site); assert.deepEqual(r3.domains, []); assert.equal(domains.listFor('u1').find((x) => x.id === d.id).siteId, null);
|
|||
|
|
assert.throws(() => domains.disconnect(user, d.id, site), /연결되어 있지 않습니다/);
|
|||
|
|
});
|