Files
AriWeb-Suite/modules/webmaker/test/domains-process.test.js
T

149 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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), /연결되어 있지 않습니다/);
});