Ariweb Suite 0.1.1 기반: 공통 라이브러리(db·http·세션·작업 엔진·할당/예약·알림·감사·보관소), 포털 뼈대, Webmaker/AriMail 도입 소스와 manifest

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:02:58 +00:00
co-authored by Claude Fable 5.1
parent 9ca4cee341
commit c7aea8d419
295 changed files with 90146 additions and 0 deletions
@@ -0,0 +1,34 @@
'use strict';
// v1.6.33 (요구 1) 이전된 영역 받기 — 공용 DNS 상태별 결과: 미등록/위임 없음(실패·원인), 네임서버 응답 없음(실패·원인), 레코드 없음("zone 파일 구성을 찾을 수 없습니다"+빈 영역), 레코드 있음(가져옴). 모의 리졸버·모의 공급자, 네트워크 없음.
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-dz-')); process.env.DATA_ROOT = tmp; process.env.DATA_DIR = path.join(tmp, 'sites');
const dz = require(ROOT + '/lib/dnszone');
// 모의 공급자: 영역 생성·레코드 추가만 기록
const made = []; dz.PROVIDERS.mock = { id: 'mock', ready: () => true, createZone: async (z) => { made.push({ zone: z }); return { id: 'z1', name: z, nameServers: ['ns1.mock', 'ns2.mock'] }; }, updateRecords: async (z, { add }) => { made.push({ zone: z, add }); return { ok: true }; }, listZones: async () => [], getZone: async () => null, deleteZone: async () => ({}), listRecords: async () => [], nameServers: async () => ['ns1.mock'] };
dz.LABELS.mock = 'Mock';
const err = (code) => Object.assign(new Error(code), { code });
const resolverFor = (m) => ({ resolveNs: async (z) => { if (m.ns instanceof Error) throw m.ns; return m.ns; }, resolveSoa: async () => { if (m.soa instanceof Error) throw m.soa; return m.soa || {}; }, resolve4: async (n) => m.a && m.a[n] ? m.a[n] : (() => { throw err('ENODATA'); })(), resolve6: async () => { throw err('ENODATA'); }, resolveCname: async () => { throw err('ENODATA'); }, resolveTxt: async (n) => m.txt && m.txt[n] ? m.txt[n] : (() => { throw err('ENODATA'); })(), resolveMx: async (n) => m.mx && m.mx[n] ? m.mx[n] : (() => { throw err('ENODATA'); })() });
test('미등록·위임 없음(NXDOMAIN) → 실패, 원인 설명', async () => {
await assert.rejects(dz.importZone('nope.example', 'admin', 'mock', { resolver: resolverFor({ ns: err('ENOTFOUND') }) }), (e) => e.status === 409 && /등록되어 있지 않거나|네임서버\(NS\)가 지정되지/.test(e.message) && /존재하지 않습니다/.test(e.message));
assert.equal(made.length, 0, '영역을 만들지 않음');
const a = dz.auditList(5).find((x) => x.action === 'zone.import'); assert.ok(a && a.ok === false && /NXDOMAIN/.test(a.reason), '실패도 감사 기록');
});
test('네임서버가 응답하지 않음(SERVFAIL/TIMEOUT) → 실패, 원인 설명', async () => {
await assert.rejects(dz.importZone('dead.example', 'admin', 'mock', { resolver: resolverFor({ ns: err('ESERVFAIL') }) }), /SERVFAIL|응답을 주지 못했/);
await assert.rejects(dz.importZone('down.example', 'admin', 'mock', { resolver: resolverFor({ ns: err('ETIMEOUT') }) }), /연결할 수 없습니다/);
assert.equal(made.length, 0);
});
test('위임은 되어 있지만 레코드가 없음 → 실패가 아니라 "zone 파일 구성을 찾을 수 없습니다" + 빈 영역', async () => {
const r = await dz.importZone('empty.example', 'admin', 'mock', { resolver: resolverFor({ ns: ['ns1.other.net.', 'ns2.other.net'] }) });
assert.equal(r.empty, true); assert.equal(r.imported, 0); assert.match(r.message, /zone 파일 구성을 찾을 수 없습니다/); assert.deepEqual(r.currentNs, ['ns1.other.net', 'ns2.other.net']); assert.deepEqual(r.nameServers, ['ns1.mock', 'ns2.mock']);
assert.equal(made.filter((m) => m.zone === 'empty.example').length, 1, '빈 영역은 만들고 레코드 추가는 없음');
});
test('레코드가 있으면 가져온다 (A·MX·TXT)', async () => {
const r = await dz.importZone('live.example', 'admin', 'mock', { resolver: resolverFor({ ns: ['ns1.x'], a: { 'live.example': ['203.0.113.5'], 'www.live.example': ['203.0.113.5'] }, mx: { 'live.example': [{ exchange: 'mail.live.example', priority: 10 }] }, txt: { 'live.example': [['v=spf1 -all']] } }) });
assert.equal(r.empty, false); assert.equal(r.imported, 4); assert.match(r.message, /레코드 4개/);
const add = made.find((m) => m.zone === 'live.example' && m.add).add; assert.ok(add.some((x) => x.type === 'MX' && x.prio === 10)); assert.ok(add.some((x) => x.type === 'TXT' && x.value === 'v=spf1 -all'));
});
test('공급자 접속 정보가 없으면 DNS 를 읽기 전에 503', async () => { await assert.rejects(dz.importZone('x.example', 'admin', 'route53', { resolver: resolverFor({ ns: ['ns'] }) }), (e) => e.status === 503); });
test('오류 코드 설명표', () => { assert.match(dz.describeDnsError('ENOTFOUND', 'a.b'), /NXDOMAIN/); assert.equal(dz.describeDnsError('ENODATA', 'a.b'), ''); assert.match(dz.describeDnsError('EWEIRD', 'a.b'), /EWEIRD/); });
@@ -0,0 +1,148 @@
'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), /연결되어 있지 않습니다/);
});
+125
View File
@@ -0,0 +1,125 @@
'use strict';
// v1.6.31 (요구 30) 자동 검증 — 라이브러리 단위. 실행: npm test (node --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 { spawnSync } = require('child_process');
const ROOT = path.join(__dirname, '..');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-test-')); process.env.DATA_ROOT = tmp; process.env.DATA_DIR = path.join(tmp, 'sites'); process.env.SITES_ROOT = path.join(tmp, 'www'); fs.mkdirSync(process.env.SITES_ROOT, { recursive: true });
test('jsonstore: 원자적 쓰기·백업·손상 감지', () => {
const js = require(ROOT + '/lib/jsonstore'); const f = path.join(tmp, 'a.json');
js.writeJsonAtomic(f, { v: 1 }, { keepBackup: true }); js.writeJsonAtomic(f, { v: 2 }, { keepBackup: true });
assert.equal(js.readJson(f, null).v, 2); assert.equal(JSON.parse(fs.readFileSync(f + '.bak', 'utf8')).v, 1);
fs.writeFileSync(f, '{broken'); const got = js.readJson(f, null); assert.equal(got.v, 1, '.bak 으로 복구'); assert.ok(js.health().some((h) => h.file === f && h.recoveredFromBackup));
fs.unlinkSync(f + '.bak'); assert.throws(() => js.readJson(f, null, { strict: true }), /손상/);
assert.equal(fs.readdirSync(tmp).filter((x) => x.endsWith('.tmp')).length, 0, '임시 파일 잔여 없음');
});
test('jsonstore: withLock 직렬화', async () => {
const js = require(ROOT + '/lib/jsonstore'); const order = [];
await Promise.all([js.withLock('k', async () => { await new Promise((r) => setTimeout(r, 30)); order.push(1); }), js.withLock('k', async () => { order.push(2); })]);
assert.deepEqual(order, [1, 2]);
});
test('ratelimit: 창·신뢰 프록시', () => {
const rl = require(ROOT + '/lib/ratelimit');
assert.deepEqual([1, 2, 3, 4].map(() => rl.limited('t', 3, 1000)), [false, false, false, true]);
assert.equal(rl.clientIp({ socket: { remoteAddress: '::ffff:127.0.0.1' }, headers: { 'x-forwarded-for': '203.0.113.9, 127.0.0.1' } }), '203.0.113.9');
assert.equal(rl.clientIp({ socket: { remoteAddress: '198.51.100.7' }, headers: { 'x-forwarded-for': '10.0.0.1' } }), '198.51.100.7', '비신뢰 연결의 XFF 무시');
});
test('netguard: 내부 주소 차단·허용 목록', async () => {
const ng = require(ROOT + '/lib/netguard');
await assert.rejects(ng.assertExternalUrl('http://127.0.0.1:8795/', { allowHttp: true }), /내부|사설|공개/);
await assert.rejects(ng.assertExternalUrl('http://localhost/', { allowHttp: true }), /내부/);
await assert.rejects(ng.assertExternalUrl('http://169.254.169.254/latest/meta-data', { allowHttp: true }), /내부|사설|공개/);
await assert.rejects(ng.assertExternalUrl('http://api.example.com/'), /프로토콜/, '기본은 https 만');
await assert.rejects(ng.assertExternalUrl('ftp://example.com/'), /프로토콜/);
assert.equal(ng.isPrivateIp('192.168.20.66'), true); assert.equal(ng.isPrivateIp('8.8.8.8'), false);
});
test('audit: 비밀값 가림', () => {
const a = require(ROOT + '/lib/audit'); const r = a.redact({ password: 'p', apiKey: 'sk-ant-1234567890', note: 'token re_abcdefghijk', nested: { secret: 'x', ok: 1 } });
assert.equal(r.password, '(비밀값 생략)'); assert.equal(r.nested.secret, '(비밀값 생략)'); assert.ok(!/re_abcdefghijk/.test(r.note)); assert.equal(r.nested.ok, 1);
a.log({ by: 'tester', action: 'test.x', detail: { password: 'zz' } }); const l = a.list({ action: 'test.' }); assert.equal(l[0].detail.password, '(비밀값 생략)');
});
test('credits: 같은 작업(ref) 중복 차감 없음 · 금액 검증', () => {
const c = require(ROOT + '/lib/credits');
c.set('u1', 10000, 'admin'); c.charge('u1', 3000, { siteId: 's', cmdNo: 1 }, 'x'); const again = c.charge('u1', 3000, { siteId: 's', cmdNo: 1 }, 'x');
assert.equal(again.duplicate, true); assert.equal(c.balance('u1'), 7000);
assert.throws(() => c.apply({ userId: 'u1', type: 'add', amount: -5 }), /0보다/); assert.throws(() => c.set('u1', -1, 'admin'), /음수/); assert.throws(() => c.apply({ userId: 'u1', type: 'add', amount: 1e12 }), /너무 큽니다/);
});
test('auth: CSRF 토큰·세션 쿠키 Secure·기본 관리자 없음', () => {
const auth = require(ROOT + '/lib/auth');
assert.equal(auth.ensureBootstrapAdmin() && /최초 관리자/.test(auth.ensureBootstrapAdmin()), true);
assert.equal(Object.keys(JSON.parse(fs.existsSync(path.join(tmp, 'users.json')) ? fs.readFileSync(path.join(tmp, 'users.json'), 'utf8') : '{}')).length, 0, 'admin/admin 이 만들어지지 않음');
const u = auth.signup({ id: 'alice', password: 'Passw0rd!', name: 'A' });
const tok = auth.csrfToken(u); assert.ok(auth.csrfOk(u, tok)); assert.ok(!auth.csrfOk(u, tok + 'x'));
assert.match(auth.makeSessionCookie('alice', { secure: true }), /; Secure/); assert.doesNotMatch(auth.makeSessionCookie('alice'), /Secure/);
const before = auth.csrfToken(u); auth.changePassword('alice', 'Passw0rd!', 'NewPassw0rd!'); assert.notEqual(auth.csrfToken(u), before, '비밀번호 변경 뒤 토큰(세션 세대) 바뀜');
const r = auth.bootstrapAdmin('test'); assert.ok(r.created && r.password && r.password !== 'admin');
});
test('guard hook: 사이트 폴더 밖 읽기·셸 명령·내부 주소 차단', () => {
const site = path.join(tmp, 'gsite'); fs.mkdirSync(site, { recursive: true }); fs.writeFileSync(path.join(site, 'index.html'), 'x');
const run = (payload) => spawnSync('node', [path.join(ROOT, 'hooks', 'guard.js')], { input: JSON.stringify(payload), env: { ...process.env, SITE_DIR: site } }).status;
assert.equal(run({ tool_name: 'Read', tool_input: { file_path: path.join(site, 'index.html') } }), 0);
assert.equal(run({ tool_name: 'Read', tool_input: { file_path: '/etc/passwd' } }), 2);
assert.equal(run({ tool_name: 'Read', tool_input: { file_path: path.join(tmp, 'users.json') } }), 2);
assert.equal(run({ tool_name: 'Bash', tool_input: { command: 'cat ../other/index.html' } }), 2);
assert.equal(run({ tool_name: 'Bash', tool_input: { command: 'curl http://127.0.0.1:3080/api/admin/settings' } }), 2);
assert.equal(run({ tool_name: 'Bash', tool_input: { command: 'ls css && cat index.html' } }), 0);
assert.equal(run({ tool_name: 'WebFetch', tool_input: { url: 'http://192.168.20.66:8795/' } }), 2);
assert.equal(run({ tool_name: 'Glob', tool_input: { pattern: '**/*.json', path: '/var/www/sites' } }), 2);
});
test('codex: 샌드박스 없으면 실행 거부(fail-closed)', () => {
const codex = require(ROOT + '/lib/engine-codex'); const sb = codex.sandboxMode ? codex.sandboxMode() : null;
if (!sb) return; assert.ok(['workspace-write', 'blocked', 'none'].includes(sb.mode)); if (sb.restricted) assert.equal(sb.mode, 'blocked');
});
test('engine-openai safePath: 심볼릭 링크 탈출 차단', () => {
const eo = require(ROOT + '/lib/engine-openai'); const site = path.join(tmp, 'osite'); fs.mkdirSync(site, { recursive: true }); fs.symlinkSync('/etc', path.join(site, 'etclink'));
const sp = eo._safePath || eo.safePath; if (!sp) return;
assert.throws(() => sp(site, 'etclink/passwd'), /밖|심볼릭/); assert.throws(() => sp(site, '../x'), /밖/); assert.ok(sp(site, 'index.html').startsWith(site));
});
test('mailsys 등록부: 다른 사이트의 로그인 이름 충돌 감지', () => {
const m = require(ROOT + '/lib/mailsys');
m.setAccount('siteA', 'm1', { login: 'ceo', providerId: 'p1', state: 'active' });
assert.equal(m.ownerOfLogin('ceo').siteId, 'siteA'); assert.equal(m.ownerOfLogin('nobody'), null);
m.setAccount('siteA', 'm1', { state: 'deprovision_pending', lastError: 'x' }); assert.equal(m.listAccounts({ state: 'deprovision_pending' }).length, 1);
});
test('domains 구매 원장: 같은 도메인 중복 구매 차단 · 닫힌 뒤 재구매', () => {
const d = require(ROOT + '/lib/domains');
const p1 = d._openPurchase('u1', 'example.com', 'create', { period: 1, quoteKrw: 15000, registrar: 'porkbun' }).p; assert.ok(p1 && p1.id);
const p2 = d._openPurchase('u1', 'example.com', 'create', {}); assert.equal(p2.dup && p2.dup.id, p1.id, '열린 구매가 있으면 중복');
assert.equal(d.purchases({ open: true }).length, 1);
d.setPurchase(p1.id, { state: 'failed' }); assert.equal(d.purchases({ open: true }).length, 0);
const p3 = d._openPurchase('u1', 'example.com', 'create', {}); assert.ok(p3.p && !p3.dup, '닫힌 뒤에는 다시 가능');
});
test('v1.6.40 prepaid: 상품(정률·정액 인센티브) · 주문 접수 · 입금 확인 적립(중복 불가) · 취소 · 삭제 보호', () => {
const pp = require(ROOT + '/lib/prepaid'); const credits = require(ROOT + '/lib/credits');
const a = pp.savePackage({ name: '베이직', amount: 10000, bonusType: 'percent', bonusValue: 10 }, 'admin'); assert.equal(a.bonus, 1000); assert.equal(a.credit, 11000);
const b = pp.savePackage({ name: '플러스', amount: 50000, bonusType: 'fixed', bonusValue: 7000, sort: -1 }, 'admin'); assert.equal(b.credit, 57000);
assert.throws(() => pp.savePackage({ name: 'x', amount: 10 }, 'admin'), /금액은/); assert.throws(() => pp.savePackage({ name: 'x', amount: 10000, bonusType: 'percent', bonusValue: 900 }, 'admin'), /500%/);
assert.deepEqual(pp.listPackages().map((p) => p.name), ['플러스', '베이직'], 'sort 순');
pp.savePackage({ id: a.id, active: false }, 'admin'); assert.equal(pp.listPackages().length, 1, '숨긴 상품은 사용자 목록에서 빠짐'); assert.equal(pp.listPackages({ all: true }).length, 2);
assert.throws(() => pp.createOrder('u9', a.id), /없거나 판매가 끝났/);
const before = credits.balance('u9'); const o = pp.createOrder('u9', b.id); assert.equal(o.status, 'pending'); assert.equal(o.credit, 57000);
for (let i = 0; i < pp.MAX_PENDING_PER_USER - 1; i++) pp.createOrder('u9', b.id); assert.throws(() => pp.createOrder('u9', b.id), /결제 대기 주문이/);
const paid = pp.markPaid(o.id, 'admin', '입금 확인'); assert.equal(paid.order.status, 'paid'); assert.equal(credits.balance('u9'), before + 57000, '결제 금액 + 인센티브가 적립');
assert.ok(credits.ledger('u9', 5).some((e) => e.ref && e.ref.orderId === o.id && e.type === 'add'), '원장에 주문 참조');
assert.throws(() => pp.markPaid(o.id, 'admin'), /이미 적립된/); assert.throws(() => pp.cancelOrder(o.id, 'u9', { userId: 'u9' }), /이미 적립된/);
const o2 = pp.myOrders('u9').find((x) => x.status === 'pending'); assert.throws(() => pp.cancelOrder(o2.id, 'u8', { userId: 'u8' }), /주문이 없습니다/, '남의 주문은 보이지 않음'); assert.equal(pp.cancelOrder(o2.id, 'u9', { userId: 'u9' }).status, 'cancelled');
const rm = pp.removePackage(b.id); assert.equal(rm.deactivated, true, '주문이 있는 상품은 숨김으로'); assert.equal(pp.removePackage(a.id).removed, true, '주문 없는 상품은 삭제');
assert.ok(require('fs').existsSync(pp.FILE + '.bak'), '.bak 보관');
});
test('v1.7.2 stats.summary: 달력 기준 3개월 합계(이번 달은 누적 중) · series7 · 디스크 웹/DB/메일 구분', () => {
const st = require(ROOT + '/lib/stats'); const fs2 = require('fs'); const siteId = 'a'.repeat(32);
const day = (offsetDays) => st.kstDate(Date.now() - offsetDays * 86400000);
const today = day(0); const thisMonth = today.slice(0, 7); const prev = new Date(today + 'T00:00:00Z'); prev.setUTCMonth(prev.getUTCMonth() - 1, 15); const prevDay = prev.toISOString().slice(0, 10);
const prev2 = new Date(today + 'T00:00:00Z'); prev2.setUTCMonth(prev2.getUTCMonth() - 2, 10); const prev2Day = prev2.toISOString().slice(0, 10);
const rec = (pv, bi, bo) => ({ req: pv, pv, bytesIn: bi, bytesOut: bo, bots: 0, v: { x: 1 }, vOver: 0, pages: {}, refs: {}, hosts: {}, b: { pv: 0, v: {}, vOver: 0, bytesOut: 0, screens: {}, langs: {} } });
fs2.mkdirSync(st.STATS_DIR, { recursive: true });
fs2.writeFileSync(require('path').join(st.STATS_DIR, siteId + '.json'), JSON.stringify({ days: { [today]: rec(3, 100, 1000), [day(1)]: rec(2, 50, 500), [prevDay]: rec(7, 700, 7000), [prev2Day]: rec(1, 10, 20) }, totals: { req: 0, pv: 0, bytesIn: 0, bytesOut: 0, bots: 0, bpv: 0, bBytesOut: 0 }, updatedAt: new Date().toISOString() }));
const mailDir = require('path').join(tmp, 'mail', siteId); fs2.mkdirSync(mailDir, { recursive: true }); fs2.writeFileSync(require('path').join(mailDir, 'box.json'), 'x'.repeat(2048));
const s = st.summary(siteId, { mailDir, mailEnabled: true });
assert.equal(s.months.length, 3); assert.equal(s.months[2].month, thisMonth); assert.equal(s.months[2].current, true);
const cur = s.months[2]; assert.ok(cur.bytesOut >= 1000 && cur.bytesIn >= 100, '이번 달 누적 (어제가 지난달일 수도 있어 ≥)');
const pm = s.months[1]; assert.equal(pm.current, false); assert.ok(pm.bytesOut >= 7000, '직전 달에 15일 기록 포함: ' + JSON.stringify(pm));
assert.equal(s.months[0].bytesOut >= 20, true, '2개월 전');
assert.equal(s.series7.length, 7); assert.equal(s.disk.mail, 2048); assert.equal(typeof s.disk.web, 'number'); assert.equal(s.disk.limit, null);
const s2 = st.summary(siteId, { mailDir, mailEnabled: false }); assert.equal(s2.disk.mail, null, '메일 미설정이면 숨김(null)');
});
+280
View File
@@ -0,0 +1,280 @@
'use strict';
// v1.6.38 (요구 2) 메일 서버(Arimail) 등록부 — 다른 서버에 설치된 Arimail 여러 대를 등록하고 사이트의 메일 도메인에 맞는 서버를 고르는지.
// 가짜 Arimail 두 대(node http)를 띄워 로그인·계정 목록·계정 생성이 "맞는 서버" 로 가는지 확인한다. 비밀번호가 등록부 파일·화면용 응답에 남지 않는 것도 본다.
const test = require('node:test'); const assert = require('node:assert/strict');
const fs = require('fs'); const path = require('path'); const os = require('os'); const http = require('http');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-mailsrv-'));
process.env.DATA_ROOT = tmp; process.env.DATA_DIR = path.join(tmp, 'sites'); process.env.SITES_ROOT = path.join(tmp, 'www');
fs.mkdirSync(tmp, { recursive: true }); fs.writeFileSync(path.join(tmp, 'session.secret'), 'a'.repeat(64));
/**
* 가짜 Arimail: domain 하나, 관리자 계정 둘(wm-admin = 연동 계정, root = 초기 관리자). 받은 요청을 기록한다.
* v1.6.39: PATCH(비밀번호·역할·상태 변경), 로그인 잠금 흉내(lockAll → 429 rate_limited), 약한 비밀번호 거절(rejectWeak) 을 지원해
* 비밀번호 동기화·초기화·되돌리기·잠금 처리를 검사할 수 있게 한다. 로그인은 users 의 admin 역할 계정 비밀번호로 검사한다(비밀번호를 바꾸면 새 값으로만 로그인).
*/
function fakeArimail(domain, adminPw) {
const users = [{ id: 'adm', login: 'wm-admin', name: 'Webmaker', mailbox: `wm-admin@${domain}`, role: 'admin', status: 'active', password: adminPw }, { id: 'root', login: 'root', name: 'Root', mailbox: `root@${domain}`, role: 'admin', status: 'active', password: 'RootPw!1' }];
const log = []; const ctl = { lockAll: false, rejectWeak: false, usageKey: null /* v1.7.3: 사용량 API 키(없으면 API 꺼짐 404) */, usageCalls: 0 };
// v1.7.3: 가짜 사용량 보고서 — 문서(AriMail docs/USAGE-API.md)의 모양을 따른다
const usageReport = (full) => ({ domain, primary: true, enabled: true, hosts: [`webmail.${domain}`], authMode: 'webmaker', timezone: 'Asia/Seoul', generatedAt: new Date().toISOString(),
accounts: { local: 2, external: 1, total: 3, limit: 0, percent: null }, storage: { bytes: 123456789, limit: 0, percent: null, largeFiles: { count: 1, bytes: 1000, sent: 1 } }, messages: { total: 420, inbound: 300, outbound: 110, drafts: 5, failed: 5 },
traffic: { day: { sent: 3, received: 9, total: 12, sentBytes: 3000, receivedBytes: 9000 }, week: { sent: 10, received: 40, total: 50, sentBytes: 1, receivedBytes: 2 }, month: { sent: 31, received: 88, total: 119, sentBytes: 310000, receivedBytes: 880000 }, monthlyLimit: 0, monthlyPercent: null },
...(full ? { period: { days: 30 }, daily: [{ date: '2026-10-23', sent: 1, received: 2, sentBytes: 10, receivedBytes: 20 }, { date: '2026-10-24', sent: 2, received: 7, sentBytes: 20, receivedBytes: 70 }],
accountList: [{ login: 'wm-admin', name: 'Webmaker', mailbox: `wm-admin@${domain}`, role: 'admin', source: 'local', status: 'active', messages: 10, bytes: 1000, limit: 0, percent: null, lastLoginAt: null, sent: 1, received: 2, sentBytes: 1, receivedBytes: 2 },
{ login: 'kim', name: 'Kim', mailbox: `kim@${domain}`, role: 'user', source: 'webmaker', status: 'active', messages: 200, bytes: 100000000, limit: 0, percent: null, lastLoginAt: null, sent: 20, received: 60, sentBytes: 2, receivedBytes: 3 },
{ login: 'other', name: 'Other', mailbox: `other@${domain}`, role: 'user', source: 'webmaker', status: 'active', messages: 210, bytes: 23455789, limit: 0, percent: null, lastLoginAt: null, sent: 10, received: 26, sentBytes: 3, receivedBytes: 4 }] } : {}) });
const srv = http.createServer((req, res) => {
let body = ''; req.on('data', (c) => { body += c; }); req.on('end', () => {
const j = body ? JSON.parse(body) : {}; log.push({ m: req.method, u: req.url, cookie: req.headers.cookie || '' });
const send = (code, o, h) => { res.writeHead(code, { 'Content-Type': 'application/json', ...(h || {}) }); res.end(JSON.stringify(o)); };
if (req.url === '/api/health') return send(200, { ok: true, version: ctl.version || '1.0.9-test' });
// v1.7.3: 사용량 API — Bearer 키, 키가 없으면 404 usage_api_disabled, 틀리면 401 unauthenticated (세션 쿠키와 무관)
if (/^\/api\/usage(\/|\?|$)/.test(req.url) && req.method === 'GET') {
ctl.usageCalls++; if (!ctl.usageKey) return send(404, { ok: false, error: 'usage_api_disabled' });
const given = (req.headers.authorization || '').replace(/^Bearer\s+/i, ''); if (given !== ctl.usageKey) return send(401, { ok: false, error: 'unauthenticated' });
const m2 = req.url.match(/^\/api\/usage\/([^?]+)/); if (m2) { if (decodeURIComponent(m2[1]) !== domain) return send(404, { ok: false, error: 'domain_not_found' }); return send(200, { ok: true, version: '1.0.9-test', ...usageReport(true) }); }
const r0 = usageReport(false); return send(200, { ok: true, version: '1.0.9-test', generatedAt: r0.generatedAt, primary: domain, totals: { domains: 1, accounts: 3, storageBytes: r0.storage.bytes, largeFileBytes: 1000, messages: 420, sentMonth: 31, receivedMonth: 88 }, domains: [r0] });
}
if (req.url === '/api/login') {
if (ctl.lockAll) return send(429, { ok: false, error: 'rate_limited' });
const u = users.find((x) => x.login === j.login && x.password === j.password && x.role === 'admin');
if (u) return send(200, { ok: true }, { 'Set-Cookie': `mg_admin=tok-${domain}; Path=/; HttpOnly; Secure` }); return send(401, { ok: false, error: 'invalid_credentials' });
}
// 잠금 흉내: 세션도 끊긴 것으로 보아(실제로는 세션 만료 뒤 재로그인 시점) 모든 관리자 호출을 401 로 돌려 재로그인(→ 429)을 유도한다
if (ctl.lockAll || !/^mg_admin=tok-/.test(req.headers.cookie || '')) return send(401, { ok: false, error: 'admin_login_required' });
const pubU = (u) => ({ id: u.id, login: u.login, name: u.name, mailbox: u.mailbox, role: u.role, status: u.status });
// v1.7.3: 사용량 API 키 발급(관리자 세션) — generate 는 새 키를 한 번만 돌려주고, revoke 는 끈다
if (req.url === '/api/setup/usage-key' && req.method === 'POST') { if (j.action === 'revoke') { ctl.usageKey = null; return send(200, { ok: true, enabled: false }); } if (j.action !== 'generate') return send(400, { ok: false, error: 'bad_request' }); ctl.usageKey = 'au_' + domain.replace(/\W/g, '') + '_' + Math.random().toString(36).slice(2, 10).padEnd(8, 'x') + 'KEYKEYKEY'; return send(200, { ok: true, enabled: true, key: ctl.usageKey, endpoint: `https://webmail.${domain}/api/usage` }); }
if (req.url === '/api/admin/users' && req.method === 'GET') return send(200, { users: users.map(pubU), domain, accounts: { used: users.length, limit: 0 } });
if (req.url === '/api/admin/users' && req.method === 'POST') { if (users.some((u) => u.login === j.login)) return send(409, { ok: false, error: 'login_taken' }); if (ctl.rejectWeak && String(j.password).length < 10) return send(400, { ok: false, error: 'weak_password' }); const u = { id: `u${users.length + 1}`, login: j.login, name: j.name, mailbox: `${j.login}@${domain}`, role: j.role || 'user', status: 'active', password: j.password }; users.push(u); return send(201, { id: u.id }); }
const m = req.url.match(/^\/api\/admin\/users\/(.+)$/);
if (m && req.method === 'PATCH') { const u = users.find((x) => x.id === m[1]); if (!u) return send(404, { ok: false, error: 'not_found' }); if (j.password !== undefined) { if (ctl.rejectWeak && String(j.password).length < 10) return send(400, { ok: false, error: 'weak_password' }); u.password = j.password; } if (j.role) u.role = j.role; if (j.status) u.status = j.status; if (j.name) u.name = j.name; return send(200, { ok: true }); }
if (m && req.method === 'DELETE') { const i = users.findIndex((u) => u.id === m[1]); if (i < 0) return send(404, { ok: false, error: 'not_found' }); users.splice(i, 1); return send(200, { ok: true }); }
send(404, { ok: false, error: 'not_found' });
});
});
return new Promise((ok) => srv.listen(0, '127.0.0.1', () => ok({ srv, users, log, ctl, url: `http://127.0.0.1:${srv.address().port}` })));
}
let A, B;
test.before(async () => { A = await fakeArimail('alpha.test', 'PwAlpha!1'); B = await fakeArimail('beta.test', 'PwBeta!2'); });
test.after(() => { try { A.srv.close(); B.srv.close(); } catch (_) {} });
test('등록부: 추가·검증·비밀번호 분리 보관·기본 서버', () => {
const ms = require('../lib/mailservers');
assert.equal(ms.list().length, 0, '처음에는 서버 없음 (설정 파일 mail.* 비어 있음)');
assert.throws(() => ms.upsert({ nickname: 'x', url: '', domain: 'alpha.test', password: 'p' }), /Arimail 주소를 입력하세요/);
assert.throws(() => ms.upsert({ nickname: 'x', url: A.url, domain: '', password: 'p' }), /메일 도메인을 입력하세요/);
assert.throws(() => ms.upsert({ nickname: 'x', url: A.url, domain: 'alpha.test', ip: 'not-an-ip', password: 'p' }), /서버 IP 형식/);
assert.throws(() => ms.upsert({ nickname: 'x', url: A.url, domain: 'alpha.test' }), /관리자 비밀번호를 입력하세요/, '새 서버는 비밀번호 필수');
const a = ms.upsert({ nickname: 'Alpha 메일', ip: '115.68.194.192', url: A.url, domain: 'alpha.test', adminLogin: 'wm-admin', password: 'PwAlpha!1' }, 'tester');
assert.equal(a.id, 'alpha'); assert.equal(a.hasPassword, true); assert.equal(a.isDefault, true, '첫 서버는 기본'); assert.equal(a.adminPassword, undefined, '화면용 응답에 비밀번호 없음');
assert.match(a.label, /Alpha 메일 — 115\.68\.194\.192 \(alpha\.test\)/);
const b = ms.upsert({ nickname: 'Beta 메일', url: B.url, domain: 'beta.test', adminLogin: 'wm-admin', password: 'PwBeta!2' }, 'tester');
assert.equal(b.isDefault, false);
const raw = fs.readFileSync(ms.FILE, 'utf8'); assert.ok(!raw.includes('PwAlpha') && !raw.includes('PwBeta'), '등록부 파일에 비밀번호 원문 없음');
const vault = require('../lib/vault'); assert.equal(vault.secretOf('arimail-alpha'), 'PwAlpha!1', '비밀번호는 보관소 arimail-<id>'); assert.equal(vault.get('arimail-alpha').kind, 'arimail');
assert.equal(require('../lib/config').get('mail.provider'), 'arimail', '서버를 등록하면 연동 모듈이 켜진다');
assert.throws(() => ms.upsert({ nickname: '중복', url: A.url, domain: 'alpha.test', password: 'p' }), /이미 등록되어 있습니다/);
// 수정: 비밀번호 비우면 유지
const a2 = ms.upsert({ id: 'alpha', nickname: 'Alpha 메일 2', ip: '115.68.194.192', url: A.url, domain: 'alpha.test', adminLogin: 'wm-admin' }, 'tester');
assert.equal(a2.nickname, 'Alpha 메일 2'); assert.equal(vault.secretOf('arimail-alpha'), 'PwAlpha!1');
assert.equal(ms.forDomain('beta.test').id, 'beta'); assert.equal(ms.forDomain('nobody.test'), null);
assert.equal(ms.pick({ webmail: { domain: 'beta.test' } }).id, 'beta', '메일 도메인이 같은 서버');
assert.equal(ms.pick({ webmail: { domain: 'nobody.test' } }).id, 'alpha', '맞는 서버가 없으면 기본 서버');
assert.equal(ms.pick({ webmail: { domain: 'beta.test', mailServer: 'alpha' } }).id, 'alpha', '사이트가 고른 서버가 우선');
ms.setDefault('beta'); assert.equal(ms.defaultServer().id, 'beta');
assert.throws(() => ms.remove('primary'), /기본 서버는 여기서 지울 수 없습니다/);
});
test('연동 모듈: 서버마다 로그인·계정 생성·상태가 맞는 서버로 간다', async () => {
const ms = require('../lib/mailservers'); const arimail = require('../lib/connectors/arimail');
const sa = arimail.forServer(ms.get('alpha')); const sb = arimail.forServer(ms.get('beta'));
const ra = await sa.createUser({ login: 'kim', name: 'Kim', password: 'MailPw!123' }); assert.equal(ra.mailbox, 'kim@alpha.test');
const rb = await sb.createUser({ login: 'kim', name: 'Kim', password: 'MailPw!123' }); assert.equal(rb.mailbox, 'kim@beta.test', '같은 아이디라도 서버가 다르면 각자 생김');
assert.equal(A.users.filter((u) => u.role === 'user').length, 1); assert.equal(B.users.filter((u) => u.role === 'user').length, 1);
assert.ok(A.log.some((x) => x.u === '/api/login')); assert.ok(A.log.filter((x) => x.u === '/api/login').length === 1, '세션 쿠키 재사용 (로그인 1회)');
const st = await ms.test('beta'); assert.equal(st.ok, true); assert.match(st.detail, /Beta 메일/); assert.equal(ms.get('beta').lastTest.ok, true);
assert.ok(!JSON.stringify(st).includes('PwBeta'), '상태 응답에 비밀번호 없음');
// 틀린 비밀번호 → 로그인 실패 문구 (비밀번호 노출 없음)
ms.upsert({ id: 'beta', nickname: 'Beta 메일', url: B.url, domain: 'beta.test', adminLogin: 'wm-admin', password: 'Wrong!' }, 'tester');
const bad = await ms.test('beta'); assert.equal(bad.ok, false); assert.match(bad.detail, /관리자 로그인 실패/); assert.ok(!bad.detail.includes('Wrong!'));
ms.upsert({ id: 'beta', nickname: 'Beta 메일', url: B.url, domain: 'beta.test', adminLogin: 'wm-admin', password: 'PwBeta!2' }, 'tester');
// 모듈 최상위(기본 서버에 묶임) 도 동작
assert.equal(arimail.settings().domain, 'beta.test'); assert.equal(arimail.mailboxFor('x'), 'x@beta.test');
});
test('mailsys: 사이트의 메일 도메인에 맞는 서버로 계정을 만들고, 도메인이 안 맞으면 이유를 말한다', async () => {
const mailsys = require('../lib/mailsys'); const ms = require('../lib/mailservers');
const siteA = { id: 'a'.repeat(32), webmail: { enabled: true, domain: 'alpha.test' } }; const siteB = { id: 'b'.repeat(32), webmail: { enabled: true, domain: 'beta.test' } }; const siteX = { id: 'c'.repeat(32), webmail: { enabled: true, domain: 'nobody.test' } };
// health 캐시가 채워질 때까지 잠깐 (비동기 60초 캐시 — 첫 호출은 pending)
mailsys.backendFor(siteA); mailsys.backendFor(siteB); mailsys.backendFor(siteX); await new Promise((r) => setTimeout(r, 300));
const ba = mailsys.backendFor(siteA); assert.equal(ba.arimail.id, 'alpha'); assert.equal(ba.arimail.nickname, 'Alpha 메일 2'); assert.equal(ba.arimail.ip, '115.68.194.192'); assert.equal(ba.canReceive, true);
const bb = mailsys.backendFor(siteB); assert.equal(bb.arimail.id, 'beta'); assert.equal(bb.canReceive, true);
const bx = mailsys.backendFor(siteX); assert.equal(bx.canReceive, false); assert.match(bx.reason, /nobody\.test 도메인을 다루는 Arimail 을 메일 서버 연동에 등록/);
// MX 기본값: 사이트·전역 값이 없으면 고른 서버의 호스트
assert.equal(mailsys.siteConfig(siteB).mxHost, '127.0.0.1');
const r = await ba.createMailbox({ siteId: siteA.id, memberId: 'm1', login: 'lee', name: 'Lee', password: 'MailPw!123' });
assert.equal(r.address, 'lee@alpha.test'); assert.ok(A.users.some((u) => u.login === 'lee')); assert.ok(!B.users.some((u) => u.login === 'lee'));
const st = mailsys.status(siteA); assert.equal(st.mailServer.id, 'alpha'); assert.equal(st.mailServer.nickname, 'Alpha 메일 2');
// 사이트가 서버를 직접 고르면 그 서버 (도메인 불일치 → 이유)
const siteB2 = { id: 'd'.repeat(32), webmail: { enabled: true, domain: 'beta.test', mailServer: 'alpha' } };
const bb2 = mailsys.backendFor(siteB2); assert.equal(bb2.arimail.id, 'alpha'); assert.equal(bb2.canReceive, false); assert.match(bb2.reason, /alpha\.test 도메인만 다루는데/);
// enableSite: 등록부에 없는 서버 id 는 거절, 빈 값은 자동
await assert.rejects(mailsys.enableSite({ id: 'e'.repeat(32), domains: ['beta.test'] }, { domain: 'beta.test', mailServer: 'ghost' }), /등록부에 없습니다/);
const s5 = { id: 'f'.repeat(32), domains: ['beta.test'] }; await mailsys.enableSite(s5, { domain: 'beta.test', mailServer: '' }, 'admin'); assert.equal(s5.webmail.mailServer, ''); assert.equal(ms.pick(s5).id, 'beta');
});
// ---------------- v1.6.39 ----------------
test('v1.6.39 (요구 2): 비밀번호 덮어쓰기 전 보관 → 되돌리기, 로그인 잠금이면 연결 확인을 쉬고, 파일은 .bak 을 남긴다', async () => {
const ms = require('../lib/mailservers'); const vault = require('../lib/vault');
assert.equal(ms.get('alpha').adminPassword, 'PwAlpha!1'); assert.equal(ms.pub(ms.get('alpha')).hasPrevPassword, false);
// 비밀번호를 새 값으로 바꾸면 직전 값이 prev-arimail-<id> 로 보관된다 (등록부 파일에는 둘 다 원문 없음)
const a = ms.upsert({ id: 'alpha', nickname: 'Alpha 메일 2', ip: '115.68.194.192', url: A.url, domain: 'alpha.test', adminLogin: 'wm-admin', password: 'Changed!1' }, 'tester');
assert.equal(a.hasPrevPassword, true); assert.ok(a.prevPasswordAt); assert.equal(vault.secretOf('arimail-alpha'), 'Changed!1'); assert.equal(vault.secretOf('prev-arimail-alpha'), 'PwAlpha!1');
assert.ok(!fs.readFileSync(ms.FILE, 'utf8').includes('Changed!1'));
const bad = await ms.test('alpha'); assert.equal(bad.ok, false); assert.match(bad.detail, /관리자 로그인 실패/); assert.equal(bad.code, 'invalid_credentials');
// 같은 값으로 다시 저장하면 이전 값은 그대로 (덮어쓰지 않음)
ms.upsert({ id: 'alpha', nickname: 'Alpha 메일 2', ip: '115.68.194.192', url: A.url, domain: 'alpha.test', adminLogin: 'wm-admin', password: 'Changed!1' }, 'tester'); assert.equal(vault.secretOf('prev-arimail-alpha'), 'PwAlpha!1');
// 되돌리기: 이전 값이 현재로, 현재 값이 이전으로 (맞바꾸기)
const rv = ms.revertPassword('alpha', 'tester'); assert.equal(vault.secretOf('arimail-alpha'), 'PwAlpha!1'); assert.equal(vault.secretOf('prev-arimail-alpha'), 'Changed!1'); assert.equal(rv.hasPrevPassword, true);
const ok = await ms.test('alpha'); assert.equal(ok.ok, true, ok.detail);
assert.throws(() => ms.revertPassword('ghost'), /그 메일 서버가 없습니다/);
// 잠금: Arimail 이 rate_limited(429) → 해제 시각 기록, 그동안 test() 는 서버를 부르지 않는다
A.ctl.lockAll = true; const n0 = A.log.filter((x) => x.u === '/api/login').length;
const lk = await ms.test('alpha'); assert.equal(lk.ok, false); assert.equal(lk.code, 'rate_limited'); assert.equal(lk.locked, true); assert.ok(lk.server.lockedUntil); assert.match(lk.detail, /너무 많아/);
const lk2 = await ms.test('alpha'); assert.equal(lk2.locked, true); assert.match(lk2.detail, /로그인 잠금 중 — \d\d:\d\d 이후/); assert.equal(A.log.filter((x) => x.u === '/api/login').length, n0 + 1, '잠금 중에는 로그인 시도를 하지 않는다');
A.ctl.lockAll = false; assert.ok(ms.pub(ms.get('alpha')).lockedUntil, '잠금은 Arimail 쪽이라 lockAll 을 풀어도 기록은 남는다(15분)');
// 해제 시각을 지나면 다시 확인하고 성공 시 잠금 기록이 지워진다
const d = ms.load(); d.items.alpha.lockedUntil = new Date(Date.now() - 1000).toISOString(); fs.writeFileSync(ms.FILE, JSON.stringify(d));
const again = await ms.test('alpha'); assert.equal(again.ok, true); assert.equal(ms.pub(ms.get('alpha')).lockedUntil, null); assert.equal(ms.load().items.alpha.lockedUntil, null);
// 파일 백업
assert.ok(fs.existsSync(ms.FILE + '.bak'), 'mail-servers.json.bak'); assert.ok(fs.existsSync(path.join(tmp, 'credentials.json.bak')), 'credentials.json.bak');
const cfg = require('../lib/config'); cfg.set({ 'mail.nickname': 'x' }, 'tester'); cfg.set({ 'mail.nickname': 'y' }, 'tester'); assert.ok(fs.existsSync(path.join(tmp, 'config.json.bak')), 'config.json.bak');
// 깨진 등록부 파일은 .bak 에서 읽는다
fs.writeFileSync(ms.FILE, '{broken'); assert.ok(ms.get('alpha'), '.bak 으로 복구'); fs.writeFileSync(ms.FILE, JSON.stringify(ms.load()));
});
test('v1.6.39 (요구 4): 연동 관리자 비밀번호 보기·초기화(저장된 비밀번호로 / 다른 관리자 계정으로)·관리자 계정 보호', async () => {
const ms = require('../lib/mailservers'); const vault = require('../lib/vault'); const arimail = require('../lib/connectors/arimail');
const rv = ms.revealPassword('alpha'); assert.equal(rv.password, 'PwAlpha!1'); assert.equal(rv.adminLogin, 'wm-admin'); assert.equal(rv.adminUrl, A.url + '/admin');
assert.throws(() => ms.revealPassword('ghost'), /그 메일 서버가 없습니다/);
// 초기화(저장된 비밀번호로 로그인) → 새 16자 비밀번호가 Arimail 과 보관소에 모두 들어가고, 이전 값은 되돌리기용으로 남는다
const r1 = await ms.resetPassword('alpha', {}, 'tester');
assert.equal(r1.password.length, 16); assert.match(r1.password, /[A-Z]/); assert.match(r1.password, /[a-z]/); assert.match(r1.password, /\d/); assert.match(r1.password, /[^A-Za-z0-9]/);
assert.equal(A.users.find((u) => u.login === 'wm-admin').password, r1.password); assert.equal(vault.secretOf('arimail-alpha'), r1.password); assert.equal(vault.secretOf('prev-arimail-alpha'), 'PwAlpha!1');
assert.equal(r1.created, false); assert.match(r1.how, /저장된 비밀번호로/); assert.equal(r1.test && r1.test.ok, true, '새 비밀번호로 바로 연결 확인'); assert.equal(r1.server.hasPassword, true);
// 저장된 비밀번호가 틀린 상태(운영 사례) → 그대로는 실패, 다른 관리자 계정(root)으로는 성공. via 비밀번호는 어디에도 저장되지 않는다
ms.upsert({ id: 'alpha', nickname: 'Alpha 메일 2', ip: '115.68.194.192', url: A.url, domain: 'alpha.test', adminLogin: 'wm-admin', password: 'Nope!' }, 'tester');
await assert.rejects(ms.resetPassword('alpha', {}, 'tester'), /Arimail 로그인 실패.*다른 관리자 계정으로 재설정/);
await assert.rejects(ms.resetPassword('alpha', { via: { login: 'root', password: 'wrong' } }, 'tester'), /Arimail 로그인 실패/);
const r2 = await ms.resetPassword('alpha', { via: { login: 'root', password: 'RootPw!1' } }, 'tester');
assert.match(r2.how, /다른 관리자 계정\(root\)/); assert.equal(A.users.find((u) => u.login === 'wm-admin').password, r2.password); assert.equal(vault.secretOf('arimail-alpha'), r2.password);
assert.ok(!JSON.stringify(vault.list()).includes('RootPw') && !fs.readFileSync(path.join(tmp, 'credentials.json'), 'utf8').includes('RootPw'));
assert.equal((await ms.test('alpha')).ok, true);
// 연동 계정이 Arimail 에 없으면 다른 관리자 계정으로 새로 만든다 (관리자 역할)
ms.upsert({ id: 'alpha', nickname: 'Alpha 메일 2', ip: '115.68.194.192', url: A.url, domain: 'alpha.test', adminLogin: 'wm-admin2', password: 'x' }, 'tester');
const r3 = await ms.resetPassword('alpha', { via: { login: 'root', password: 'RootPw!1' } }, 'tester'); assert.equal(r3.created, true); const nu = A.users.find((u) => u.login === 'wm-admin2'); assert.equal(nu.role, 'admin'); assert.equal(nu.password, r3.password);
assert.equal((await ms.test('alpha')).ok, true);
// 관리자 계정 보호: 연동 계정·관리자 역할 계정은 지우거나 역할을 내릴 수 없다. 일반 메일 계정은 그대로 지울 수 있다
const sa = arimail.forServer(ms.get('alpha'));
await assert.rejects(sa.deleteUserByLogin('wm-admin2'), /보호됩니다/); await assert.rejects(sa.deleteUserByLogin('root'), /보호됩니다/); await assert.rejects(sa.deleteUserById('adm'), /보호됩니다/);
await assert.rejects(sa.updateUser('root', { role: 'user' }), /보호됩니다/); await assert.rejects(sa.updateUser('adm', { status: 'disabled' }), /보호됩니다/);
await sa.updateUser('adm', { password: 'Fine!1234' }); assert.equal(A.users.find((u) => u.id === 'adm').password, 'Fine!1234', '비밀번호 변경은 허용');
assert.equal(await sa.deleteUserByLogin('kim'), true); assert.ok(!A.users.some((u) => u.login === 'kim'));
assert.ok(A.users.some((u) => u.login === 'root') && A.users.some((u) => u.login === 'wm-admin2'));
});
test('v1.6.39 (요구 3): 사이트 관리자 비밀번호 ↔ 메일 서버 비밀번호 동기화 (로그인 때 맞추기, 켤 때 synced 표시, 실패 기록)', async () => {
const mailsys = require('../lib/mailsys'); const ms = require('../lib/mailservers');
ms.upsert({ id: 'alpha', nickname: 'Alpha 메일 2', ip: '115.68.194.192', url: A.url, domain: 'alpha.test', adminLogin: 'root', password: 'RootPw!1' }, 'tester');
const siteA = { id: 'a'.repeat(32), ownerId: 'owner', webmail: { enabled: true, domain: 'alpha.test' } };
mailsys.backendFor(siteA); await new Promise((r) => setTimeout(r, 300));
// 앞 테스트에서 만든 lee(m1) 계정 — 등록부에 providerId 가 있다. 관리자 레코드(해시 포함) 흉내
const lee = { id: 'm1', loginId: 'lee', name: 'Lee', mail: true, hash: 'hash-v1' };
assert.deepEqual(await mailsys.syncPassword(siteA, { ...lee, mail: false }, 'NewPw!456'), { skipped: 'mail-off' });
const s1 = await mailsys.syncPassword(siteA, lee, 'NewPw!456', 'lee'); assert.equal(s1.ok, true); assert.equal(A.users.find((u) => u.login === 'lee').password, 'NewPw!456');
assert.deepEqual(await mailsys.syncPassword(siteA, lee, 'NewPw!456'), { skipped: 'already-synced' }, '같은 해시면 다시 보내지 않는다');
assert.equal(mailsys.syncState(siteA, lee).state, 'synced');
// 관리자 비밀번호가 바뀌면(해시 변경) 다음 로그인 때 다시 맞춘다
const lee2 = { ...lee, hash: 'hash-v2' }; assert.equal(mailsys.syncState(siteA, lee2).state, 'pending');
assert.equal((await mailsys.syncPassword(siteA, lee2, 'Third!789')).ok, true); assert.equal(A.users.find((u) => u.login === 'lee').password, 'Third!789');
// Arimail 정책 거절(weak_password) → 실패 기록, 상태 error, 비밀번호 원문은 기록에 없음
A.ctl.rejectWeak = true; const lee3 = { ...lee, hash: 'hash-v3' }; const bad = await mailsys.syncPassword(siteA, lee3, 'short'); assert.equal(bad.ok, false); assert.match(bad.error, /비밀번호 정책/);
const st3 = mailsys.syncState(siteA, lee3); assert.equal(st3.state, 'error'); assert.match(st3.error, /정책/); A.ctl.rejectWeak = false;
const acc = mailsys.accountOf(siteA.id, 'm1'); assert.ok(!JSON.stringify(acc).includes('short') && !JSON.stringify(acc).includes('Third!789'));
const au = require('../lib/audit').list ? require('../lib/audit').list({ action: 'mail.account.sync' }) : null; if (au) assert.ok(!JSON.stringify(au).includes('Third!789'));
// 메일 사용 켜기: synced 면 등록부에 해시를 적고 안내 문구가 "같습니다", 아니면 임시 비밀번호 안내
const park = { id: 'm2', loginId: 'park', name: 'Park', hash: 'hash-park' };
const e1 = await mailsys.enableMember(siteA, park, { password: 'ParkPw!123', synced: true }); assert.equal(e1.address, 'park@alpha.test'); assert.equal(e1.synced, true); assert.match(e1.note, /사이트 관리자 비밀번호와 같습니다/); assert.equal(mailsys.accountOf(siteA.id, 'm2').pwSyncHash, 'hash-park');
const choi = { id: 'm3', loginId: 'choi', name: 'Choi', hash: 'hash-choi' };
const e2 = await mailsys.enableMember(siteA, choi, { password: 'TempPw!123', synced: false }); assert.equal(e2.synced, false); assert.match(e2.note, /임시입니다/); assert.equal(mailsys.accountOf(siteA.id, 'm3').pwSyncHash, null);
assert.equal(mailsys.syncState(siteA, { ...choi, mail: true }).state, 'pending');
});
// v1.7.3 (요구 2) 메일 사용량 연동 — 키 발급·보관(비밀값 분리) · 사용량 조회·정리 · 사이트 계정만 걸러내기 · 오류 안내 · 캐시
test('v1.7.3 메일 사용량: 키 발급 → 보관소 · 사용량 조회(서버/도메인/사이트) · 키 오류·버전 안내 · 캐시', async () => {
const ms = require('../lib/mailservers'); const mu = require('../lib/mailusage'); const vault = require('../lib/vault'); const mailsys = require('../lib/mailsys');
const alpha = ms.get('alpha'); assert.ok(alpha, 'alpha 서버가 앞 테스트에서 등록됨'); assert.equal(ms.pub(alpha).hasUsageKey, false);
// 키 없음 → 안내 (Arimail 을 부르지 않음)
const u0 = await mu.forServer('alpha', { force: true }); assert.equal(u0.ok, false); assert.equal(u0.error.code, 'no_usage_key'); assert.match(u0.error.message, /사용량 API 키가 없습니다/);
// 발급: 저장된 관리자 계정으로 Arimail 에 로그인해 받는다 → 보관소 arimail-usage-alpha, 등록부 파일에는 원문 없음
const r = await ms.issueUsageKey('alpha', 'tester'); assert.equal(r.server.hasUsageKey, true); assert.equal(r.replaced, false); assert.ok(A.ctl.usageKey && A.ctl.usageKey.startsWith('au_'));
assert.equal(vault.secretOf('arimail-usage-alpha'), A.ctl.usageKey); assert.equal(vault.get('arimail-usage-alpha').kind, 'arimail-usage');
assert.ok(!fs.readFileSync(ms.FILE, 'utf8').includes(A.ctl.usageKey), '등록부 파일에 키 원문 없음'); assert.ok(!JSON.stringify(ms.list()).includes(A.ctl.usageKey), '화면용 응답에 키 원문 없음'); assert.ok(r.server.usageKeyHint && r.server.usageKeyHint.length < A.ctl.usageKey.length);
assert.ok(A.log.some((l) => l.u === '/api/setup/usage-key' && /mg_admin=tok-/.test(l.cookie)), '관리자 세션으로 발급');
// 서버 요약
const u1 = await mu.forServer('alpha', { force: true }); assert.equal(u1.ok, true); assert.equal(u1.totals.storageBytes, 123456789); assert.equal(u1.domains.length, 1); assert.equal(u1.domains[0].domain, 'alpha.test'); assert.equal(u1.domains[0].traffic.month.sent, 31); assert.equal(u1.domains[0].accountList, undefined, '요약에는 계정 목록 없음');
// 캐시: 같은 요청은 Arimail 을 다시 부르지 않는다
const calls = A.ctl.usageCalls; const u1b = await mu.forServer('alpha'); assert.equal(u1b.cached, true); assert.equal(A.ctl.usageCalls, calls);
// 도메인 상세 + 사이트용(이 사이트 계정만)
const u2 = await mu.forDomain('alpha', 'alpha.test', { force: true }); assert.equal(u2.ok, true); assert.equal(u2.report.accountList.length, 3); assert.equal(u2.report.daily.length, 2);
const siteX = { id: 'site-usage-x', title: 'X', domains: ['alpha.test'], webmail: { enabled: true, domain: 'alpha.test', mailServer: 'alpha' } };
mailsys.setAccount(siteX.id, 'm-kim', { login: 'kim', address: 'kim@alpha.test', providerId: 'u9', provider: 'arimail', state: 'active' });
const u3 = await mu.forSite(siteX); assert.equal(u3.ok, true); assert.equal(u3.storage.bytes, 123456789); assert.equal(u3.accounts.total, 3); assert.equal(u3.accounts.mine, 1); assert.equal(u3.accountList.length, 1); assert.equal(u3.accountList[0].mailbox, 'kim@alpha.test'); assert.equal(u3.accountList[0].mine, true); assert.equal(u3.myBytes, 100000000);
assert.ok(!JSON.stringify(u3).includes('other@'), '다른 사이트 계정 이름은 사이트 응답에 없음');
const off = await mu.forSite({ id: 'nope', webmail: { enabled: false } }); assert.equal(off.ok, false); assert.equal(off.code, 'mail_off');
const mism = await mu.forSite({ id: 'nope2', webmail: { enabled: true, domain: 'beta.test', mailServer: 'alpha' } }); assert.equal(mism.ok, false); assert.equal(mism.code, 'domain_mismatch');
// Arimail 에서 키를 다시 만들어 우리 키가 틀려진 경우 → 401 → 안내
A.ctl.usageKey = 'au_changed_on_arimail_side_000000000'; mu.invalidate('alpha');
const u4 = await mu.forServer('alpha', { force: true }); assert.equal(u4.ok, false); assert.equal(u4.error.code, 'unauthenticated'); assert.match(u4.error.message, /키가 Arimail 의 키와 다릅니다/);
// 다시 발급하면 바뀐 키로 맞춰진다(replaced)
const r2 = await ms.issueUsageKey('alpha', 'tester'); assert.equal(r2.replaced, true); assert.equal(vault.secretOf('arimail-usage-alpha'), A.ctl.usageKey);
assert.equal((await mu.forServer('alpha', { force: true })).ok, true);
// Arimail 쪽에서 키를 지우면(API 꺼짐) → 404 → 안내
A.ctl.usageKey = null; const u5 = await mu.forServer('alpha', { force: true }); assert.equal(u5.ok, false); assert.equal(u5.error.code, 'usage_api_disabled'); assert.match(u5.error.message, /꺼져 있습니다/);
// 낮은 버전(1.0.8) 은 "키가 틀렸다" 대신 업데이트 안내
A.ctl.version = '1.0.8'; const u6 = await mu.forServer('alpha', { force: true }); assert.equal(u6.ok, false); assert.equal(u6.error.code, 'usage_unsupported'); assert.match(u6.error.message, /1\.0\.9 이상으로 업데이트/);
await assert.rejects(ms.issueUsageKey('alpha', 'tester'), /사용량 API 가 없습니다/); A.ctl.version = null;
// 직접 붙여 넣기 · 형식 검사 · 삭제
assert.throws(() => ms.writeUsageKey('alpha', 'bad key with spaces', 'tester'), /형식이 올바르지 않습니다/);
const w = ms.upsert({ id: 'alpha', nickname: 'Alpha 메일', url: A.url, domain: 'alpha.test', adminLogin: 'wm-admin', usageKey: 'au_pasted_from_arimail_setup_screen_1234' }, 'tester'); assert.equal(w.hasUsageKey, true); assert.equal(vault.secretOf('arimail-usage-alpha'), 'au_pasted_from_arimail_setup_screen_1234');
const c = ms.clearUsageKey('alpha'); assert.equal(c.hasUsageKey, false); assert.equal(vault.secretOf('arimail-usage-alpha'), '');
mailsys.removeAccount(siteX.id, 'm-kim');
});
// v1.7.3 추가(운영자 요청): 웹메이커 서비스 관리자 비밀번호 ↔ Arimail 같은 ID 계정 동기화 — 로그인 때 호출되는 syncServiceAdmin
test('v1.7.3 관리자 비밀번호 동기화: 같은 ID 계정만 · 지문이 같으면 생략 · 계정 없으면 생략 · 실패 기록', async () => {
const mailsys = require('../lib/mailsys'); const ms = require('../lib/mailservers');
// 앞 테스트(비밀번호 되돌리기·초기화)가 alpha 의 보관 비밀번호를 가짜 서버와 어긋나게 남길 수 있다 — 가짜 서버의 실제 연동 계정 비밀번호로 다시 맞춘다
ms.upsert({ id: 'alpha', nickname: 'Alpha 메일', url: A.url, domain: 'alpha.test', adminLogin: 'wm-admin', password: A.users.find((u) => u.login === 'wm-admin').password }, 'tester');
const rootA = A.users.find((u) => u.login === 'root'); const before = rootA.password;
// 일반 사용자는 아무 일도 하지 않는다
assert.deepEqual(await mailsys.syncServiceAdmin({ id: 'root', role: 'user' }, 'NewRoot!123', { fingerprint: 'fp1' }), []);
const r1 = await mailsys.syncServiceAdmin({ id: 'root', role: 'admin' }, 'NewRoot!123', { fingerprint: 'fp1', by: 'root' });
const a1 = r1.find((x) => /Alpha/.test(x.server)); assert.ok(a1 && a1.ok === true, JSON.stringify(r1)); assert.equal(rootA.password, 'NewRoot!123', 'Arimail(alpha) root 비밀번호가 바뀜'); assert.notEqual(before, rootA.password);
const b1 = r1.find((x) => /Beta/.test(x.server)); assert.ok(b1 && (b1.ok === true || b1.skipped), 'beta 도 같은 ID 계정이 있으면 맞춘다');
const rec = ms.adminSyncOf('alpha').root; assert.equal(rec.fp, 'fp1'); assert.ok(rec.at); assert.ok(!JSON.stringify(ms.load()).includes('NewRoot!123'), '등록부에 비밀번호 없음');
assert.ok(ms.list().find((s) => s.id === 'alpha').adminSync.some((x) => x.login === 'root' && x.at), '화면용 응답에 동기화 기록');
// 같은 지문이면 Arimail 을 부르지 않는다
const calls = A.log.length; const r2 = await mailsys.syncServiceAdmin({ id: 'root', role: 'admin' }, 'NewRoot!123', { fingerprint: 'fp1' }); assert.equal(r2.find((x) => /Alpha/.test(x.server)).skipped, 'already-synced'); assert.equal(A.log.length, calls);
// 비밀번호가 바뀌면(지문 변경) 다시 맞춘다
const r3 = await mailsys.syncServiceAdmin({ id: 'root', role: 'admin' }, 'Another!456', { fingerprint: 'fp2' }); assert.equal(r3.find((x) => /Alpha/.test(x.server)).ok, true); assert.equal(rootA.password, 'Another!456');
// Arimail 에 없는 ID 는 생략(계정을 만들지 않는다)
const r4 = await mailsys.syncServiceAdmin({ id: 'nobody', role: 'admin' }, 'Nobody!789', { fingerprint: 'fp3' }); assert.equal(r4.find((x) => /Alpha/.test(x.server)).skipped, 'no-account'); assert.ok(!A.users.some((u) => u.login === 'nobody'));
// 정책 거부(weak_password) → 실패 기록, 예외 없음
A.ctl.rejectWeak = true; const r5 = await mailsys.syncServiceAdmin({ id: 'root', role: 'admin' }, 'short', { fingerprint: 'fp4' }); A.ctl.rejectWeak = false;
const f5 = r5.find((x) => /Alpha/.test(x.server)); assert.equal(f5.ok, false); assert.match(f5.error, /비밀번호 정책/); assert.ok(ms.adminSyncOf('alpha').root.error);
const au = require('../lib/audit').list({ action: 'mail.admin.sync' }); assert.ok(au.length >= 2); assert.ok(!JSON.stringify(au).includes('NewRoot!123') && !JSON.stringify(au).includes('Another!456'), '작업 기록에 비밀번호 없음');
});
@@ -0,0 +1,67 @@
'use strict';
// v1.6.32 (요구 1) Route 53 Domains 등록 업체 — 모의 JSON 1.1 서버로 검색·가격·등록(비동기 완료)·갱신·네임서버·소유 확인·등록자 변환을 검사한다. 실제 AWS 호출·비용 없음.
const test = require('node:test'); const assert = require('node:assert/strict');
const http = require('http'); 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-r53-')); process.env.DATA_ROOT = tmp; process.env.DATA_DIR = path.join(tmp, 'sites');
const calls = []; const state = { ops: {}, domains: {} }; let nextOp = 1;
const srv = http.createServer((req, res) => {
let b = ''; req.on('data', (c) => { b += c; }); req.on('end', () => {
const op = String(req.headers['x-amz-target'] || '').split('.').pop(); const j = b ? JSON.parse(b) : {}; calls.push({ op, j, auth: req.headers.authorization || '' });
const send = (code, o) => { res.writeHead(code, { 'Content-Type': 'application/x-amz-json-1.1' }); res.end(JSON.stringify(o)); };
if (!/^AWS4-HMAC-SHA256 Credential=AKIATEST\//.test(req.headers.authorization || '')) return send(403, { __type: 'InvalidSignatureException', message: 'bad signature' });
if (op === 'ListPrices') return j.Tld === 'com' ? send(200, { Prices: [{ Tld: 'com', RegistrationPrice: { Price: 14.0, Currency: 'USD' }, RenewalPrice: { Price: 15.0, Currency: 'USD' }, TransferPrice: { Price: 14.0, Currency: 'USD' } }] }) : send(200, { Prices: [] });
if (op === 'CheckDomainAvailability') return send(200, { Availability: j.DomainName === 'taken.com' ? 'UNAVAILABLE' : j.DomainName === 'odd.com' ? 'DONT_KNOW' : 'AVAILABLE' });
if (op === 'RegisterDomain') { if (!j.RegistrantContact || !/^\+82\.\d+$/.test(j.RegistrantContact.PhoneNumber)) return send(400, { __type: 'com.amazonaws.route53domains#InvalidInput', message: 'bad phone' }); const id = 'op-' + nextOp++; state.ops[id] = { Status: 'IN_PROGRESS', polls: 0, name: j.DomainName, years: j.DurationInYears }; return send(200, { OperationId: id }); }
if (op === 'GetOperationDetail') { const o = state.ops[j.OperationId]; if (!o) return send(400, { __type: 'InvalidInput', message: 'no op' }); o.polls++; if (o.polls >= 2 && o.years && o.name !== 'slow.com') { o.Status = 'SUCCESSFUL'; state.domains[o.name] = { exp: Math.floor(Date.now() / 1000) + o.years * 365 * 86400, ns: ['ns-1.awsdns-00.com', 'ns-2.awsdns-11.net'] }; } return send(200, { OperationId: j.OperationId, Status: o.Status, Type: 'REGISTER_DOMAIN', DomainName: o.name }); }
if (op === 'GetDomainDetail') { const d = state.domains[j.DomainName]; if (!d) return send(400, { __type: 'com.amazonaws.route53domains#InvalidInput', message: 'Domain ' + j.DomainName + ' not found in account' }); return send(200, { DomainName: j.DomainName, ExpirationDate: d.exp, Nameservers: d.ns.map((Name) => ({ Name })), StatusList: ['ok'], AutoRenew: false }); }
if (op === 'RenewDomain') { const d = state.domains[j.DomainName]; if (!d) return send(400, { __type: 'InvalidInput', message: 'not found' }); if (j.CurrentExpiryYear !== new Date(d.exp * 1000).getUTCFullYear()) return send(400, { __type: 'InvalidInput', message: 'wrong expiry year' }); d.exp += j.DurationInYears * 365 * 86400; const id = 'op-' + nextOp++; state.ops[id] = { Status: 'SUCCESSFUL', polls: 9, name: j.DomainName }; return send(200, { OperationId: id }); }
if (op === 'UpdateDomainNameservers') { const d = state.domains[j.DomainName]; if (!d) return send(400, { __type: 'InvalidInput', message: 'not found' }); d.ns = j.Nameservers.map((n) => n.Name); return send(200, { OperationId: 'op-ns' }); }
if (op === 'ListDomains') return send(200, { Domains: Object.entries(state.domains).map(([DomainName, d]) => ({ DomainName, Expiry: d.exp, AutoRenew: false })) });
return send(400, { __type: 'UnknownOperation', message: op });
});
});
let reg;
test.before(async () => { await new Promise((r) => srv.listen(0, '127.0.0.1', r)); process.env.ROUTE53DOMAINS_API_BASE = `http://127.0.0.1:${srv.address().port}`; require(ROOT + '/lib/vault').set({ kind: 'route53', secret: 'secret', settings: { ACCESS_KEY_ID: 'AKIATEST' } }); reg = require(ROOT + '/lib/registrars/route53'); });
test.after(() => srv.close());
const CONTACT = { firstName: 'Gildong', lastName: 'Hong', company: '', email: 'g@example.com', phoneCountry: '+82', phoneArea: '010', phoneNumber: '12345678', street: 'Teheran-ro', number: '123', zipcode: '06123', city: 'Seoul', state: '', country: 'KR' };
test('등록 업체 창구에 route53 이 있고 보관소 키로 준비된다', () => { const r = require(ROOT + '/lib/registrar'); assert.ok(r.IDS.includes('route53')); assert.equal(r.LABELS.route53, 'Amazon Route 53'); assert.ok(reg.ready()); assert.ok(r.catalog().find((x) => x.id === 'route53').needsContact); });
test('검색: 가능·불가·모름·미지원 확장자, 가격 USD', async () => {
const r = await reg.check(['free.com', 'taken.com', 'odd.com', 'nope.kr']);
assert.equal(r[0].status, 'free'); assert.deepEqual(r[0].price, { currency: 'USD', amount: 14 });
assert.equal(r[1].status, 'active'); assert.match(r[1].reason, /이미 등록/);
assert.equal(r[2].status, 'unknown'); assert.equal(r[3].status, 'unknown'); assert.match(r[3].reason, /다루지 않는 확장자/);
assert.equal((await reg.price('free.com', 'create', 2)).amount, 28); assert.equal((await reg.price('free.com', 'renew', 1)).amount, 15);
await assert.rejects(reg.price('x.kr'), /등록하지 않습니다/);
});
test('등록자 변환: 전화 +82.1012345678, PERSON/COMPANY, 빠진 값 거절', () => {
const c = reg.toContact(CONTACT); assert.equal(c.PhoneNumber, '+82.1012345678'); assert.equal(c.ContactType, 'PERSON'); assert.equal(c.AddressLine1, 'Teheran-ro 123'); assert.equal(c.CountryCode, 'KR');
assert.equal(reg.toContact({ ...CONTACT, company: 'Oz Group' }).OrganizationName, 'Oz Group');
assert.throws(() => reg.toContact({ ...CONTACT, email: '' }), /Email/); assert.throws(() => reg.toContact(null), /등록자 정보/);
});
test('등록: 비동기 작업을 기다려 ACT + 만료일, 갱신은 CurrentExpiryYear 를 읽어 보낸다, 네임서버 변경, 소유 확인', async () => {
const r = await reg.createDomain('free.com', { period: 2, contact: CONTACT });
assert.equal(r.status, 'ACT'); assert.equal(r.id, 'free.com'); assert.match(r.expiresAt, /^\d{4}-\d{2}-\d{2}$/);
const reg1 = calls.find((c) => c.op === 'RegisterDomain').j; assert.equal(reg1.DurationInYears, 2); assert.equal(reg1.AutoRenew, false); assert.equal(reg1.PrivacyProtectRegistrantContact, true);
const before = (await reg.getDomain('free.com')).expiresAt; const rn = await reg.renewDomain('free.com', 'free.com', 1); assert.equal(rn.status, 'ACT');
assert.ok((await reg.getDomain('free.com')).expiresAt > before, '만료일이 늘어남');
await reg.setNameServers('free.com', 'free.com', ['ns1.example.net.', 'ns2.example.net']); assert.deepEqual((await reg.getDomain('free.com')).nameServers, ['ns1.example.net', 'ns2.example.net']);
assert.ok(await reg.ownsDomain('free.com')); assert.equal(await reg.ownsDomain('other.com'), null, '계정에 없는 도메인은 null');
assert.equal((await reg.listDomains()).length, 1);
});
test('등록이 오래 걸리면 PENDING 으로 돌아오고, 구매 흐름은 영역을 미루고 finalizeRegistration 이 완료 뒤 채운다', async () => {
const r = await reg.createDomain('slow.com', { period: 1, contact: CONTACT }); assert.equal(r.status, 'PENDING'); assert.ok(r.operationId);
// 도메인 기록에 PENDING 으로 넣고 완료 확인 흐름을 돌린다 (registrar 설정 route53, DNS 수동)
const d = require(ROOT + '/lib/domains'); const fs2 = require('fs'); const f = path.join(tmp, 'domains.json'); const cur = (() => { try { return JSON.parse(fs2.readFileSync(f, 'utf8')); } catch (_) { return { domains: [], contacts: {}, purchases: [] }; } })();
cur.domains.push({ id: 'dom1', userId: 'u1', name: 'slow.com', type: 'registered', opId: 'slow.com', status: 'PENDING', period: 1, registrar: 'route53', createdAt: new Date().toISOString(), pending: { kind: 'registration', since: new Date().toISOString(), etaMinutes: 30, checks: [], operationId: r.operationId } }); fs2.writeFileSync(f, JSON.stringify(cur));
const f1 = await d.finalizeRegistration('dom1', 'admin'); assert.equal(f1.done, false, '아직 처리 중');
const p1 = await d.propagation('dom1'); assert.equal(p1.pending, true); assert.match(p1.message, /등록을 처리/);
state.ops[r.operationId].Status = 'SUCCESSFUL'; state.domains['slow.com'] = { exp: Math.floor(Date.now() / 1000) + 365 * 86400, ns: ['ns-a', 'ns-b'] };
const f2 = await d.finalizeRegistration('dom1', 'admin'); assert.equal(f2.done, true); assert.match(f2.detail, /등록 완료/);
const after = JSON.parse(fs2.readFileSync(f, 'utf8')).domains.find((x) => x.id === 'dom1'); assert.equal(after.status, 'ACT'); assert.ok(after.expiresAt); assert.match(after.zoneError || '', /DNS 공급자/, 'DNS 공급자 없음 → 영역 생략 안내');
assert.equal((await d.finalizeRegistration('dom1', 'admin')).done, true, '멱등');
});
test('잘못된 키는 403 메시지로 안내한다', async () => {
require(ROOT + '/lib/vault').set({ kind: 'route53', secret: 'secret', settings: { ACCESS_KEY_ID: 'AKIAWRONG' } });
await assert.rejects(reg.listDomains(), (e) => e.status === 403 && /InvalidSignatureException/.test(e.message));
});
+152
View File
@@ -0,0 +1,152 @@
'use strict';
// v1.6.31 (요구 30) 자동 검증 — 서비스 단위(빈 데이터로 서버를 띄워 API 로 확인). 사용자 간 격리 · CSRF/출처 · 가입 제한 · 손상 파일 · 설정 토큰.
const test = require('node:test'); const assert = require('node:assert/strict');
const fs = require('fs'); const path = require('path'); const os = require('os'); const { spawn } = require('child_process');
const ROOT = path.join(__dirname, '..'); const PORT = 3900 + Math.floor(Math.random() * 90);
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-srv-')); const B = `http://127.0.0.1:${PORT}`;
let child; const jar = {};
const api = async (p, init = {}, who = 'a') => { const headers = { 'Content-Type': 'application/json', ...(init.headers || {}) }; if (jar[who]) headers.cookie = jar[who]; if (init.referer !== null) headers.referer = init.referer || `${B}/`; const r = await fetch(B + p, { ...init, headers, redirect: 'manual' }); const sc = r.headers.get('set-cookie'); if (sc && init.keep !== false) jar[who] = sc.split(';')[0]; let b = {}; try { b = await r.json(); } catch (_) {} return { status: r.status, b, headers: r.headers }; };
test.before(async () => {
fs.mkdirSync(tmp, { recursive: true }); fs.writeFileSync(path.join(tmp, 'setup.token'), 'TESTTOKEN0123456789abcdefghij\n', { mode: 0o600 });
child = spawn('node', [path.join(ROOT, 'server.js')], { env: { ...process.env, PORT: String(PORT), HOST: '127.0.0.1', DATA_ROOT: tmp, DATA_DIR: path.join(tmp, 'sites'), SITES_ROOT: path.join(tmp, 'www'), PUBLIC_BASE_URL: B, WEBMAKER_SETUP_PORT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] });
let out = ''; child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { out += c; });
for (let i = 0; i < 100; i++) { if (/listening on/.test(out)) break; await new Promise((r) => setTimeout(r, 100)); }
assert.match(out, /listening on/); assert.doesNotMatch(out, /admin \/ admin/, '기동 때 admin/admin 을 만들지 않음');
});
test.after(() => { try { child.kill('SIGTERM'); } catch (_) {} });
test('기본 관리자 없음 · 가입·로그인·세션', async () => {
const info = await api('/api/auth/info'); assert.equal(info.b.setupRequired, true, '관리자가 없으니 설치 점검 미완');
assert.equal((await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ id: 'admin', password: 'admin' }) }, 'x')).status, 401, 'admin/admin 로그인 불가');
const a = await api('/api/auth/signup', { method: 'POST', body: JSON.stringify({ id: 'alice', password: 'Passw0rd!', name: 'Alice' }) }, 'a'); assert.equal(a.status, 200);
const b = await api('/api/auth/signup', { method: 'POST', body: JSON.stringify({ id: 'bob', password: 'Passw0rd!', name: 'Bob' }) }, 'b'); assert.equal(b.status, 200);
assert.equal((await api('/api/auth/me', {}, 'a')).b.user.id, 'alice');
});
test('설정 토큰: 관리자가 생기기 전에는 토큰으로, 생긴 뒤에는 토큰 거절', async () => {
const tok = fs.readFileSync(path.join(tmp, 'setup.token'), 'utf8').trim();
const s1 = await api('/api/setup/login', { method: 'POST', body: JSON.stringify({ token: tok }) }, 's'); assert.equal(s1.status, 200);
const cr = await api('/api/setup/admin/create', { method: 'POST', body: JSON.stringify({ id: 'root1', name: 'Root', password: 'RootPassw0rd!' }) }, 's'); assert.ok(cr.status < 300, JSON.stringify(cr.b));
const s2 = await api('/api/setup/login', { method: 'POST', body: JSON.stringify({ token: tok }) }, 's2'); assert.equal(s2.status, 403, '쓸 수 있는 관리자가 생기면 토큰 로그인 불가');
const lg = await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ id: 'root1', password: 'RootPassw0rd!' }) }, 'r'); assert.equal(lg.status, 200);
const ov = await api('/api/admin/overview', {}, 'r'); assert.equal(ov.status, 200);
const au = await api('/api/admin/audit', {}, 'r'); assert.equal(au.status, 200); assert.ok((au.b.items || []).some((x) => x.action === 'auth.login'));
assert.ok(!JSON.stringify(au.b).includes('RootPassw0rd'), '작업 기록에 비밀번호가 없음');
const rc = await api('/api/admin/recovery', {}, 'r'); assert.equal(rc.status, 200); assert.ok(Array.isArray(rc.b.items));
const dns = await api('/api/admin/dns', {}, 'r'); assert.equal(dns.status, 200, 'v1.6.33: /api/admin/dns 가 404 가 아니어야 함 (1.5.36~1.6.32 회귀)'); assert.ok(dns.b.catalog && Array.isArray(dns.b.domains));
const zi = await api('/api/admin/dns/zones', { method: 'POST', body: JSON.stringify({ domain: 'example.com', provider: 'route53', import: true }) }, 'r'); assert.equal(zi.status, 503, '공급자 접속 정보 없음 → 503 안내 (not found 아님): ' + JSON.stringify(zi.b)); assert.match(zi.b.error || '', /접속 정보/);
});
test('관리자가 alice 에게 캐시 지급 → 작업 기록에 남음', async () => {
const r = await api('/api/admin/credits/alice', { method: 'POST', body: JSON.stringify({ type: 'add', amount: 50000, memo: 'test' }) }, 'r'); assert.equal(r.status, 200, JSON.stringify(r.b));
const au = await api('/api/admin/audit?action=credits.', {}, 'r'); assert.ok(au.b.items.some((x) => x.action === 'credits.add' && x.target === 'alice'));
});
test('사용자 간 격리: 다른 사용자의 사이트 ID 로 접근 불가', async () => {
const mk = await api('/api/sites', { method: 'POST', body: JSON.stringify({ title: 'A site', purpose: 'p', content: 'c' }) }, 'a'); assert.ok(mk.status < 300, 'alice 사이트 생성 ' + mk.status + ' ' + JSON.stringify(mk.b).slice(0, 100));
const id = (mk.b.site && mk.b.site.id) || mk.b.id; assert.ok(id);
assert.equal((await api(`/api/sites/${id}`, {}, 'a')).status, 200);
assert.equal((await api(`/api/sites/${id}`, {}, 'b')).status, 404, 'bob 은 404');
assert.equal((await api(`/api/sites/${id}`, { method: 'PATCH', body: JSON.stringify({ title: 'hacked' }) }, 'b')).status, 404);
assert.equal((await api(`/api/sites/${id}/events`, {}, 'b')).status, 404);
assert.equal((await api('/api/admin/overview', {}, 'b')).status, 403, '일반 사용자는 관리자 API 403');
global.__siteId = id;
});
test('생성 사이트 출처·CSRF: 세션 쿠키만으로는 API 사용 불가', async () => {
const id = global.__siteId;
assert.equal((await api('/api/auth/me', { referer: `${B}/${id}/index.html` }, 'a')).status, 401, '사이트 페이지 출처 → 세션 무시');
assert.equal((await api('/api/auth/me', { referer: null, headers: { 'sec-fetch-mode': 'cors', 'sec-fetch-dest': 'empty' } }, 'a')).status, 401, 'Referer 없는 브라우저 fetch → 세션 무시');
assert.ok([401, 403].includes((await api('/api/auth/csrf', { referer: `${B}/${id}/` }, 'a')).status), '사이트 페이지는 CSRF 토큰을 못 받음');
const t = await api('/api/auth/csrf', {}, 'a'); assert.equal(t.status, 200);
assert.equal((await api('/api/auth/me', { referer: null, headers: { 'sec-fetch-mode': 'cors', 'sec-fetch-dest': 'empty', 'x-wm-csrf': t.b.csrf } }, 'a')).status, 200, 'CSRF 헤더가 있으면 가능');
assert.equal((await api('/api/auth/me', { headers: { 'sec-fetch-mode': 'navigate', 'sec-fetch-dest': 'document' }, referer: null }, 'a')).status, 200, '화면 이동은 세션 사용');
});
test('v1.6.35 도메인 탭 API: 장바구니·외부 도메인 경로가 있고 거부 이유를 안내한다', async () => {
const d = await api('/api/domains', {}, 'a'); assert.equal(d.status, 200); assert.ok(Array.isArray(d.b.cart)); assert.equal(typeof d.b.platform.allowSubdomain, 'boolean'); assert.equal(typeof d.b.dnsReady, 'boolean');
// 경로가 있고(404 "not found" 가 아님) 이유를 말한다: 서브 도메인 계정이면 403, 자체 도메인 계정인데 등록 업체 접속 정보가 없으면 503 "도메인 서비스가 아직 준비되지 않았습니다"
const ct = await api('/api/domains/cart', { method: 'POST', body: JSON.stringify({ domain: 'x.example' }) }, 'a'); assert.ok([403, 503].includes(ct.status), JSON.stringify(ct.b)); assert.match(ct.b.error, /서브 도메인 계정|준비되지 않았습니다/);
// 외부 도메인 등록: 소유 확인(owned) 없이는 DNS 를 보기 전에 409 (서브 도메인 계정이면 403)
const ex = await api('/api/domains/external', { method: 'POST', body: JSON.stringify({ domain: 'x.example', mode: 'ns', owned: false }) }, 'a'); assert.ok([403, 409].includes(ex.status), JSON.stringify(ex.b)); if (ex.status === 409) assert.match(ex.b.error, /이미 구매\(소유\)했는지/);
assert.equal((await api('/api/domains/external', { method: 'POST', body: JSON.stringify({ domain: 'x.example', mode: 'ns', owned: true }) }, 'x')).status, 401, '로그인 없이는 401');
assert.equal((await api('/api/domains/0123456789abcdef/status', {}, 'a')).status, 404, '남의/없는 도메인 상태는 404');
// 관리자 설정: 서브 도메인 등록 금지 → 중복 확인이 403 이유로 끝난다
const sv = await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ domains: { allowSubdomain: false, platformDomain: 'example.com' } }) }, 'r'); assert.equal(sv.status, 200, JSON.stringify(sv.b).slice(0, 200)); assert.equal(sv.b.domainsConfig.allowSubdomain, false);
const ck = await api('/api/domains/subdomain/check', { method: 'POST', body: JSON.stringify({ name: 'mystore' }) }, 'a'); assert.equal(ck.status, 403); assert.match(ck.b.error, /서브 도메인 등록을 받지 않습니다/);
await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ domains: { allowSubdomain: true } }) }, 'r');
});
test('v1.6.38 메일 서버 등록부 API · AI 제공자 저장이 등급 목록 포함 여부를 알려 준다', async () => {
// 요구 2: 메일 서버 등록부 — 경로가 있고(관리자 전용) 검증 문구가 나오며 비밀번호는 응답·작업 기록에 없다
assert.equal((await api('/api/admin/mail-servers', {}, 'a')).status, 403, '일반 회원은 403');
const l0 = await api('/api/admin/mail-servers', {}, 'r'); assert.equal(l0.status, 200, 'v1.6.38: mail-servers 가 관리자 경로 목록에 있어야 함'); assert.ok(Array.isArray(l0.b.servers));
const bad = await api('/api/admin/mail-servers', { method: 'POST', body: JSON.stringify({ nickname: '테스트', url: '', domain: 'mail.test', password: 'x' }) }, 'r'); assert.equal(bad.status, 400); assert.match(bad.b.error, /Arimail 주소를 입력하세요/);
const ok = await api('/api/admin/mail-servers', { method: 'POST', body: JSON.stringify({ nickname: '테스트 메일', ip: '10.9.8.7', url: 'http://127.0.0.1:1', domain: 'mail.test', adminLogin: 'wm', password: 'SecretPw!9' }) }, 'r'); assert.equal(ok.status, 201, JSON.stringify(ok.b)); assert.equal(ok.b.server.hasPassword, true); assert.ok(!JSON.stringify(ok.b).includes('SecretPw'));
const mail = await api('/api/admin/mail', {}, 'r'); assert.equal(mail.status, 200); assert.ok(mail.b.servers.some((x) => x.id === ok.b.server.id && x.ip === '10.9.8.7'), '메일 시스템 화면 응답에 서버 목록');
const t = await api(`/api/admin/mail-servers/${ok.b.server.id}/test`, { method: 'POST', body: '{}' }, 'r'); assert.equal(t.status, 200); assert.equal(t.b.ok, false); assert.match(t.b.detail, /연결할 수 없습니다/);
const au = await api('/api/admin/audit?action=mailserver.', {}, 'r'); assert.ok(au.b.items.some((x) => x.action === 'mailserver.save')); assert.ok(!JSON.stringify(au.b).includes('SecretPw'), '작업 기록에 비밀번호 없음');
const del = await api(`/api/admin/mail-servers/${ok.b.server.id}`, { method: 'DELETE' }, 'r'); assert.equal(del.status, 200); assert.ok(!del.b.servers.some((x) => x.id === ok.b.server.id));
// 요구 1: 키 없이 "모델 조회" 는 거절, 키 없이 저장하면 등급 목록에서 빠지는 이유를 응답으로
const ft = await api('/api/admin/providers/openrouter/test', { method: 'POST', body: JSON.stringify({ apiKey: '' }) }, 'r'); assert.equal(ft.status, 200); assert.equal(ft.b.ok, false); assert.match(ft.b.error, /API 키가 필요합니다/);
const sv = await api('/api/admin/providers/openrouter', { method: 'PUT', body: JSON.stringify({ enabled: true, models: [{ id: 'x/y', label: 'y' }] }) }, 'r'); assert.equal(sv.status, 200); assert.equal(sv.b.usable, false); assert.match(sv.b.unavailableReason, /API 키가 없습니다/);
const lp = await api('/api/admin/providers', {}, 'r'); const orp = lp.b.providers.find((x) => x.id === 'openrouter'); assert.equal(orp.usable, false); assert.match(orp.unavailableReason, /API 키/);
const st = await api('/api/admin/settings', {}, 'r'); assert.ok(st.b.unavailableProviders.some((x) => x.id === 'openrouter'));
await api('/api/admin/providers/openrouter', { method: 'PUT', body: JSON.stringify({ enabled: false }) }, 'r');
});
test('v1.6.39 메일 서버 비밀번호 보기·되돌리기·초기화 경로 · Route 53 안내 문구', async () => {
// 요구 4: [보기] 는 관리자만, 호출은 작업 기록에 남되 비밀번호는 기록에 없다
const ok = await api('/api/admin/mail-servers', { method: 'POST', body: JSON.stringify({ nickname: '비밀 테스트', url: 'http://127.0.0.1:1', domain: 'pw.test', adminLogin: 'wm', password: 'FirstPw!1' }) }, 'r'); assert.equal(ok.status, 201, JSON.stringify(ok.b)); const id = ok.b.server.id;
assert.equal(ok.b.server.hasPrevPassword, false); assert.equal(ok.b.server.lockedUntil, null); assert.equal(ok.b.server.adminUrl, 'http://127.0.0.1:1/admin');
assert.equal((await api(`/api/admin/mail-servers/${id}/password`, {}, 'a')).status, 403, '일반 회원은 비밀번호를 볼 수 없다');
const rv = await api(`/api/admin/mail-servers/${id}/password`, {}, 'r'); assert.equal(rv.status, 200); assert.equal(rv.b.password, 'FirstPw!1'); assert.equal(rv.b.adminLogin, 'wm');
const au = await api('/api/admin/audit?action=mailserver.reveal', {}, 'r'); assert.ok(au.b.items.some((x) => x.action === 'mailserver.reveal' && x.target === id)); assert.ok(!JSON.stringify(au.b).includes('FirstPw'));
// 요구 2: 되돌릴 이전 값이 없으면 404 안내, 비밀번호를 바꾸면 hasPrevPassword, 되돌리면 첫 값으로
assert.equal((await api(`/api/admin/mail-servers/${id}/revert-password`, { method: 'POST', body: '{}' }, 'r')).status, 404);
const ch = await api(`/api/admin/mail-servers/${id}`, { method: 'PUT', body: JSON.stringify({ nickname: '비밀 테스트', url: 'http://127.0.0.1:1', domain: 'pw.test', adminLogin: 'wm', password: 'SecondPw!2' }) }, 'r'); assert.equal(ch.status, 200); assert.equal(ch.b.server.hasPrevPassword, true);
assert.equal((await api(`/api/admin/mail-servers/${id}/password`, {}, 'r')).b.password, 'SecondPw!2');
const rev = await api(`/api/admin/mail-servers/${id}/revert-password`, { method: 'POST', body: '{}' }, 'r'); assert.equal(rev.status, 200); assert.equal(rev.b.server.hasPrevPassword, true, '맞바꾸기라 다시 되돌릴 수 있다');
assert.equal((await api(`/api/admin/mail-servers/${id}/password`, {}, 'r')).b.password, 'FirstPw!1');
// 요구 4: 초기화는 Arimail 에 닿아야 한다 — 닿지 않는 주소면 오류(비밀번호는 바뀌지 않음), via 비밀번호는 기록에 없다
const rs = await api(`/api/admin/mail-servers/${id}/reset-password`, { method: 'POST', body: JSON.stringify({ via: { login: 'root', password: 'RootSecret!' } }) }, 'r'); assert.ok(rs.status >= 400, JSON.stringify(rs.b)); assert.match(rs.b.error, /Arimail/);
assert.equal((await api(`/api/admin/mail-servers/${id}/password`, {}, 'r')).b.password, 'FirstPw!1');
const au2 = await api('/api/admin/audit?action=mailserver.reset-password', {}, 'r'); assert.ok(au2.b.items.some((x) => x.action === 'mailserver.reset-password' && x.ok === false)); assert.ok(!JSON.stringify(au2.b).includes('RootSecret'));
// 연결 확인 응답에 잠금 필드가 있다 (닿지 않는 서버라 locked:false)
const t = await api(`/api/admin/mail-servers/${id}/test`, { method: 'POST', body: '{}' }, 'r'); assert.equal(t.b.locked, false); assert.equal(t.b.lockedUntil, null);
await api(`/api/admin/mail-servers/${id}`, { method: 'DELETE' }, 'r');
// 요구 1: Route 53 접속 정보가 없을 때 무엇이 빠졌는지 말한다 (비밀값 없음)
assert.match(require('../lib/dns/route53').missingMessage(), /Route 53 접속 정보가 없습니다 — .*ACCESS_KEY_ID=/);
});
test('v1.6.40 캐시·충전: 상품 관리(관리자) · 사용자 주문 접수 · 입금 확인 적립 · 새 화면 파일', async () => {
assert.equal((await api('/api/admin/prepaid/packages', { method: 'POST', body: JSON.stringify({ name: 'x', amount: 10000 }) }, 'a')).status, 403, '일반 사용자는 상품을 만들 수 없음');
const mk = await api('/api/admin/prepaid/packages', { method: 'POST', body: JSON.stringify({ name: '스타터', amount: 20000, bonusType: 'percent', bonusValue: 15, note: '첫 충전' }) }, 'r'); assert.equal(mk.status, 200, JSON.stringify(mk.b)); assert.equal(mk.b.package.credit, 23000);
const bad = await api('/api/admin/prepaid/packages', { method: 'POST', body: JSON.stringify({ name: '', amount: 20000 }) }, 'r'); assert.equal(bad.status, 400); assert.match(bad.b.error, /이름/);
const pk = await api('/api/credits/packages', {}, 'b'); assert.equal(pk.status, 200); assert.equal(pk.b.pgReady, false, 'PG 미연동 표시'); assert.equal(pk.b.packages.length, 1); assert.equal(pk.b.packages[0].credit, 23000);
const before = (await api('/api/me/credit', {}, 'b')).b.balance;
const od = await api('/api/credits/orders', { method: 'POST', body: JSON.stringify({ packageId: mk.b.package.id }) }, 'b'); assert.equal(od.status, 201, JSON.stringify(od.b)); assert.equal(od.b.order.status, 'pending');
assert.equal((await api('/api/me/credit', {}, 'b')).b.balance, before, '주문 접수만으로는 적립되지 않음');
const adm = await api('/api/admin/prepaid?status=pending', {}, 'r'); assert.equal(adm.status, 200); assert.equal(adm.b.summary.pendingOrders, 1); assert.equal(adm.b.orders[0].userId, 'bob');
assert.equal((await api(`/api/admin/prepaid/orders/${od.b.order.id}`, { method: 'POST', body: JSON.stringify({ action: 'paid', memo: '계좌이체' }) }, 'a')).status, 403, '일반 사용자는 적립 불가');
const paid = await api(`/api/admin/prepaid/orders/${od.b.order.id}`, { method: 'POST', body: JSON.stringify({ action: 'paid', memo: '계좌이체 확인' }) }, 'r'); assert.equal(paid.status, 200, JSON.stringify(paid.b)); assert.equal(paid.b.balance, before + 23000, '결제 금액 + 인센티브 적립');
assert.equal((await api(`/api/admin/prepaid/orders/${od.b.order.id}`, { method: 'POST', body: JSON.stringify({ action: 'paid' }) }, 'r')).status, 409, '같은 주문 두 번 적립 불가');
assert.equal((await api(`/api/credits/orders/${od.b.order.id}/cancel`, { method: 'POST', body: '{}' }, 'b')).status, 409, '적립된 주문은 취소 불가');
const mine = await api('/api/credits/orders', {}, 'b'); assert.equal(mine.b.orders[0].status, 'paid'); assert.equal((await api('/api/credits/orders', {}, 'a')).b.orders.length, 0, '다른 사용자 주문은 보이지 않음');
const au = await api('/api/admin/audit?action=prepaid.', {}, 'r'); assert.ok(au.b.items.some((x) => x.action === 'prepaid.order.paid' && x.detail && x.detail.credit === 23000));
// 새 사용자 화면: 레일 내비게이션(홈·배포·DNS·메일·캐시)과 새 화면 파일이 서빙된다
const page = await fetch(`${B}/`, { headers: { cookie: jar.b, referer: `${B}/` }, redirect: 'manual' }); const html = await page.text(); assert.equal(page.status, 200);
for (const t of ['data-tab="home"', 'data-tab="deploy"', 'data-tab="dns"', 'data-tab="mail"', 'data-tab="credits"', 'id="deployChecklist"', 'id="creditsBody"', '/flow-ui.js', '/credits-ui.js']) assert.ok(html.includes(t), '화면에 ' + t);
for (const f of ['/flow-ui.js', '/credits-ui.js']) { const r = await fetch(B + f, { headers: { cookie: jar.b } }); assert.equal(r.status, 200, f); }
});
test('v1.7.3 메일 사용량 경로: 사이트(메일 꺼짐 → 이유) · 통계에 mailUsage 없음 · 관리자 서버 사용량/키 경로', async () => {
const mk = await api('/api/sites', { method: 'POST', body: JSON.stringify({ title: 'Usage site', purpose: 'p', content: 'c' }) }, 'a'); assert.ok(mk.status < 300, JSON.stringify(mk.b).slice(0, 120)); const id = (mk.b.site && mk.b.site.id) || mk.b.id;
const mu = await api(`/api/sites/${id}/mailusage`, {}, 'a'); assert.equal(mu.status, 200); assert.equal(mu.b.ok, false); assert.equal(mu.b.code, 'mail_off');
assert.equal((await api(`/api/sites/${id}/mailusage`, {}, 'b')).status, 404, '다른 사용자는 404');
const st = await api(`/api/sites/${id}/stats`, {}, 'a'); assert.equal(st.status, 200); assert.equal(st.b.disk.mail, null, '메일 꺼진 사이트는 disk.mail null'); assert.equal(st.b.mailUsage, undefined);
const us = await api('/api/admin/mail-servers/nope/usage', {}, 'r'); assert.equal(us.status, 200); assert.equal(us.b.ok, false); assert.equal(us.b.error.code, 'no_server');
const uk = await api('/api/admin/mail-servers/nope/usage-key', { method: 'POST', body: JSON.stringify({ action: 'issue' }) }, 'r'); assert.equal(uk.status, 404);
assert.equal((await api('/api/admin/mail-servers/nope/usage-key', { method: 'POST', body: JSON.stringify({ action: 'bogus' }) }, 'r')).status, 400);
assert.equal((await api('/api/admin/mail-servers/nope/usage', {}, 'a')).status, 403, '일반 사용자는 관리자 경로 403');
for (const f of ['/site-info.js', '/domains-ui.js', '/admin.js']) { const r = await fetch(B + f); assert.equal(r.status, 200); const t = await r.text(); assert.ok(/mailusage|usage-key|mailUsage/.test(t), f + ' 에 사용량 코드 포함'); }
});
test('로그인 실패 잠금·속도 제한', async () => {
let last; for (let i = 0; i < 11; i++) last = await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ id: 'alice', password: 'wrong' }) }, 'z');
assert.equal(last.status, 429, '10회 실패 뒤 계정 잠금');
});
test('손상 파일 감지: credits.json 이 깨지면 운영 복구에 보인다', async () => {
try { fs.unlinkSync(path.join(tmp, 'credits.json.bak')); } catch (_) {} // v1.6.40: 앞 테스트(선불 적립)가 원장을 다시 써 .bak 이 생긴다 — 이 테스트는 "백업도 없는" 손상을 본다
fs.writeFileSync(path.join(tmp, 'credits.json'), '{oops'); const r = await api('/api/admin/credits', {}, 'r'); assert.ok(r.status >= 500, '빈 원장으로 숨기지 않음: ' + r.status);
const rc = await api('/api/admin/recovery', {}, 'r'); assert.ok(rc.b.items.some((x) => x.kind === 'data'));
});