'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), /연결되어 있지 않습니다/); });