Files
AriWeb-Suite/modules/arimail/test/provision.test.mjs

256 lines
34 KiB
JavaScript

/**
* 1.0.5 시험: DNS 모듈(Google Cloud DNS·Route 53·공개 조회), 필요한 레코드 계산(SPF 병합), Resend 도메인·웹훅 모듈, 자동 설정 계획(buildPlan/runPlan).
* 외부 서비스는 모두 가짜 fetch(env.DNS_FETCH / env.RESEND_FETCH)와 가짜 리졸버로 대신합니다.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { generateKeyPairSync } from 'node:crypto';
import { makeEnv, runner, eq, ok } from './_helpers.mjs';
import { loadConfig, applyConfig, saveConfig } from '../server/config.mjs';
import { mergeSpf, requiredRecords, applyRecords, checkRecords, probeDns } from '../server/dns/index.mjs';
import { txtChunks, lookup, reverseCheck } from '../server/dns/common.mjs';
import * as google from '../server/dns/google.mjs';
import * as route53 from '../server/dns/route53.mjs';
import { resendDnsRecords, findDomain, providerStatus, providerError, clearStatusCache, autoVerifyDomain } from '../server/providers/resend.mjs';
import { buildPlan, runPlan } from '../server/provision.mjs';
const { t, done } = runner();
const tmp = () => mkdtempSync(join(tmpdir(), 'arimail-prov-'));
const envFor = async (dir, over = {}) => { const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), MAILBOX: 'admin@example.test', PUBLIC_HOST: 'mail.example.test', RESEND_API_KEY: '', ...over }); loadConfig(env); return env; };
/** 가짜 리졸버: 표에 있는 값만 돌려주고 없으면 ENOTFOUND. */
function fakeResolver(table = {}) {
const get = (name, type) => { const v = table[`${type} ${name.toLowerCase()}`]; if (v === undefined) { const e = new Error('nf'); e.code = 'ENOTFOUND'; throw e; } return v; };
return { resolveTxt: async (n) => get(n, 'TXT').map((v) => txtChunks(v)), resolveMx: async (n) => get(n, 'MX'), resolveCname: async (n) => get(n, 'CNAME'), resolve4: async (n) => get(n, 'A'), reverse: async (ip) => get(ip, 'PTR'), table };
}
/** 가짜 Resend(도메인·웹훅 관리 부분만) — 상태를 기억합니다. */
function fakeResendAdmin(state = {}) {
state.domains = state.domains || []; state.webhooks = state.webhooks || []; state.calls = [];
return async (url, options = {}) => {
const u = new URL(url), path = u.pathname, method = options.method || 'GET', body = options.body ? JSON.parse(options.body) : null; state.calls.push({ path, method, body });
if (!options.headers?.Authorization?.includes('re_good')) return Response.json({ message: 'API key is invalid' }, { status: 401 });
let m;
if (path === '/domains' && method === 'GET') return Response.json({ data: state.domains.map(({ records, ...d }) => d) });
if (path === '/domains' && method === 'POST') { const d = { id: 'dom-' + (state.domains.length + 1), name: body.name, status: 'not_started', region: body.region, capabilities: body.capabilities, records: [{ record: 'DKIM', name: 'resend._domainkey', type: 'TXT', value: 'p=PUBKEY', status: 'not_started' }, { record: 'SPF', name: 'send', type: 'CNAME', value: 'send.forge.rmta.net', status: 'not_started' }, ...(body.capabilities.receiving === 'enabled' ? [{ record: 'Receiving', name: '', type: 'MX', value: `inbound-smtp.${body.region}.amazonaws.com`, priority: 9, status: 'not_started' }] : [])] }; state.domains.push(d); return Response.json(d); }
if ((m = path.match(/^\/domains\/([^/]+)\/verify$/))) { const d = state.domains.find((x) => x.id === m[1]); if (!d) return Response.json({}, { status: 404 }); d.status = state.verifyResult || 'pending'; return Response.json({ object: 'domain', id: d.id }); }
if ((m = path.match(/^\/domains\/([^/]+)$/))) { const d = state.domains.find((x) => x.id === m[1]); if (!d) return Response.json({ message: 'not found' }, { status: 404 }); if (method === 'PATCH') { d.capabilities = body.capabilities; if (body.capabilities.receiving === 'enabled' && !d.records.some((r) => r.type === 'MX')) d.records.push({ record: 'Receiving', name: '', type: 'MX', value: `inbound-smtp.${d.region}.amazonaws.com`, priority: 9 }); if (body.capabilities.receiving === 'disabled') d.records = d.records.filter((r) => r.type !== 'MX'); return Response.json({ object: 'domain', id: d.id }); } return Response.json(d); }
if (path === '/webhooks' && method === 'GET') return Response.json({ data: state.webhooks });
if (path === '/webhooks' && method === 'POST') { state.seq = (state.seq || 0) + 1; const w = { id: 'wh-' + state.seq, endpoint: body.endpoint, events: body.events, status: 'enabled' }; state.webhooks.push(w); return Response.json({ object: 'webhook', id: w.id, signing_secret: 'whsec_' + w.id }); }
if ((m = path.match(/^\/webhooks\/([^/]+)$/)) && method === 'DELETE') { state.webhooks = state.webhooks.filter((w) => w.id !== m[1]); return Response.json({ object: 'webhook', id: m[1], deleted: true }); }
return Response.json({ message: 'unexpected ' + method + ' ' + path }, { status: 500 });
};
}
/** 가짜 Google Cloud DNS + OAuth. zone 의 rrsets 를 기억합니다. */
function fakeGoogle(state = {}) {
state.rrsets = state.rrsets || {}; state.calls = []; state.changes = [];
return async (url, options = {}) => {
const u = new URL(url), method = options.method || 'GET', body = options.body && !(options.body instanceof URLSearchParams) ? JSON.parse(options.body) : null; state.calls.push({ host: u.hostname, path: u.pathname, method, body });
if (u.hostname === 'oauth2.googleapis.com') { const p = options.body; ok(String(p.get('assertion')).split('.').length === 3, 'JWT 3 부분'); return Response.json({ access_token: 'ya29.test', expires_in: 3600 }); }
if (!options.headers?.Authorization?.includes('ya29.test')) return Response.json({ error: { message: 'unauth' } }, { status: 401 });
if (u.pathname.endsWith('/managedZones') && method === 'GET') return Response.json({ managedZones: [{ name: 'other-zone', dnsName: 'other.test.', visibility: 'public' }, { name: 'example-zone', dnsName: 'example.test.', visibility: 'public' }, { name: 'priv', dnsName: 'example.test.', visibility: 'private' }] });
if (u.pathname.includes('/rrsets')) { const key = `${u.searchParams.get('type')} ${u.searchParams.get('name')}`; return Response.json({ rrsets: state.rrsets[key] ? [state.rrsets[key]] : [] }); }
if (u.pathname.endsWith('/changes') && method === 'POST') { state.changes.push(body); for (const d of body.deletions || []) delete state.rrsets[`${d.type} ${d.name}`]; for (const a of body.additions || []) state.rrsets[`${a.type} ${a.name}`] = a; return Response.json({ status: 'pending' }); }
return Response.json({ error: { message: 'unexpected ' + u.pathname } }, { status: 500 });
};
}
/** 가짜 Route 53: 서명 헤더만 확인하고 XML 로 답합니다. */
function fakeRoute53(state = {}) {
state.calls = []; state.rrsets = state.rrsets || {};
return async (url, options = {}) => {
const u = new URL(url), method = options.method || 'GET'; state.calls.push({ path: u.pathname, query: u.search, method, headers: options.headers, body: options.body });
const auth = options.headers?.Authorization || '';
if (!/^AWS4-HMAC-SHA256 Credential=AKIATEST\/\d{8}\/us-east-1\/route53\/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=[0-9a-f]{64}$/.test(auth) && !/SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date/.test(auth)) return new Response('<ErrorResponse><Error><Code>InvalidSignature</Code><Message>bad</Message></Error></ErrorResponse>', { status: 403 });
if (u.pathname === '/2013-04-01/hostedzonesbyname') { const name = u.searchParams.get('dnsname'); return new Response(`<ListHostedZonesByNameResponse><HostedZones>${name === 'example.test.' ? '<HostedZone><Id>/hostedzone/Z123</Id><Name>example.test.</Name><Config><PrivateZone>false</PrivateZone></Config></HostedZone>' : ''}</HostedZones></ListHostedZonesByNameResponse>`); }
if (u.pathname.endsWith('/rrset') && method === 'GET') { const key = `${u.searchParams.get('type')} ${u.searchParams.get('name')}`; const r = state.rrsets[key]; return new Response(`<ListResourceRecordSetsResponse><ResourceRecordSets>${r ? `<ResourceRecordSet><Name>${r.name}</Name><Type>${r.type}</Type><TTL>300</TTL><ResourceRecords>${r.values.map((v) => `<ResourceRecord><Value>${v.replace(/"/g, '&quot;')}</Value></ResourceRecord>`).join('')}</ResourceRecords></ResourceRecordSet>` : ''}</ResourceRecordSets></ListResourceRecordSetsResponse>`); }
if (u.pathname.endsWith('/rrset') && method === 'POST') { state.lastBody = options.body; for (const m of options.body.matchAll(/<ResourceRecordSet><Name>([^<]+)<\/Name><Type>([^<]+)<\/Type><TTL>\d+<\/TTL><ResourceRecords>(.*?)<\/ResourceRecords>/g)) state.rrsets[`${m[2]} ${m[1]}`] = { name: m[1], type: m[2], values: [...m[3].matchAll(/<Value>([^<]*)<\/Value>/g)].map((x) => x[1].replace(/&quot;/g, '"')) }; return new Response('<ChangeResourceRecordSetsResponse><ChangeInfo><Id>/change/C999</Id><Status>PENDING</Status></ChangeInfo></ChangeResourceRecordSetsResponse>'); }
return new Response('<ErrorResponse><Error><Code>Unexpected</Code></Error></ErrorResponse>', { status: 500 });
};
}
const saKey = () => generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } }).privateKey;
await t('1.0.5 DNS 공통: SPF 병합, TXT 255자 분할, 공개 조회 비교(TXT/MX/CNAME, 없음=not_found), PTR 확인', async () => {
eq(mergeSpf('', ['mx', 'a:mail.example.test']), 'v=spf1 mx a:mail.example.test ~all', '새 SPF');
eq(mergeSpf('v=spf1 include:_spf.google.com -all', ['mx', 'a:mail.example.test']), 'v=spf1 include:_spf.google.com mx a:mail.example.test -all', '기존 SPF 에 끼워 넣기(all 유지)');
eq(mergeSpf('v=spf1 mx a:mail.example.test ~all', ['mx', 'a:mail.example.test']), 'v=spf1 mx a:mail.example.test ~all', '이미 있으면 그대로');
eq(txtChunks('x'.repeat(600)).map((c) => c.length).join(','), '255,255,90', '분할'); eq(txtChunks('').length, 1, '빈 값도 조각 하나');
const r = fakeResolver({ 'TXT example.test': ['v=spf1 mx ~all', 'other'], 'MX example.test': [{ priority: 10, exchange: 'mail.example.test' }], 'CNAME send.example.test': ['send.forge.rmta.net'], 'A mail.example.test': ['203.0.113.5'], 'PTR 203.0.113.5': ['mail.example.test'] });
eq((await lookup({ name: 'example.test', type: 'TXT', values: ['v=spf1 mx ~all'] }, { resolver: r })).ok, true, 'TXT 일치');
eq((await lookup({ name: 'example.test', type: 'TXT', values: ['v=spf1 -all'] }, { resolver: r })).ok, false, 'TXT 불일치');
eq((await lookup({ name: 'example.test', type: 'MX', values: ['mail.example.test'], priority: 10 }, { resolver: r })).ok, true, 'MX 일치(우선순위 포함)');
eq((await lookup({ name: 'example.test', type: 'MX', values: ['mail.example.test'], priority: 9 }, { resolver: r })).ok, false, 'MX 우선순위 다름');
eq((await lookup({ name: 'send.example.test', type: 'CNAME', values: ['send.forge.rmta.net.'] }, { resolver: r })).ok, true, 'CNAME 끝 점 무시');
const nf = await lookup({ name: 'nope.example.test', type: 'TXT', values: ['x'] }, { resolver: r }); eq(nf.ok, false, '없음'); eq(nf.error, 'not_found', 'not_found 코드');
eq((await reverseCheck('mail.example.test', { resolver: r })).ok, true, 'PTR 일치'); eq((await reverseCheck('mail.example.test', { resolver: fakeResolver({ 'A mail.example.test': ['203.0.113.5'], 'PTR 203.0.113.5': ['vps-1.hosting.test'] }) })).ok, false, 'PTR 다름');
});
await t('1.0.5 필요한 레코드: 수·발신 방식별 구성(Haraka MX·SPF·DKIM·DMARC, Resend 레코드 변환, 수신이 Resend 가 아니면 Resend MX 제외)', async () => {
const dir = tmp(); const env = await envFor(dir);
applyConfig(env, { inbound: 'haraka', outbound: 'haraka', HARAKA_DKIM_PUBLIC: 'PUB', HARAKA_DKIM_SELECTOR: 'arimail', HARAKA_TOKEN: 'x' });
const recs = requiredRecords(env, { existingSpf: 'v=spf1 include:spf.protection.outlook.com -all' });
eq(recs.map((r) => `${r.type} ${r.name}${r.optional ? '?' : ''}`).join(' | '), 'MX example.test | TXT example.test | TXT arimail._domainkey.example.test | TXT _dmarc.example.test?', 'Haraka 수·발신 레코드');
eq(recs[0].values[0], 'mail.example.test', 'MX 는 호스트 이름'); eq(recs[1].values[0], 'v=spf1 include:spf.protection.outlook.com mx a:mail.example.test -all', '기존 SPF 병합'); ok(recs[2].values[0].startsWith('v=DKIM1; k=rsa; p=PUB'), 'DKIM');
const resendDomain = { name: 'Example.test', records: [{ record: 'DKIM', name: 'resend._domainkey', type: 'TXT', value: 'p=K' }, { record: 'SPF', name: 'send', type: 'CNAME', value: 'send.forge.rmta.net' }, { record: 'Receiving', name: '', type: 'MX', value: 'inbound-smtp.ap-northeast-1.amazonaws.com', priority: 9 }] };
eq(resendDnsRecords(resendDomain).map((r) => `${r.type} ${r.name}`).join(' | '), 'TXT resend._domainkey.example.test | CNAME send.example.test | MX example.test', 'Resend 레코드 → FQDN');
applyConfig(env, { inbound: 'haraka', outbound: 'resend', HARAKA_TOKEN: 'x' });
const mixed = requiredRecords(env, { resendDomain });
eq(mixed.map((r) => `${r.type} ${r.name}`).join(' | '), 'MX example.test | TXT resend._domainkey.example.test | CNAME send.example.test', '수신 Haraka + 발신 Resend: Resend MX 는 빠지고 우리 MX 만');
applyConfig(env, { inbound: 'resend', outbound: 'resend', HARAKA_TOKEN: 'x' });
eq(requiredRecords(env, { resendDomain }).filter((r) => r.type === 'MX')[0].priority, 9, '수신 Resend: Resend MX(우선순위 9)');
rmSync(dir, { recursive: true, force: true });
});
await t('1.0.5 Google Cloud DNS 모듈: 서비스 계정 JWT 인증, 존 자동 선택(공개·가장 긴 일치), rrdatas 변환(TXT 따옴표·분할, MX 우선순위), UPSERT(기존 삭제+추가, 같으면 생략), 오류 코드', async () => {
const dir = tmp(); const env = await envFor(dir); const g = {}; env.DNS_FETCH = fakeGoogle(g);
applyConfig(env, { inbound: 'resend', outbound: 'resend', DNS_PROVIDER: 'google', DNS_GOOGLE_CREDENTIALS: JSON.stringify({ client_email: 'sa@p.iam.gserviceaccount.com', private_key: saKey(), project_id: 'proj-1' }), HARAKA_TOKEN: 'x' });
const zone = await google.findZone(env, 'example.test'); eq(zone.name, 'example-zone', '공개 존 선택(사설 제외)');
const longTxt = 'v=DKIM1; p=' + 'A'.repeat(300), rr = google.rrdata({ type: 'TXT', values: [longTxt] })[0];
eq(rr, txtChunks(longTxt).map((c) => `"${c}"`).join(' '), 'TXT 255자 분할·따옴표'); eq(rr.split('" "').length, 2, '두 조각'); eq(rr.replace(/"/g, '').replace(/ /g, ''), longTxt.replace(/ /g, ''), '이어 붙이면 원본');
eq(google.rrdata({ type: 'MX', values: ['mail.example.test'], priority: 10 })[0], '10 mail.example.test.', 'MX 형식'); eq(google.rrdata({ type: 'CNAME', values: ['send.forge.rmta.net'] })[0], 'send.forge.rmta.net.', 'CNAME 끝 점');
g.rrsets['TXT example.test.'] = { name: 'example.test.', type: 'TXT', ttl: 300, rrdatas: ['"v=spf1 include:old ~all"'] };
const r1 = await google.upsert(env, zone, [{ name: 'example.test', type: 'TXT', ttl: 300, values: ['v=spf1 include:old mx ~all'] }, { name: 'example.test', type: 'MX', ttl: 300, values: ['mail.example.test'], priority: 10 }]);
eq(r1.changed.length, 2, '두 레코드 변경'); const ch = g.changes[0]; eq(ch.deletions.length, 1, '기존 TXT 삭제'); eq(ch.additions.length, 2, '추가 2'); eq(ch.additions[0].rrdatas[0], '"v=spf1 include:old mx ~all"', '새 SPF');
const r2 = await google.upsert(env, zone, [{ name: 'example.test', type: 'MX', ttl: 300, values: ['mail.example.test'], priority: 10 }]); eq(r2.unchanged.length, 1, '같으면 생략'); eq(g.changes.length, 1, '변경 요청 없음');
eq((await probeDns(env)).ok, true, '연결 확인'); eq((await probeDns(env)).project, 'proj-1', '프로젝트 id 는 JSON 에서');
applyConfig(env, { inbound: 'resend', outbound: 'resend', DNS_PROVIDER: 'google', DNS_GOOGLE_CREDENTIALS: '{bad', HARAKA_TOKEN: 'x' }); eq((await probeDns(env)).error, 'dns_bad_credentials', 'JSON 오류 코드');
applyConfig(env, { inbound: 'resend', outbound: 'resend', DNS_PROVIDER: 'google', DNS_GOOGLE_CREDENTIALS: '', HARAKA_TOKEN: 'x' }); eq((await probeDns(env)).error, 'dns_not_configured', '없음 코드');
env.DNS_FETCH = fakeGoogle({}); applyConfig(env, { inbound: 'resend', outbound: 'resend', DNS_PROVIDER: 'google', DNS_GOOGLE_CREDENTIALS: JSON.stringify({ client_email: 'a@b', private_key: saKey(), project_id: 'p' }), MAILBOX: 'x', HARAKA_TOKEN: 'x' }); env.MAILBOX = 'admin@nozone.test'; eq((await probeDns(env)).error, 'dns_zone_not_found', '존 없음 코드');
rmSync(dir, { recursive: true, force: true });
});
await t('1.0.5 Route 53 모듈: SigV4 서명 헤더, 존 검색(상위 도메인까지), UPSERT XML(TXT 따옴표, MX), 같으면 생략, 오류 코드', async () => {
const dir = tmp(); const env = await envFor(dir); const a = {}; env.DNS_FETCH = fakeRoute53(a);
applyConfig(env, { inbound: 'resend', outbound: 'resend', DNS_PROVIDER: 'route53', DNS_AWS_ACCESS_KEY_ID: 'AKIATEST', DNS_AWS_SECRET_ACCESS_KEY: 'secret', HARAKA_TOKEN: 'x' });
const zone = await route53.findZone(env, 'mail.example.test'); eq(zone.id, 'Z123', '상위 도메인 존 찾기'); ok(/x-amz-date/.test(JSON.stringify(a.calls[0].headers)), '서명 헤더');
const r1 = await route53.upsert(env, zone, [{ name: 'example.test', type: 'TXT', ttl: 300, values: ['v=spf1 mx ~all'] }, { name: 'example.test', type: 'MX', ttl: 300, values: ['mail.example.test'], priority: 10 }]);
eq(r1.changed.length, 2, '변경 2'); eq(r1.changeId, 'C999', '변경 id'); ok(a.lastBody.includes('<Action>UPSERT</Action>') && a.lastBody.includes('<Value>&quot;v=spf1 mx ~all&quot;</Value>') && a.lastBody.includes('<Value>10 mail.example.test.</Value>'), 'XML 본문');
const r2 = await route53.upsert(env, zone, [{ name: 'example.test', type: 'TXT', ttl: 300, values: ['v=spf1 mx ~all'] }]); eq(r2.unchanged.length, 1, '같으면 생략');
applyConfig(env, { inbound: 'resend', outbound: 'resend', DNS_PROVIDER: 'route53', DNS_AWS_ACCESS_KEY_ID: 'AKIAWRONG', DNS_AWS_SECRET_ACCESS_KEY: 'x', HARAKA_TOKEN: 'x' }); eq((await probeDns(env)).error, 'dns_auth_failed', '서명 거부 코드');
rmSync(dir, { recursive: true, force: true });
});
await t('1.0.5 자동 설정 계획(Resend 수·발신 + Google DNS): 키 없음 → 입력 요청, 도메인 등록·기능·DNS 적용·검증·웹훅(비밀키 저장)을 자동으로 처리하고 반영 뒤 준비 완료', async () => {
const dir = tmp(); const env = await envFor(dir); const rs = { verifyResult: 'pending' }, g = {}; env.RESEND_FETCH = fakeResendAdmin(rs); env.DNS_FETCH = fakeGoogle(g);
const resolver = fakeResolver({});
applyConfig(env, { inbound: 'resend', outbound: 'resend', HARAKA_TOKEN: 'x' });
let p = await buildPlan(env, { resolver });
eq(p.steps.find((s) => s.id === 'resend.key').state, 'action', 'API 키 없음 → 사용자 입력'); ok(p.steps.find((s) => s.id === 'resend.key').input.some((i) => i.key === 'RESEND_API_KEY'), '입력 칸'); eq(p.ready, false, '준비 안 됨');
// 키를 입력하면(runPlan input) 저장 뒤 진단: 도메인 등록 todo, DNS 모듈 없음 → action
let r = await runPlan(env, { step: 'resend.key', input: { RESEND_API_KEY: 're_good_key_123456789', RESEND_REGION: 'ap-northeast-1' }, resolver });
eq(env.RESEND_API_KEY, 're_good_key_123456789', '입력 저장'); p = r.plan;
eq(p.steps.find((s) => s.id === 'resend.domain').state, 'todo', '도메인 등록 자동 가능');
// 전체 실행: 도메인 등록(발송+수신) → DNS 모듈 없음(action) → 검증은 레코드 대기
r = await runPlan(env, { resolver }); p = r.plan;
eq(rs.domains.length, 1, '도메인 등록됨'); eq(rs.domains[0].capabilities.receiving, 'enabled', '수신 켬'); eq(env.RESEND_DOMAIN_ID, 'dom-1', '도메인 id 저장');
eq(p.steps.find((s) => s.id === 'dns.apply').state, 'action', 'DNS 모듈 없음 → 직접 넣기 안내'); ok(p.records.some((x) => x.type === 'MX' && x.priority === 9), 'Resend 수신 MX 포함'); eq(p.steps.find((s) => s.id === 'resend.verify').state, 'wait', '레코드 반영 전에는 검증 대기');
eq(p.steps.find((s) => s.id === 'resend.webhook').state, 'ok', '웹훅은 만들어짐'); eq(rs.webhooks[0].events[0], 'email.received', '웹훅 이벤트'); eq(env.RESEND_WEBHOOK_SECRET, 'whsec_wh-1', '서명 비밀키 자동 저장');
// DNS 모듈 연결 → 자동 적용 → 전파 대기
saveConfig(env, { DNS_PROVIDER: 'google', DNS_GOOGLE_CREDENTIALS: JSON.stringify({ client_email: 'sa@p', private_key: saKey(), project_id: 'proj' }) });
r = await runPlan(env, { resolver }); p = r.plan;
ok(g.changes.length === 1 && g.changes[0].additions.length === 3, 'Google 에 3개 레코드 적용 — ' + JSON.stringify(g.changes.map((c) => c.additions.map((x) => x.type + ' ' + x.name))));
eq(p.steps.find((s) => s.id === 'dns.apply').state, 'wait', '적용 뒤 전파 대기'); eq(p.ready, false, '아직');
// 전파됨(리졸버에 값이 보임) → 검증 요청 자동 → pending → 사용자가 확인 → verified
Object.assign(resolver.table, { 'TXT resend._domainkey.example.test': ['p=PUBKEY'], 'CNAME send.example.test': ['send.forge.rmta.net'], 'MX example.test': [{ priority: 9, exchange: 'inbound-smtp.ap-northeast-1.amazonaws.com' }] });
r = await runPlan(env, { resolver }); p = r.plan;
eq(p.steps.find((s) => s.id === 'dns.apply').state, 'ok', '레코드 반영 확인'); eq(rs.domains[0].status, 'pending', '검증 요청됨'); eq(p.steps.find((s) => s.id === 'resend.verify').state, 'wait', '검증 진행 중 안내');
rs.domains[0].status = 'verified'; p = await buildPlan(env, { resolver });
eq(p.ready, true, '준비 완료'); ok(p.readyMessage.includes('준비가 끝났습니다'), '완료 메시지');
// 웹훅 비밀키를 잃은 경우: action + 새로 만들기 → 새 비밀키 저장(기존 삭제)
saveConfig(env, { RESEND_WEBHOOK_SECRET: '' }); p = await buildPlan(env, { resolver }); eq(p.steps.find((s) => s.id === 'resend.webhook').state, 'action', '비밀키 없음 → 사용자 선택');
r = await runPlan(env, { step: 'resend.webhook', stepAction: 'recreate', resolver }); eq(rs.webhooks.length, 1, '기존 삭제 후 하나'); eq(env.RESEND_WEBHOOK_SECRET, 'whsec_wh-2', '새 비밀키 저장'); eq(r.plan.steps.find((s) => s.id === 'resend.webhook').state, 'ok', '웹훅 정상');
// API 키가 틀리면 오류 단계로 안내
saveConfig(env, { RESEND_API_KEY: 're_bad' }); p = await buildPlan(env, { resolver }); eq(p.steps.find((s) => s.id === 'resend.domain').state, 'error', '키 거부 → 오류'); ok(p.steps.find((s) => s.id === 'resend.domain').detail.includes('거부'), '오류 문구');
rmSync(dir, { recursive: true, force: true });
});
await t('1.0.5 자동 설정 계획(내장 Haraka 수·발신 + Route 53): 미설치 안내, DKIM 자동 생성, SPF 병합 적용, PTR 안내; 외부 SMTP 중계는 접속 정보 입력 요청', async () => {
const dir = tmp(); const env = await envFor(dir, { HARAKA_DIR: join(dir, 'haraka') }); const a = {}; env.DNS_FETCH = fakeRoute53(a);
const resolver = fakeResolver({ 'TXT example.test': ['v=spf1 include:_spf.google.com ~all'], 'A mail.example.test': ['203.0.113.9'], 'PTR 203.0.113.9': ['vps.hosting.test'] });
applyConfig(env, { inbound: 'haraka', outbound: 'haraka', DNS_PROVIDER: 'route53', DNS_AWS_ACCESS_KEY_ID: 'AKIATEST', DNS_AWS_SECRET_ACCESS_KEY: 'secret', HARAKA_TOKEN: 'x' });
let p = await buildPlan(env, { resolver });
eq(p.steps.find((s) => s.id === 'haraka.install').state, 'action', 'Haraka 미설치 → 명령 안내'); ok(p.steps.find((s) => s.id === 'haraka.install').detail.includes('haraka-apply.sh'), '명령');
eq(p.steps.find((s) => s.id === 'haraka.dkim').state, 'todo', 'DKIM 자동 생성 가능'); ok(!p.steps.some((s) => s.id === 'resend.key'), 'Resend 단계 없음');
const r = await runPlan(env, { resolver }); p = r.plan;
ok(env.HARAKA_DKIM_PRIVATE, 'DKIM 생성됨'); ok(a.lastBody && a.lastBody.includes('_dmarc') === false, 'DMARC(권장)는 자동 적용하지 않음');
ok(a.lastBody.includes('v=spf1 include:_spf.google.com mx a:mail.example.test ~all'), '기존 SPF 에 병합해 적용'); ok(a.lastBody.includes('arimail._domainkey.example.test.'), 'DKIM TXT 적용'); ok(a.lastBody.includes('<Value>10 mail.example.test.</Value>'), 'MX 적용');
eq(p.steps.find((s) => s.id === 'dns.apply').state, 'wait', '전파 대기');
ok(!p.steps.some((s) => s.id === 'haraka.ptr'), 'Haraka 관리 서비스가 없으면 실행 단계는 아직'); eq(p.ready, false, '설치 전');
// 외부 SMTP 중계
applyConfig(env, { inbound: 'resend', outbound: 'smtp', RESEND_API_KEY: '', HARAKA_TOKEN: 'x' });
p = await buildPlan(env, { resolver }); const relay = p.steps.find((s) => s.id === 'smtp.relay'); eq(relay.state, 'action', '중계 서버 정보 요청'); ok(relay.input.some((i) => i.key === 'SMTP_RELAY_HOST'), '입력 칸');
rmSync(dir, { recursive: true, force: true });
});
/** 운영(webmail.ibit.kr)에서 겪은 상태를 흉내 낸 Resend 도메인: 검증 실패, DKIM 은 다른 공개키가 DNS 에 있고, 수신 MX 는 예전 값이지만 Resend 는 verified. */
const failedDomain = () => ({ id: 'dom-9', name: 'example.test', status: 'failed', region: 'ap-northeast-1', capabilities: { sending: 'enabled', receiving: 'enabled' }, records: [
{ record: 'DKIM', name: 'resend._domainkey', type: 'TXT', value: 'p=NEWKEY', status: 'failed' },
{ record: 'SPF', name: 'send', type: 'MX', value: 'feedback-smtp.ap-northeast-1.amazonses.com', priority: 10, status: 'failed' },
{ record: 'SPF', name: 'send', type: 'TXT', value: 'v=spf1 include:amazonses.com ~all', status: 'failed' },
{ record: 'Receiving', name: '', type: 'MX', value: 'receive-apne1.forge.rmta.net', priority: 10, status: 'verified' },
] });
const liveDns = () => ({ 'TXT resend._domainkey.example.test': ['p=OLDKEY'], 'MX send.example.test': [{ priority: 10, exchange: 'feedback-smtp.ap-northeast-1.amazonses.com' }], 'TXT send.example.test': ['v=spf1 include:amazonses.com ~all'], 'MX example.test': [{ priority: 10, exchange: 'inbound-smtp.ap-northeast-1.amazonaws.com' }] });
await t('1.0.11 발신 도메인 상태 진단: 미등록 → mail_domain_missing(예전에는 ok), 검증 실패 → mail_domain_unverified 와 레코드별 wrong/missing·원인 문장, Resend 가 verified 라 한 레코드는 값이 달라도 ok, 403 "not verified" 는 mail_forbidden 이 아님', async () => {
const dir = tmp(); const env = await envFor(dir); const rs = { domains: [] }; env.RESEND_FETCH = fakeResendAdmin(rs);
applyConfig(env, { inbound: 'resend', outbound: 'resend', RESEND_API_KEY: 're_good_key_123456789' });
let st = await providerStatus(env, { force: true }); eq(st.provider, 'mail_domain_missing', '미등록 도메인'); ok(st.detail.includes('example.test') && st.detail.includes('자동 설정'), '안내 문장');
rs.domains.push(failedDomain()); env.DNS_RESOLVER = fakeResolver(liveDns());
st = await providerStatus(env, { force: true });
eq(st.provider, 'mail_domain_unverified', '검증 실패 → 별도 코드'); eq(st.domain.status, 'failed', '도메인 상태'); eq(st.domain.id, 'dom-9', '도메인 id');
const by = Object.fromEntries(st.records.map((r) => [r.type + ' ' + r.name, r]));
eq(by['TXT resend._domainkey.example.test'].problem, 'wrong', 'DKIM 값 다름'); eq(by['TXT resend._domainkey.example.test'].found[0], 'p=OLDKEY', '지금 값');
eq(by['MX send.example.test'].ok, true, 'Resend 가 failed 라 해도 우리 조회가 맞으면 ok'); eq(by['TXT send.example.test'].ok, true, 'SPF TXT ok');
eq(by['MX example.test'].ok, true, 'Resend verified → 예전 MX 값이라도 ok'); eq(by['MX example.test'].problem, null, '문제 없음');
ok(st.detail.startsWith('검증 실패 — 값이 다른 레코드: TXT resend._domainkey.example.test (지금 p=OLDKEY / 필요 p=NEWKEY)'), '원인 문장: ' + st.detail);
ok(st.detail.includes('자동으로 다시 검증') && !st.detail.includes('없는 레코드'), '값 바꾸기 안내, missing 없음');
eq(await providerStatus(env), st, '60초 캐시(force 없음)');
// DKIM 레코드가 아예 없으면 missing
delete env.DNS_RESOLVER.table['TXT resend._domainkey.example.test']; clearStatusCache(env);
st = await providerStatus(env, { force: true }); eq(st.records.find((r) => r.name.startsWith('resend._domainkey')).problem, 'missing', '없음'); ok(st.detail.includes('없는 레코드: TXT resend._domainkey.example.test'), 'missing 문장');
// 레코드가 모두 보이는데 failed(사용자가 고친 뒤) → 재검증 안내; pending → 기다리는 중
env.DNS_RESOLVER.table['TXT resend._domainkey.example.test'] = ['p=NEWKEY']; st = await providerStatus(env, { force: true }); ok(st.detail.includes('검증을 다시 요청하면') && st.detail.includes('자동으로 요청'), '재검증 안내: ' + st.detail);
rs.domains[0].status = 'pending'; st = await providerStatus(env, { force: true }); eq(st.provider, 'mail_domain_unverified', 'pending 도 미검증'); ok(st.detail.startsWith('검증 진행 중 — DNS 레코드는 모두 보입니다'), 'pending 문장');
// 레코드 진단이 실패해도(도메인 상세 500) 상태는 보고
const base = env.RESEND_FETCH; env.RESEND_FETCH = async (url, o) => (new URL(url).pathname === '/domains/dom-9' ? Response.json({ message: 'boom' }, { status: 500 }) : base(url, o));
st = await providerStatus(env, { force: true }); eq(st.provider, 'mail_domain_unverified', '진단 실패해도 코드 유지'); ok(st.detail.includes('레코드 진단 실패'), '진단 실패 표시'); env.RESEND_FETCH = base;
// verified → ok
rs.domains[0].status = 'verified'; st = await providerStatus(env, { force: true }); eq(st.provider, 'ok', '검증 완료'); eq(st.records, undefined, '정상이면 레코드 진단 없음');
// 오류 코드 매핑: Resend 의 403 "not verified" 문구는 도메인 문제, 그 밖의 403 은 키 권한 문제
eq(providerError(403, 'The example.test domain is not verified. Please, add and verify your domain on https://resend.com/domains'), 'mail_domain_unverified', '403 미검증');
eq(providerError(403, 'This API key is restricted to only send emails'), 'mail_forbidden', '403 권한'); eq(providerError(403), 'mail_forbidden', '403 문구 없음'); eq(providerError(401, 'x'), 'mail_auth_failed', '401');
rmSync(dir, { recursive: true, force: true });
});
await t('1.0.11 자동 재검증과 계획 단계: 값이 다른 레코드 → 사용자 작업(action)·검증 요청 안 함, 없는 레코드 → 반영 대기(wait), 모두 보이면 검증 자동 요청(failed → pending), pending/verified/끔 → 건너뜀', async () => {
const dir = tmp(); const env = await envFor(dir); const rs = { domains: [failedDomain()], webhooks: [{ id: 'wh-1', endpoint: 'https://mail.example.test/api/webhooks/resend', events: ['email.received'], status: 'enabled' }] }; env.RESEND_FETCH = fakeResendAdmin(rs);
applyConfig(env, { inbound: 'resend', outbound: 'resend', RESEND_API_KEY: 're_good_key_123456789', RESEND_WEBHOOK_SECRET: 'whsec_x' }); saveConfig(env, { RESEND_DOMAIN_ID: 'dom-9' }, { allowHidden: true });
const resolver = fakeResolver(liveDns());
const verifies = () => rs.calls.filter((c) => c.path.endsWith('/verify')).length;
// 값이 다름 → action
let p = await buildPlan(env, { resolver }); let v = p.steps.find((s) => s.id === 'resend.verify');
eq(v.state, 'action', '값 다름 → 사용자 작업'); ok(v.title.includes('값이 다릅니다') && v.detail.includes('값이 다른 레코드: TXT resend._domainkey.example.test (지금 p=OLDKEY / 필요 p=NEWKEY)'), '어느 레코드인지: ' + v.detail);
ok(v.actions.some((a) => a.id === 'verify') && v.actions.some((a) => a.id === 'recheck'), '단추'); eq(v.auto, false, '자동 실행 안 함');
const dns = p.steps.find((s) => s.id === 'dns.apply'); eq(dns.state, 'action', 'DNS 직접 수정'); ok(dns.title.includes('고치기') && dns.detail.startsWith('값이 다른 레코드 1개'), 'DNS 단계 문구: ' + dns.detail);
const mx = p.records.find((r) => r.type === 'MX' && r.name === 'example.test'); eq(mx.check.ok, true, '예전 MX 값이지만 Resend verified → ok'); eq(mx.check.byProvider, true, 'byProvider 표시');
eq(p.records.find((r) => r.name === 'resend._domainkey.example.test').check.problem, 'wrong', '표에도 wrong');
let r = await autoVerifyDomain(env, { resolver }); eq(r.skipped, 'records', '레코드 틀림 → 요청 안 함'); eq(r.problems.join(','), 'TXT resend._domainkey.example.test: wrong', '문제 목록'); eq(verifies(), 0, 'verify 호출 없음');
// 레코드 없음 → wait
delete resolver.table['TXT resend._domainkey.example.test']; p = await buildPlan(env, { resolver }); v = p.steps.find((s) => s.id === 'resend.verify');
eq(v.state, 'wait', '없음 → 전파 대기'); ok(v.detail.includes('아직 DNS 에 없는 Resend 레코드: TXT resend._domainkey.example.test') && v.detail.includes('자동으로 검증을 요청'), '대기 문구: ' + v.detail);
eq(p.steps.find((s) => s.id === 'dns.apply').title, 'DNS 레코드 1개 넣기', '없음 → 넣기');
eq((await autoVerifyDomain(env, { resolver })).skipped, 'records', '없음 → 요청 안 함');
// 고침 → todo(자동) + 자동 재검증이 요청
resolver.table['TXT resend._domainkey.example.test'] = ['p=NEWKEY']; p = await buildPlan(env, { resolver }); v = p.steps.find((s) => s.id === 'resend.verify'); eq(v.state, 'todo', '모두 보임 → 자동 실행 가능'); eq(v.auto, true, 'auto');
eq(p.steps.find((s) => s.id === 'dns.apply').state, 'ok', 'DNS 완료');
r = await autoVerifyDomain(env, { resolver }); eq(r.requested, true, '검증 요청'); eq(r.status, 'failed', '이전 상태'); eq(rs.domains[0].status, 'pending', 'Resend 상태 pending'); eq(verifies(), 1, 'verify 1회');
eq((await providerStatus(env, { force: true })).domain.status, 'pending', '상태 캐시 비움 → 새 상태');
// 진행 중·완료·끔 → 건너뜀
r = await autoVerifyDomain(env, { resolver }); eq(r.skipped, 'pending', 'pending 건너뜀'); eq(verifies(), 1, '추가 호출 없음');
rs.domains[0].status = 'verified'; eq((await autoVerifyDomain(env, { resolver })).skipped, 'verified', 'verified 건너뜀');
rs.domains[0].status = 'failed'; env.RESEND_AUTO_VERIFY = '0'; eq((await autoVerifyDomain(env, { resolver })).skipped, 'disabled', '끔'); delete env.RESEND_AUTO_VERIFY;
rs.domains = []; eq((await autoVerifyDomain(env, { resolver })).skipped, 'missing', '미등록 건너뜀'); env.RESEND_API_KEY = ''; eq((await autoVerifyDomain(env, { resolver })).skipped, 'no_key', '키 없음');
rmSync(dir, { recursive: true, force: true });
});
done();