/** test/domains.test.mjs — 도메인 영역 단위 테스트(서비스 직접 호출): 이름 정규화·중복·필요 DNS 값·메일 레코드 연동·해제·운영자 목록. 연결 작업 흐름은 test/sites.test.mjs 가 함께 검증한다. */ import { test, eq, ok, rejects, done } from '../lib/test.mjs'; import { makeDeps, seedGroup, appFor, call } from './helpers.mjs'; import { createFakeHal } from '../portal/sites/fake-hal.mjs'; import { createFake as fakeWebmaker } from '../portal/webmaker/adapter.mjs'; import { signHeaders } from '../lib/serviceauth.mjs'; import * as quota from '../lib/quota.mjs'; const setup = async () => { const deps = await makeDeps(); deps.hal = createFakeHal(); deps.webmaker = fakeWebmaker({ store: deps.store }); const who = seedGroup(deps); quota.grantEntitlement(deps.store, { groupId: who.group.id, resource: 'sites', amount: 1 }); const app = await appFor(deps, ['sites', 'domains'], who); return { deps, who, app, D: deps.domains }; }; await test('이름 정규화·형식 검사·용도·필요 DNS 값(복사용)·메일 레코드는 메일 영역에서 제공', async () => { const { deps, who, app, D } = await setup(); eq(D.normalize(' HTTPS://Example.COM/path '), 'example.com'); await rejects(() => Promise.resolve().then(() => D.add({ group: who.group, user: who.user }, { name: 'bad_domain' })), 'bad_domain'); await rejects(() => Promise.resolve().then(() => D.add({ group: who.group, user: who.user }, { name: 'a.com', purpose: 'x' })), 'bad_purpose'); const d = D.add({ group: who.group, user: who.user }, { name: 'Mail.Example.org', purpose: 'both' }); eq(d.name, 'mail.example.org'); let recs = await D.requiredRecords(d); ok(recs.find((r) => r.type === 'TXT' && r.name === '_ariweb-verify.mail.example.org')); const a = recs.find((r) => r.type === 'A'); eq(a.value, null); ok(/관리형 배포/.test(a.note)); ok(recs.find((r) => /MX/.test(r.type) && /메일 서비스 화면/.test(r.note))); deps.mailDns = { recordsFor: async (name) => [{ type: 'MX', name, value: `mail.ariweb.net`, prio: 10, state: 'pending' }, { type: 'TXT', name, value: 'v=spf1 include:_spf.ariweb.net ~all', purpose: 'SPF', state: 'pending' }] }; recs = await D.requiredRecords(d); ok(recs.find((r) => r.type === 'MX' && r.value === 'mail.ariweb.net')); ok(recs.find((r) => r.purpose === 'SPF')); eq(D.setMailDnsState('mail.example.org', 'warn', { dkim: 'missing' }).mail_dns_state, 'warn'); eq(D.list(who.group.id)[0].mailDnsState, 'warn'); // 사이트에 연결된 관리형 도메인은 서버 공인 IP 를 A 값으로 const s = (await call(app, 'POST', '/api/sites', { name: 'S', projectMode: 'none', deployMode: 'managed' })).json.site; deps.sites.save(s.id, { server_id: 'srv_web2' }); const d2 = D.add({ group: who.group, user: who.user }, { name: 'web.example.org', purpose: 'web', siteId: s.id }); eq((await D.requiredRecords(d2)).find((r) => r.type === 'A').value, '203.0.113.20'); eq(D.detail(D.get(d2.id)).actionNeeded.code, 'ownership'); }); await test('중복·소속 검사·연결 대상 변경·해제·API 권한', async () => { const { deps, who, app, D } = await setup(); const d = D.add({ group: who.group, user: who.user }, { name: 'dup.example', purpose: 'web' }); const e = await rejects(() => Promise.resolve().then(() => D.add({ group: who.group, user: who.user }, { name: 'dup.example' })), 'domain_taken'); ok(/이미 등록한/.test(e.message)); eq(e.detail.href, `/domains/${d.id}`); const other = seedGroup(deps, { email: 'o@example.test' }); const e2 = await rejects(() => Promise.resolve().then(() => D.add({ group: other.group, user: other.user }, { name: 'dup.example' })), 'domain_taken'); ok(/다른 고객/.test(e2.message)); eq(e2.detail.href, null); await rejects(() => Promise.resolve().then(() => D.add({ group: other.group, user: other.user }, { name: 'x.example', siteId: 'nope' })), 'not_found'); const s = (await call(app, 'POST', '/api/sites', { name: 'S', projectMode: 'none' })).json.site; const up = D.update(d, { siteId: s.id, purpose: 'both' }, who.user); eq(up.site_id, s.id); eq(up.purpose, 'both'); eq(up.dns_state, 'pending'); const r = await call(app, 'PATCH', `/api/domains/${d.id}`, { purpose: 'web' }); eq(r.status, 200); eq(r.json.domain.purpose, 'web'); // viewer 는 변경 불가 const now = new Date().toISOString(); deps.store.run('INSERT INTO users(id, email, display_name, created_at, updated_at) VALUES(?,?,?,?,?)', 'u_v', 'v@example.test', 'V', now, now); deps.store.run('INSERT INTO memberships(group_id, user_id, role, joined_at) VALUES(?,?,\'viewer\',?)', who.group.id, 'u_v', now); const appV = await appFor(deps, ['sites', 'domains'], { user: deps.store.get('SELECT * FROM users WHERE id=\'u_v\''), group: who.group, membership: deps.store.get('SELECT * FROM memberships WHERE user_id=\'u_v\'') }); eq((await call(appV, 'DELETE', `/api/domains/${d.id}`)).status, 403); eq((await call(appV, 'GET', `/api/domains/${d.id}`)).status, 200); eq((await call(app, 'DELETE', `/api/domains/${d.id}`)).json.released, true); eq(D.get(d.id), null); eq(deps.store.get('SELECT COUNT(*) AS n FROM audit_log WHERE action=\'domain.release\'').n, 1); }); await test('운영자 목록: 서명 필요·반복 실패·만료 임박', async () => { const { deps, who, app, D } = await setup(); deps.vault.set('service:hal', 'hal-secret-example'); const d = D.add({ group: who.group, user: who.user }, { name: 'ops.example', purpose: 'web' }); deps.store.run('UPDATE domains SET cert_state=\'valid\', cert_expires_at=? WHERE id=?', new Date(Date.now() + 3 * 86400000).toISOString(), d.id); for (let i = 0; i < 3; i++) deps.store.run('INSERT INTO domain_checks(id, domain_id, kind, ok, checked_at) VALUES(?,?,\'dns\',0,?)', 'dc' + i, d.id, new Date().toISOString()); eq((await call(app, 'GET', '/internal/ops/domains')).status, 401); const h = Object.fromEntries(Object.entries(signHeaders('hal', 'hal-secret-example', 'GET', '/internal/ops/domains', undefined)).map(([k, v]) => [k.toLowerCase(), v])); const r = await call(app, 'GET', '/internal/ops/domains', undefined, h); eq(r.status, 200, r.text); eq(r.json.expiring.length, 1); eq(r.json.repeatedFailures.length, 1); eq(r.json.domains[0].recentFailures, 3); }); done();