Files
AriWeb-Suite/test/mail.test.mjs
T

186 lines
36 KiB
JavaScript

/** test/mail.test.mjs — 메일 영역(§15~§18·§23.2·§14) 회귀 테스트: 생성 규칙·배정·메일함·경고·100%·전환·해지·운영자·어댑터(가짜 AriMail HTTP 서버). */
import http from 'node:http';
import { test, eq, ok, rejects, done } from '../lib/test.mjs';
import { makeDeps, seedGroup, appFor, call } from './helpers.mjs';
import { createFake, create as createAdapter } from '../portal/mail/arimail.mjs';
import * as svc from '../portal/mail/service.mjs';
import * as quota from '../lib/quota.mjs';
import { listNotifications } from '../lib/notify.mjs';
import { listAudit } from '../lib/audit.mjs';
import { newId } from '../lib/crypto.mjs';
import { nowIso } from '../lib/db.mjs';
/** 그룹·사이트·도메인·권리를 갖춘 시험 환경 */
async function env({ entitle = true } = {}) {
const deps = await makeDeps(); const fake = createFake(); deps.arimail = fake; deps.opsAuthBypass = true; deps.dnsResolver = { mx: async (n) => [`mail.${n}`], txt: async (n) => (/_domainkey/.test(n) ? ['v=DKIM1; k=rsa; p=FAKEPUBLICKEY'] : /_dmarc/.test(n) ? ['v=DMARC1; p=quarantine'] : ['v=spf1 a mx -all']) }; deps.tcpProbe = async () => true;
const w = seedGroup(deps); const g = w.group.id; const now = nowIso();
const site = newId('s_'); deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, primary_domain, created_at, updated_at) VALUES(?,?,?,\'live\',?,?,?)', site, g, '내 사이트', 'site.example.test', now, now);
for (const [name, sid] of [['site.example.test', site], ['solo.example.test', null], ['other.example.test', null]]) deps.store.run('INSERT INTO domains(id, group_id, name, purpose, site_id, ownership_state, created_at, updated_at) VALUES(?,?,?,?,?,\'verified\',?,?)', newId('d_'), g, name, sid ? 'web' : 'mail', sid, now, now);
if (entitle) { quota.grantEntitlement(deps.store, { groupId: g, resource: 'mailboxes', amount: 5 }); quota.grantEntitlement(deps.store, { groupId: g, resource: 'mail_storage', amount: 10 * 1024 ** 3 }); quota.grantEntitlement(deps.store, { groupId: g, resource: 'mail_msgs', amount: 1000 }); quota.grantEntitlement(deps.store, { groupId: g, resource: 'mail_services', amount: 1 }); }
deps.config.values = { mailVerifier: 'verify@external.example.test' };
const app = await appFor(deps, ['mail'], w);
return { deps, fake, w, g, site, app };
}
const alloc = { mailboxes: 2, storageBytes: 1024 ** 3, monthlyMessages: 100 };
await test('생성 규칙: 사이트 대표 메일은 사이트 연결 도메인만·사이트당 하나·사이트 수 소비 없음, 독립은 mail_services 권리 소비, 권리 없으면 안내', async () => {
const { deps, g, site, app, fake } = await env();
let r = await call(app, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: site, domain: 'solo.example.test', alloc }); eq(r.json.error, 'mail_domain_not_site', '사이트 연결 도메인만');
r = await call(app, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: site, domain: 'site.example.test', alloc }); eq(r.status, 200, '대표 메일 생성: ' + JSON.stringify(r.json).slice(0, 200)); const s1 = r.json.service; eq(s1.kind, 'site_bound'); eq(s1.status, 'needs_setup');
eq(quota.figures(deps.store, g, 'sites').allocated, 0, '운영 사이트 수를 쓰지 않음'); eq(quota.figures(deps.store, g, 'mailboxes').allocated, 2, '메일함 배정'); eq(quota.figures(deps.store, g, 'mail_services').allocated, 0, '대표 메일은 독립 옵션을 쓰지 않음');
ok(fake.state.domains.has('site.example.test'), 'AriMail 테넌트 준비'); eq(fake.state.domains.get('site.example.test').ariweb.kind, 'site_bound', 'ariweb 표시'); eq(fake.state.configs.get('site.example.test').LIMIT_ACCOUNTS, 2, '도메인별 보장량 적용');
r = await call(app, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: site, domain: 'site.example.test', alloc }); eq(r.json.error, 'mail_domain_in_use', '같은 도메인 재사용 불가');
deps.store.run('INSERT INTO domains(id, group_id, name, purpose, site_id, ownership_state, created_at, updated_at) VALUES(?,?,?,?,?,\'verified\',?,?)', newId('d_'), g, 'two.example.test', 'web', site, nowIso(), nowIso());
r = await call(app, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: site, domain: 'two.example.test', alloc: { mailboxes: 1 } }); eq(r.json.error, 'mail_site_has_primary', '사이트당 대표 메일 하나');
r = await call(app, 'POST', '/api/mail/services', { kind: 'standalone', domain: 'solo.example.test', alloc: { mailboxes: 1, storageBytes: 1024 ** 3, monthlyMessages: 10 } }); eq(r.status, 200, '독립 생성'); eq(quota.figures(deps.store, g, 'mail_services').allocated, 1, '독립 옵션 1 소비'); eq(quota.figures(deps.store, g, 'sites').allocated, 0, '사이트 수 소비 없음');
r = await call(app, 'POST', '/api/mail/services', { kind: 'standalone', domain: 'other.example.test', alloc: { mailboxes: 1 } }); eq(r.json.error, 'quota_insufficient', '독립 옵션 부족 안내'); eq(r.json.detail.link, '/billing', '상품 안내 링크');
r = await call(app, 'POST', '/api/mail/services', { kind: 'standalone', domain: 'nowhere.example.test', alloc: { mailboxes: 1 } }); eq(r.json.error, 'mail_domain_unknown', '등록되지 않은 도메인');
// 배정 산술: 남은 메일함 5-2-1=2 → 3 요청은 거부
const { deps: d2, app: a2, site: s2 } = await env(); r = await call(a2, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: s2, domain: 'site.example.test', alloc: { mailboxes: 6 } }); eq(r.json.error, 'quota_insufficient', '구매 총량 초과'); eq(d2.store.get('SELECT COUNT(*) AS n FROM mail_services').n, 0, '실패하면 서비스를 만들지 않음');
const { app: a3 } = await env({ entitle: false }); r = await call(a3, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: (await env({ entitle: false })).site, domain: 'site.example.test', alloc: { mailboxes: 1 } }); ok(['quota_insufficient', 'not_found'].includes(r.json.error), '권리 없으면 생성 불가');
const list = await call(app, 'GET', '/api/mail/services'); eq(list.json.services.length, 2, '목록'); ok(list.json.notes.noBackup.includes('백업'), '메일 백업 미제공 문구');
});
await test('활성화 작업: 권리 → DNS(미반영이면 외부 대기) → 준비 → 외부 왕복 확인(검증 주소 없으면 needs_user) → 활성화', async () => {
const { deps, app, site, fake } = await env();
const s = (await call(app, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: site, domain: 'site.example.test', alloc })).json.service;
deps.dnsResolver = { mx: async () => [], txt: async () => [] };
let r = await call(app, 'POST', `/api/mail/services/${s.id}/dns-check`); eq(r.json.result.ok, false, 'DNS 미반영'); ok(r.json.result.summary.missing >= 3, '누락 수'); eq(r.json.result.items.find((i) => i.kind === 'mx').state, 'missing', 'MX 미반영');
deps.dnsResolver = { mx: async () => ['mail.wrong.test'], txt: async () => ['v=spf1 include:other -all'] }; r = await call(app, 'POST', `/api/mail/services/${s.id}/dns-check`); eq(r.json.result.items.find((i) => i.kind === 'mx').state, 'wrong', '잘못된 값과 미반영 구분');
r = await call(app, 'POST', `/api/mail/services/${s.id}/activate`); eq(r.status, 200); await deps.jobs.drain(); let j = (await call(app, 'GET', `/api/mail/services/${s.id}/job`)).json.job; eq(j.status, 'awaiting_external', 'DNS 대기'); ok(j.waitReason.includes('DNS'), '대기 사유');
eq((await call(app, 'POST', `/api/mail/services/${s.id}/activate`)).json.job.id, j.id, '중복 활성화 없음(멱등)');
deps.dnsResolver = { mx: async (n) => [`mail.${n}`], txt: async (n) => (/_domainkey/.test(n) ? ['v=DKIM1; k=rsa; p=FAKEPUBLICKEY'] : /_dmarc/.test(n) ? ['v=DMARC1; p=quarantine'] : ['v=spf1 a mx -all']) };
deps.config.values.mailVerifier = ''; await call(app, 'POST', `/api/mail/services/${s.id}/job/resume`); await deps.jobs.drain(); j = (await call(app, 'GET', `/api/mail/services/${s.id}/job`)).json.job; eq(j.status, 'needs_user', '검증 주소 없으면 사용자 확인'); ok(j.userMessage.includes('검증 주소'), '안내'); eq(deps.store.get('SELECT status FROM mail_services WHERE id=?', s.id).status, 'activating', '아직 활성화 아님');
deps.config.values.mailVerifier = 'verify@external.example.test'; await call(app, 'POST', `/api/mail/services/${s.id}/job/resume`); await deps.jobs.drain(); j = (await call(app, 'GET', `/api/mail/services/${s.id}/job`)).json.job; eq(j.status, 'done', '활성화 완료: ' + JSON.stringify(j.error)); eq(deps.store.get('SELECT status, send_state, receive_state FROM mail_services WHERE id=?', s.id).status, 'active', 'active');
ok(fake.state.calls.includes('roundTrip'), '실제 왕복 확인 호출'); ok(listNotifications(deps.store, { groupId: s.group_id }).some((n) => n.title.includes('활성화')), '알림');
// 왕복 실패면 활성화하지 않음
fake.state.roundTrip = { sent: true, received: false, replied: false }; const d = (await call(app, 'GET', `/api/mail/services/${s.id}`)).json; eq(d.service.status, 'active'); r = await call(app, 'POST', `/api/mail/services/${s.id}/verify`); eq(r.json.test.status, 'failed', '수동 확인 실패 표시'); eq(deps.store.get('SELECT receive_state FROM mail_services WHERE id=?', s.id).receive_state, 'error', '수신 상태 오류');
});
await test('메일함: 사전 확인(남은 수·공간), 초기 비밀번호 1회 표시, 초대 링크 만료·재발송·사용 완료, 설정·정지·세션 종료·삭제, 정지도 수를 소비', async () => {
const { deps, app, site, fake } = await env();
// 메일함 3개 배정: 서비스 관리자 메일함(admin@도메인, AriMail 이 도메인을 만들 때 생김)도 메일함 수를 소비한다 → kim·lee 까지 가능, park 은 한도.
const s = (await call(app, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: site, domain: 'site.example.test', alloc: { ...alloc, mailboxes: 3 } })).json.service;
let r = await call(app, 'POST', `/api/mail/services/${s.id}/mailboxes`, { local: 'Bad Name', displayName: 'x' }); eq(r.json.error, 'validation', '주소 형식');
r = await call(app, 'POST', `/api/mail/services/${s.id}/mailboxes`, { local: 'kim', displayName: '김', quotaBytes: 100 * 1024 ** 2 }); eq(r.status, 200, '생성: ' + JSON.stringify(r.json).slice(0, 200)); ok(r.json.initialPassword && r.json.initialPassword.length >= 12, '초기 비밀번호 1회'); eq(r.json.mailbox.mustChangePassword, true, '변경 필요'); ok(r.json.program.imap.host, '접속 안내');
const again = await call(app, 'GET', `/api/mail/services/${s.id}/mailboxes/${r.json.mailbox.id}`); ok(!JSON.stringify(again.json).includes(r.json.initialPassword), '비밀번호 재표시 없음');
r = await call(app, 'POST', `/api/mail/services/${s.id}/mailboxes`, { local: 'lee', mode: 'invite', inviteEmail: 'lee@external.example.test' }); eq(r.status, 200); ok(r.json.invite.url.includes('/mail/invite/'), '초대 링크'); eq(r.json.mailbox.inviteState, 'pending', '초대 대기'); const lee = r.json.mailbox; const token = r.json.invite.url.split('/').pop();
r = await call(app, 'POST', `/api/mail/services/${s.id}/mailboxes`, { local: 'park' }); eq(r.json.error, 'mailbox_limit', '메일함 수 한도(3)'); eq(r.json.detail.link, '/usage', '배정 안내');
r = await call(app, 'POST', '/api/mail/invite/accept', { token, password: 'short' }); eq(r.json.error, 'weak_password'); r = await call(app, 'POST', '/api/mail/invite/accept', { token, password: 'new-password-12' }); eq(r.status, 200, '초대 수락'); eq(r.json.address, 'lee@site.example.test');
r = await call(app, 'POST', '/api/mail/invite/accept', { token, password: 'new-password-12' }); eq(r.json.error, 'invite_used', '사용 완료 표시'); eq((await call(app, 'GET', `/api/mail/services/${s.id}/mailboxes/${lee.id}`)).json.invite.state, 'used', '초대 상태');
deps.store.run('UPDATE mail_invites SET used_at=NULL, expires_at=? WHERE mailbox_id=?', new Date(0).toISOString(), lee.id); eq((await call(app, 'POST', '/api/mail/invite/accept', { token, password: 'new-password-12' })).json.error, 'invite_expired', '만료'); r = await call(app, 'POST', `/api/mail/services/${s.id}/mailboxes/${lee.id}/invite`, {}); ok(r.json.invite.url, '재발송'); eq((await call(app, 'GET', `/api/mail/services/${s.id}/mailboxes/${lee.id}`)).json.invite.resent_count, 1, '재발송 횟수');
const kim = (await call(app, 'GET', `/api/mail/services/${s.id}/mailboxes`)).json.mailboxes.find((m) => m.address === 'kim@site.example.test');
r = await call(app, 'PATCH', `/api/mail/services/${s.id}/mailboxes/${kim.id}`, { displayName: '김철수', quotaBytes: 200 * 1024 ** 2 }); eq(r.json.mailbox.displayName, '김철수'); eq(fake.state.users.get('site.example.test').find((u) => u.login === 'kim').quotaBytes, 200 * 1024 ** 2, 'AriMail 반영');
r = await call(app, 'PATCH', `/api/mail/services/${s.id}/mailboxes/${kim.id}`, { quotaBytes: 5 * 1024 ** 3 }); eq(r.json.error, 'storage_limit', '배정 초과 거부');
r = await call(app, 'PATCH', `/api/mail/services/${s.id}/mailboxes/${kim.id}`, { resetPassword: true }); ok(r.json.newPassword, '재설정 비밀번호 1회');
r = await call(app, 'POST', `/api/mail/services/${s.id}/mailboxes/${kim.id}/sessions/end`, {}); eq(r.json.passwordReset, true, '세션 종료');
r = await call(app, 'PATCH', `/api/mail/services/${s.id}/mailboxes/${kim.id}`, { status: 'suspended' }); eq(r.json.mailbox.status, 'suspended'); eq((await call(app, 'GET', `/api/mail/services/${s.id}/mailboxes`)).json.capacity.mailboxes.remaining, 0, '정지도 수를 소비');
r = await call(app, 'DELETE', `/api/mail/services/${s.id}/mailboxes/${kim.id}`, { confirm: 'wrong' }); eq(r.json.error, 'confirm_required'); r = await call(app, 'DELETE', `/api/mail/services/${s.id}/mailboxes/${kim.id}`, { confirm: 'kim@site.example.test' }); eq(r.status, 200); eq((await call(app, 'GET', `/api/mail/services/${s.id}/mailboxes`)).json.capacity.mailboxes.remaining, 1, '삭제 뒤 반환');
ok(listAudit(deps.store, { groupId: s.group_id, action: 'mail.mailbox' }).length >= 5, '감사 기록'); ok(!JSON.stringify(listAudit(deps.store, { groupId: s.group_id })).includes('new-password-12'), '감사에 비밀번호 없음');
// 서버 연결 불가: 503 + 정보 확인 지연
fake.state.failWith = 'service_unreachable'; r = await call(app, 'POST', `/api/mail/services/${s.id}/mailboxes`, { local: 'zzz' }); eq(r.json.error, 'service_unreachable'); ok(r.json.message.includes('정보 확인 지연'), '지연 안내'); fake.state.failWith = null;
});
await test('사용량·경고·100%: 계측이 quota_usage 와 메일함에 기록되고 70/80/90/95/100 알림이 사건으로 묶이며 100% 문구·다음 KST 월·발송 제한·정책 미확정 표시', async () => {
const { deps, app, site, fake, g } = await env();
const s = (await call(app, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: site, domain: 'site.example.test', alloc })).json.service;
const kim = (await call(app, 'POST', `/api/mail/services/${s.id}/mailboxes`, { local: 'kim', quotaBytes: 1000 })).json.mailbox;
fake.state.usage.set('site.example.test', { storageBytes: 0.95 * 1024 ** 3, monthly: 100, sent: 60, received: 40, monthReset: '2026-10-31T15:00:00.000Z' }); fake.state.users.get('site.example.test').find((u) => u.login === 'kim').bytes = 950;
const c = await svc.collectMailUsage(deps, s.id); eq(c.ok, true); eq(quota.usageOf(deps.store, g, 'mail_storage', 'mail_service', s.id).used, Math.round(0.95 * 1024 ** 3), 'quota_usage 저장 공간'); eq(quota.usageOf(deps.store, g, 'mail_msgs', 'mail_service', s.id).used, 100, '월간 통수');
const u = (await call(app, 'GET', `/api/mail/services/${s.id}/usage`)).json; eq(u.figures.storage.level, 95, '95% 구간'); eq(u.figures.monthly.level, 100, '월간 100%'); eq(u.lastUsage.sent, 60, '송신'); eq(u.lastUsage.received, 40, '수신'); eq(u.lastUsage.countingRule, 'per_final_recipient', '수신자 기준'); ok(u.notes.counting.includes('최종 수신자'), '통수 설명'); ok(u.notes.limit100.monthly.includes('새 발신을 막고'), '월간 100% 문구'); ok(u.nextReset, '다음 KST 월'); ok(u.measuredAt, '마지막 계측'); eq(u.policy.largeAttachment.deferred, true, '대용량 첨부 정책 미확정'); ok(u.sendLimits.note.includes('초당 10건'), '발송 제한 안내');
const mb = (await call(app, 'GET', `/api/mail/services/${s.id}/mailboxes/${kim.id}`)).json.mailbox; eq(mb.level, 95, '메일함 95%'); const n1 = listNotifications(deps.store, { groupId: g }).filter((n) => n.subject_id === kim.id); eq(n1.length, 1, '메일함 경고 1건'); eq(n1[0].incident_key, `${g}:mail_storage:${kim.id}:95`, '사건 키 규약');
await svc.collectMailUsage(deps, s.id); eq(listNotifications(deps.store, { groupId: g }).filter((n) => n.subject_id === kim.id).length, 1, '같은 구간은 묶임'); eq(listNotifications(deps.store, { groupId: g }).filter((n) => n.subject_id === kim.id)[0].count, 2, '갱신 횟수');
fake.state.users.get('site.example.test').find((u) => u.login === 'kim').bytes = 1000; await svc.collectMailUsage(deps, s.id); const n2 = listNotifications(deps.store, { groupId: g }).filter((n) => n.subject_id === kim.id && n.incident_key.endsWith(':100')); eq(n2.length, 1, '100% 알림'); eq(n2[0].mandatory, 1, '필수 알림'); ok(n2[0].body.includes('임시 실패'), '100% 동작 설명'); eq(deps.store.get('SELECT status FROM mailboxes WHERE id=?', kim.id).status, 'over_quota', '용량 초과 상태');
fake.state.failWith = 'service_unreachable'; const c2 = await svc.collectMailUsage(deps, s.id); eq(c2.ok, false); fake.state.failWith = null; const u2 = (await call(app, 'GET', `/api/mail/services/${s.id}/usage`)).json; eq(u2.usageError, 'service_unreachable', '정보 확인 지연 표시, 마지막 값 유지'); eq(u2.figures.monthly.used, 100, '마지막 계측값');
// 정책 확정 시 표시
svc.writePolicy(deps, { largeAttachment: { maxBytes: 500 * 1024 ** 2, maxDownloads: 10, maxDays: 7 } }); eq(svc.readPolicy(deps).largeAttachment.deferred, false, '정책 확정');
});
await test('도메인 보안(§18)·POP/IMAP 스위치(영향 확인 + 적용 후 접속 확인)·웹메일 SSO·메일 프로그램 설정(비밀번호 없음)', async () => {
const { deps, app, site, fake } = await env();
const s = (await call(app, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: site, domain: 'site.example.test', alloc })).json.service;
const sec = (await call(app, 'GET', `/api/mail/services/${s.id}/security`)).json.report; eq(sec.items.find((i) => i.id === 'spf').state, 'ok', 'SPF 정상'); eq(sec.items.find((i) => i.id === 'dkim').state, 'ok', 'DKIM'); eq(sec.items.find((i) => i.id === 'dmarc').state, 'ok', 'DMARC'); eq(sec.items.find((i) => i.id === 'roundtrip').state, 'recommended', '시험 전'); ok(sec.records.every((r) => r.value), '복사 가능한 값');
deps.dnsResolver = { mx: async (n) => [`mail.${n}`], txt: async (n) => (/_domainkey/.test(n) ? ['v=DKIM1; k=rsa; p=FAKEPUBLICKEY'] : /_dmarc/.test(n) ? [] : ['v=spf1 a mx -all']) }; deps.store.run('UPDATE mail_services SET settings_json=\'{}\' WHERE id=?', s.id);
const sec2 = (await call(app, 'GET', `/api/mail/services/${s.id}/security`)).json.report; eq(sec2.items.find((i) => i.id === 'dmarc').state, 'recommended', 'DMARC 없으면 권장 개선');
let r = await call(app, 'POST', `/api/mail/services/${s.id}/protocols`, { pop: false }); eq(r.json.error, 'confirm_required', '영향 확인 필요'); ok(r.json.message.includes('메일 프로그램'), '영향 안내');
r = await call(app, 'POST', `/api/mail/services/${s.id}/protocols`, { pop: false, confirmed: true }); eq(r.json.services.pop, false, 'POP 끔'); eq(r.json.check.summary, 'ok', '적용 후 접속 확인'); eq(deps.store.get('SELECT pop_enabled FROM mail_services WHERE id=?', s.id).pop_enabled, 0);
deps.tcpProbe = async (h, p) => p !== 995; r = await call(app, 'POST', `/api/mail/services/${s.id}/protocols`, { imap: true, confirmed: true }); eq(r.json.check.summary, 'partial', '일부 포트 응답 없음 표시');
const kim = (await call(app, 'POST', `/api/mail/services/${s.id}/mailboxes`, { local: 'kim' })).json.mailbox;
r = await call(app, 'POST', `/api/mail/services/${s.id}/webmail`, { mailboxId: kim.id }); eq(r.json.sso, true, 'SSO'); ok(r.json.url.includes('/api/auth/sso?ticket='), 'SSO 주소');
const d = (await call(app, 'GET', `/api/mail/services/${s.id}/mailboxes/${kim.id}`)).json; ok(d.program.imap.ports.tls === 993 && d.program.smtp.ports.starttls === 587, '접속 정보'); ok(!JSON.stringify(d).toLowerCase().includes('password":"'), '비밀번호 없음');
r = await call(app, 'POST', `/api/mail/services/${s.id}/aliases`, { address: 'info', target: 'kim' }); ok(['policy_deferred', 'unsupported_in_version'].includes(r.json.error), '별칭: 정책 미확정이면 거부'); ok(r.json.message.includes('상품정책'), '정책 안내');
});
await test('전환(§14): 점검표(주소·데이터·별칭·전달·비밀번호·DNS·옵션·작업), 옵션 부족이면 차단, 전후 확인 뒤 독립 전환, 누락이면 미완료, canReturnSiteCount', async () => {
const { deps, app, site, fake, g } = await env();
deps.dnsResolver = { mx: async (n) => [`mail.${n}`], txt: async (n) => (/_domainkey/.test(n) ? ['v=DKIM1; k=rsa; p=FAKEPUBLICKEY'] : /_dmarc/.test(n) ? ['v=DMARC1; p=none'] : ['v=spf1 a mx -all']) };
const s = (await call(app, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: site, domain: 'site.example.test', alloc })).json.service; await call(app, 'POST', `/api/mail/services/${s.id}/mailboxes`, { local: 'kim' });
let c = svc.canReturnSiteCount(deps, site); eq(c.ok, false, '대표 메일이 있으면 사이트 수 반환 불가'); eq(c.reasons[0].code, 'site_primary_mail_active'); eq(c.reasons[0].canConvert, true, '옵션 있음');
let r = await call(app, 'GET', `/api/mail/services/${s.id}/convert`); eq(r.json.checklist.canConvert, false, 'DNS 확인 전'); eq(r.json.checklist.items.find((i) => i.id === 'dns').ok, false);
await call(app, 'POST', `/api/mail/services/${s.id}/dns-check`); r = await call(app, 'GET', `/api/mail/services/${s.id}/convert`); eq(r.json.checklist.canConvert, true, '점검표 충족: ' + JSON.stringify(r.json.checklist.items.filter((i) => !i.ok))); eq(r.json.checklist.items.length, 8, '항목 수');
// 옵션 부족
const ent = deps.store.get('SELECT id FROM quota_entitlements WHERE resource=\'mail_services\''); quota.endEntitlement(deps.store, ent.id); r = await call(app, 'GET', `/api/mail/services/${s.id}/convert`); eq(r.json.checklist.items.find((i) => i.id === 'entitlement').ok, false, '옵션 부족'); eq(r.json.checklist.items.find((i) => i.id === 'entitlement').link, '/billing'); r = await call(app, 'POST', `/api/mail/services/${s.id}/convert`, { confirm: 'site.example.test' }); eq(r.json.error, 'conversion_blocked', '차단'); eq(svc.canReturnSiteCount(deps, site).reasons[0].canConvert, false, '반환 화면 안내: 먼저 구매');
quota.grantEntitlement(deps.store, { groupId: g, resource: 'mail_services', amount: 1 });
// 전환 뒤 누락 시나리오: after 확인에서 메일함이 사라짐 → 미완료 + 되돌리기
// listUsers 호출 순서(POST 부터): 요청 검사 점검표(1) → 작업 점검표(2) → 전환 전(3) → 전환 후(4). 전환 후 조회에서 메일함 하나가 사라진 상황을 흉내낸다.
const users = fake.state.users.get('site.example.test'); let calls = 0; const origList = fake.listUsers; fake.listUsers = async (...a) => { calls++; const rr = await origList(...a); if (calls === 4) rr.users = rr.users.slice(1); return rr; };
r = await call(app, 'POST', `/api/mail/services/${s.id}/convert`, { confirm: 'site.example.test' }); eq(r.status, 200); await deps.jobs.drain(); let j = (await call(app, 'GET', `/api/mail/services/${s.id}/job`)).json.job; eq(j.status, 'failed', '누락이면 완료하지 않음: ' + j.error); eq(j.errorCode, 'conversion_incomplete'); fake.listUsers = origList;
await call(app, 'POST', `/api/mail/services/${s.id}/job/revert`); j = (await call(app, 'GET', `/api/mail/services/${s.id}/job`)).json.job; eq(j.status, 'reverted', '되돌림'); eq(deps.store.get('SELECT kind, site_id FROM mail_services WHERE id=?', s.id).kind, 'site_bound', '종류 복원'); eq(quota.figures(deps.store, g, 'mail_services').allocated, 0, '옵션 반환');
// 정상 전환
deps.store.run('UPDATE mail_services SET updated_at=? WHERE id=?', nowIso(), s.id); r = await call(app, 'POST', `/api/mail/services/${s.id}/convert`, { confirm: 'site.example.test' }); eq(r.status, 200, JSON.stringify(r.json)); await deps.jobs.drain(); j = (await call(app, 'GET', `/api/mail/services/${s.id}/job`)).json.job; eq(j.status, 'done', '전환 완료: ' + j.error);
const after = deps.store.get('SELECT kind, site_id FROM mail_services WHERE id=?', s.id); eq(after.kind, 'standalone'); eq(after.site_id, null); eq(fake.state.domains.get('site.example.test').ariweb.kind, 'standalone', 'AriMail 표시'); eq(users.length, 2, '메일함 유지(admin + kim)'); eq(quota.figures(deps.store, g, 'mail_services').allocated, 1, '옵션 소비');
eq(svc.canReturnSiteCount(deps, site).ok, true, '전환 뒤 사이트 수 반환 가능'); eq(deps.store.all('SELECT * FROM mail_tests WHERE kind IN (\'conversion_before\',\'conversion_after\')').length >= 2, true, '전후 확인 기록'); ok(listAudit(deps.store, { groupId: g, action: 'mail.service.convert' })[0]?.immutable === 1, '삭제 불가 감사');
});
await test('해지·보존(§23.2): 송수신 즉시 중지(AriMail frozen), 30일 읽기·내보내기 안내·삭제 예정일, 배정 반환 중, 보존 종료 뒤 삭제·반환·복구 불가 표시, 삭제 보류', async () => {
const { deps, app, site, fake, g } = await env();
const s = (await call(app, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: site, domain: 'site.example.test', alloc })).json.service;
let r = await call(app, 'POST', `/api/mail/services/${s.id}/cancel`, { confirm: 'nope' }); eq(r.json.error, 'confirm_required'); r = await call(app, 'POST', `/api/mail/services/${s.id}/cancel`, { confirm: 'site.example.test' }); eq(r.json.error, 'site_live', '운영 사이트 영향 안내');
r = await call(app, 'POST', `/api/mail/services/${s.id}/cancel`, { confirm: 'site.example.test', acknowledgeSite: true }); eq(r.status, 200); eq(r.json.service.status, 'cancelled'); eq(fake.state.domains.get('site.example.test').operating, 'frozen', '송수신 즉시 중지'); const lc = r.json.lifecycle; eq(lc.state, 'readonly_retention', '읽기 전용 보존'); ok(lc.retentionUntil && lc.deleteAfter, '보존 종료·삭제 예정일'); ok(lc.backup.includes('제공되지 않습니다'), '백업 미제공'); ok(lc.exportable.includes('가능'), '내보내기 가능');
eq(quota.allocationOf(deps.store, g, 'mailboxes', 'mail_service', s.id).state, 'returning', '배정 반환 중'); ok(listNotifications(deps.store, { groupId: g }).some((n) => n.mandatory && n.title.includes('해지')), '필수 알림');
r = await call(app, 'POST', `/api/mail/services/${s.id}/export`, {}); eq(r.json.export.status, 'guidance'); ok(r.json.export.guidance.some((x) => x.includes('IMAP')), '내보내기 안내');
eq((await svc.purgeExpired(deps)).length, 0, '보존 중에는 삭제 안 함'); deps.store.run('UPDATE mail_services SET delete_after=? WHERE id=?', new Date(0).toISOString(), s.id);
deps.store.run('UPDATE mail_services SET settings_json=? WHERE id=?', JSON.stringify({ deleteHold: true }), s.id); eq((await svc.purgeExpired(deps)).length, 0, '삭제 보류'); deps.store.run('UPDATE mail_services SET settings_json=\'{}\' WHERE id=?', s.id);
eq((await svc.purgeExpired(deps)).length, 1, '보존 종료 삭제'); eq(deps.store.get('SELECT status FROM mail_services WHERE id=?', s.id).status, 'deleted'); eq(quota.allocationOf(deps.store, g, 'mailboxes', 'mail_service', s.id), null, '배정 반환 완료'); ok(!fake.state.domains.has('site.example.test'), 'AriMail 도메인 제거');
eq(svc.lifecycle(deps, svc.getService(deps, g, s.id)).exportable, '불가', '삭제 뒤 복구 불가 표시'); eq((await call(app, 'GET', '/api/mail/services')).json.services.length, 0, '목록에서 제외');
});
await test('운영자 API(§30.2): 개요·서비스 상세(접근 사유 기록)·운영 상태(이전 중 동결)·정책·메일 원문 없음', async () => {
const { deps, app, site, fake, g } = await env();
const s = (await call(app, 'POST', '/api/mail/services', { kind: 'site_bound', siteId: site, domain: 'site.example.test', alloc })).json.service;
let r = await call(app, 'GET', '/internal/ops/mail/overview'); eq(r.status, 200); eq(r.json.services.length, 1); ok(r.json.servers[0].health.ok, '서버 건강');
r = await call(app, 'GET', `/internal/ops/mail/services/${s.id}?reason=customer-ticket-1`, undefined, { 'x-ops-actor': 'ops-kim' }); eq(r.json.service.domain, 'site.example.test'); const a = listAudit(deps.store, { subjectType: 'mail_service', subjectId: s.id, action: 'ops.mail.service.read' })[0]; eq(a.actor_id, 'ops-kim', '접근 기록'); eq(a.detail.reason, 'customer-ticket-1', '사유'); ok(!JSON.stringify(r.json).includes('"text"') && !JSON.stringify(r.json).includes('"html"'), '메일 원문 없음');
r = await call(app, 'POST', `/internal/ops/mail/services/${s.id}/operating`, { operating: 'migrating_out', reason: '서버 이전' }); eq(r.json.operating, 'migrating_out'); eq(fake.state.domains.get('site.example.test').operating, 'migrating_out', 'AriMail 반영'); eq(deps.store.get('SELECT migrating FROM mail_services WHERE id=?', s.id).migrating, 1);
r = await call(app, 'POST', `/internal/ops/mail/services/${s.id}/operating`, { operating: 'bogus' }); eq(r.status, 422);
r = await call(app, 'PUT', '/internal/ops/mail/policy', { verifier: 'check@external.example.test', aliases: { perMailbox: 3 } }); eq(r.json.policy.aliases.deferred, false); eq((await call(app, 'GET', '/internal/ops/mail/policy')).json.policy.verifier, 'check@external.example.test');
r = await call(app, 'GET', '/internal/ops/mail/servers/fake/rates?domain=site.example.test'); eq(r.json.rates.deliveryRate, 5, '발송 속도');
// 서비스 키 우회를 끄면 거부
deps.opsAuthBypass = false; r = await call(app, 'GET', '/internal/ops/mail/overview'); eq(r.status, 401, '서명 없으면 401');
});
await test('어댑터: 가짜 AriMail HTTP 서버에 대해 설정 토큰·관리자 세션·사용량 키·SSO 티켓·연결 불가(service_unreachable) 동작', async () => {
const deps = await makeDeps(); const seen = []; const token = 'setup-token-' + Math.random().toString(36).slice(2);
const srv = http.createServer((req, res) => { let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { const u = new URL(req.url, 'http://x'); seen.push({ m: req.method, p: u.pathname, host: req.headers['x-forwarded-host'] || req.headers.host, auth: req.headers.authorization, cookie: req.headers.cookie, scope: req.headers['x-mail-scope'], token: req.headers['x-setup-token'] }); const j = (code, o, h = {}) => { res.writeHead(code, { 'Content-Type': 'application/json', ...h }); res.end(JSON.stringify(o)); };
if (u.pathname === '/api/health') return j(200, { ok: true, version: '1.0.11-test', domains: 2 });
if (u.pathname.startsWith('/api/setup/')) { if (req.headers['x-setup-token'] !== token) return j(401, { ok: false, error: 'setup_token_invalid' }); if (u.pathname === '/api/setup/domains' && req.method === 'GET') return j(200, { ok: true, domains: [{ domain: 'a.test', hosts: ['mail.a.test'], ariweb: null, operating: 'active' }] }); if (u.pathname === '/api/setup/domains' && req.method === 'POST') return j(201, { ok: true, domain: { domain: JSON.parse(body).domain, hosts: ['mail.' + JSON.parse(body).domain] }, initialAdmin: { login: 'admin', password: JSON.parse(body).adminPassword } }); if (u.pathname.startsWith('/api/setup/domains/') && req.method === 'PATCH') return j(200, { ok: true, domain: { domain: 'a.test', ...JSON.parse(body) } }); if (u.pathname === '/api/setup/config' && req.method === 'GET') return j(200, { ok: true, values: {}, meta: { host: 'mail.' + u.searchParams.get('domain'), dns: { records: [{ type: 'MX', name: 'a.test', value: '10 mail.a.test' }] } } }); if (u.pathname === '/api/setup/config' && req.method === 'PUT') return j(200, { ok: true, saved: JSON.parse(body).values }); return j(404, { ok: false, error: 'not_found' }); }
if (u.pathname === '/api/usage/a.test') { if (req.headers.authorization !== 'Bearer usage-key-1') return j(401, { ok: false, error: 'unauthenticated' }); return j(200, { ok: true, domain: 'a.test', storage: { bytes: 10 }, traffic: { month: { total: 3 } }, warnLevel: 0, limitedReasons: [], accountList: [] }); }
if (u.pathname === '/api/login' && req.method === 'POST') { const b = JSON.parse(body); if (b.login === 'admin' && b.password === 'admin-pw-1') return j(200, { ok: true }, { 'Set-Cookie': 'mg_admin=tok123; Path=/; HttpOnly' }); return j(401, { ok: false, error: 'invalid_credentials' }); }
if (u.pathname === '/api/admin/users' && req.method === 'GET') { if (req.headers.cookie !== 'mg_admin=tok123' || req.headers['x-mail-scope'] !== 'admin') return j(403, { ok: false, error: 'admin_login_required' }); return j(200, { ok: true, users: [{ id: 'u1', login: 'admin', mailbox: 'admin@a.test' }], domain: 'a.test' }); }
if (u.pathname === '/api/admin/users' && req.method === 'POST') return j(200, { ok: true, id: 'u2' });
j(404, { ok: false, error: 'not_found' }); }); });
await new Promise((r) => srv.listen(0, '127.0.0.1', r)); const port = srv.address().port; const base = `http://127.0.0.1:${port}`;
try {
const ad = createAdapter(deps); const s = ad.saveServer({ name: '시험 서버', baseUrl: base, internalUrl: base, setupUrl: base, mailHost: 'mail.a.test', isDefault: true, secrets: { 'setup-token': token, 'usage-key': 'usage-key-1', 'sso-secret': 'sso-secret-1' } });
ok(!JSON.stringify(s).includes(token) && s.secrets.setupToken === true, '응답에 비밀 없음'); eq((await ad.health(s.id)).ok, true, '건강');
eq((await ad.domains(s.id)).length, 1, '도메인 목록(토큰)'); const added = await ad.addDomain(s.id, { domain: 'b.test', ariweb: { kind: 'standalone' } }); eq(added.initialAdmin.login, 'admin', '초기 관리자'); ok(deps.vault.get('arimail:' + s.id + ':admin:b.test'), '관리자 로그인 보관');
eq((await ad.updateDomain(s.id, 'a.test', { operating: 'frozen' })).operating, 'frozen', 'PATCH'); eq((await ad.requiredRecords ? null : (await ad.domainConfig(s.id, 'a.test')).meta.dns.records[0].type), 'MX', '설정·DNS 레코드'); await ad.setDomainLimits(s.id, 'a.test', { mailboxes: 3, storageBytes: 100, monthlyMessages: 10 }); ok(seen.find((x) => x.m === 'PUT' && x.p === '/api/setup/config'), '보장량 PUT');
eq((await ad.usage(s.id, 'a.test')).storage.bytes, 10, '사용량 키'); ok(seen.find((x) => x.p === '/api/usage/a.test').auth === 'Bearer usage-key-1', 'Bearer');
ad.storeAdmin(s.id, 'a.test', 'admin', 'admin-pw-1'); const users = await ad.listUsers(s.id, 'a.test'); eq(users.users[0].login, 'admin', '관리자 세션'); const req = seen.find((x) => x.p === '/api/admin/users'); eq(req.host, 'mail.a.test', '도메인 호스트 전달'); eq(req.scope, 'admin', '관리자 범위'); eq((await ad.createUser(s.id, 'a.test', { login: 'kim', password: 'kim-pw-123' })).id, 'u2', '계정 생성');
const sso = await ad.ssoUrl(s.id, 'a.test', 'kim@a.test'); ok(sso.startsWith('https://mail.a.test/api/auth/sso?ticket='), 'SSO 주소'); const tk = decodeURIComponent(sso.split('ticket=')[1]); const payload = JSON.parse(Buffer.from(tk.split('.')[0], 'base64url')); eq(payload.mailbox, 'kim@a.test'); ok(payload.exp > Date.now(), '만료');
ad.storeAdmin(s.id, 'a.test', 'admin', 'wrong'); await rejects(() => ad.listUsers(s.id, 'a.test'), 'invalid_credentials', '관리자 로그인 실패 코드');
srv.close(); await rejects(() => ad.health(s.id).then((h) => { if (!h.ok) throw Object.assign(new Error(h.error), { code: h.error }); }), 'service_unreachable', '연결 불가'); await rejects(() => ad.domains(s.id), 'service_unreachable', '연결 불가(설정)');
} finally { try { srv.close(); } catch (_) {} }
});
done();