186 lines
22 KiB
JavaScript
186 lines
22 KiB
JavaScript
/**
|
|||
|
|
* 1.0.0 시험: 설정 파일(config.mjs)·발송 모듈 선택(providers)·SMTP 중계 클라이언트·내장 Haraka 수신 전달 API·초기 설정 API.
|
||
|
|
* 실행: node test/config.test.mjs (npm test 와 deploy.sh 가 함께 돌립니다)
|
||
|
|
*/
|
||
|
|
import net from 'node:net';
|
||
|
|
import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync } from 'node:fs';
|
||
|
|
import { tmpdir } from 'node:os';
|
||
|
|
import { join } from 'node:path';
|
||
|
|
import { makeEnv, runner, eq, ok, fakeResend } from './_helpers.mjs';
|
||
|
|
import { handle } from '../server/api.mjs';
|
||
|
|
import { loadConfig, saveConfig, publicConfig, applyConfig, configFile, setupToken, generateDkim, dnsRecords, coerceValue, CONFIG_SCHEMA, maskSecret } from '../server/config.mjs';
|
||
|
|
import { sendMail, outboundMode, inboundMode, relaySettings, outboundStatus, usesResend, usesHaraka } from '../server/providers/index.mjs';
|
||
|
|
import { smtpSend, smtpProbe, smtpErrorCode } from '../server/providers/smtp-client.mjs';
|
||
|
|
import { payloadToEml } from '../server/providers/smtp.mjs';
|
||
|
|
import { setupApi, createSetupServer, harakaStatus } from '../server/setup.mjs';
|
||
|
|
import { sendDraft, saveDraft } from '../server/store.mjs';
|
||
|
|
import { createSession } from '../server/auth.mjs';
|
||
|
|
|
||
|
|
const { t, done } = runner();
|
||
|
|
const api = (env, path, cookie, method = 'GET', body, extraHeaders = {}) => {
|
||
|
|
const headers = { ...(cookie ? { cookie } : {}), ...extraHeaders };
|
||
|
|
let payload = body;
|
||
|
|
if (body !== undefined && !(body instanceof FormData) && typeof body !== 'string' && !Buffer.isBuffer(body)) { payload = JSON.stringify(body); headers['content-type'] = 'application/json'; }
|
||
|
|
return handle(env, new Request('https://mail.arihakdang.com/api/' + path, { method, headers, body: payload }), { path: path.split('?')[0].split('/').filter(Boolean), ip: '127.0.0.1' });
|
||
|
|
};
|
||
|
|
const adminLogin = async (env) => { const r = await api(env, 'login', '', 'POST', { login: 'arihakdang', password: 'admin-secret-12' }, { 'x-mail-scope': 'admin' }); return r.headers.get('set-cookie').split(';')[0]; };
|
||
|
|
const tmp = () => mkdtempSync(join(tmpdir(), 'arimail-cfg-'));
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 시험용 SMTP 서버: EHLO/STARTTLS 없음/AUTH PLAIN/MAIL/RCPT/DATA 를 받아 기록합니다.
|
||
|
|
* @param {object} o rejectRcpt: 거부할 주소, authUser/authPass: 인증 요구, dataCode: DATA 끝 응답 코드
|
||
|
|
*/
|
||
|
|
function fakeSmtp({ rejectRcpt = [], authUser = '', authPass = '', dataCode = 250 } = {}) {
|
||
|
|
const sessions = [];
|
||
|
|
const server = net.createServer((socket) => {
|
||
|
|
const s = { from: null, rcpt: [], data: '', auth: null }; sessions.push(s); let inData = false, buf = '';
|
||
|
|
socket.write('220 fake.test ESMTP\r\n');
|
||
|
|
socket.on('data', (chunk) => {
|
||
|
|
buf += chunk.toString('latin1');
|
||
|
|
while (true) {
|
||
|
|
const i = buf.indexOf('\r\n'); if (i < 0) break; const line = buf.slice(0, i); buf = buf.slice(i + 2);
|
||
|
|
if (inData) { if (line === '.') { inData = false; socket.write(dataCode === 250 ? '250 2.0.0 Ok: queued as FAKE123\r\n' : `${dataCode} rejected by fake\r\n`); } else s.data += (line.startsWith('..') ? line.slice(1) : line) + '\r\n'; continue; }
|
||
|
|
const cmd = line.split(' ')[0].toUpperCase();
|
||
|
|
if (cmd === 'EHLO') socket.write('250-fake.test\r\n250-SIZE 10485760\r\n' + (authUser ? '250-AUTH PLAIN LOGIN\r\n' : '') + '250 8BITMIME\r\n');
|
||
|
|
else if (cmd === 'AUTH') { const [, user, pass] = Buffer.from(line.split(' ')[2] || '', 'base64').toString('utf8').split('\0'); s.auth = user; socket.write(user === authUser && pass === authPass ? '235 2.7.0 ok\r\n' : '535 5.7.8 authentication failed\r\n'); }
|
||
|
|
else if (cmd === 'MAIL') { s.from = line.match(/<([^>]*)>/)?.[1]; socket.write('250 ok\r\n'); }
|
||
|
|
else if (cmd === 'RCPT') { const a = line.match(/<([^>]*)>/)?.[1]; if (rejectRcpt.includes(a)) socket.write('550 5.1.1 no such user\r\n'); else { s.rcpt.push(a); socket.write('250 ok\r\n'); } }
|
||
|
|
else if (cmd === 'DATA') { inData = true; socket.write('354 go ahead\r\n'); }
|
||
|
|
else if (cmd === 'QUIT') { socket.write('221 bye\r\n'); socket.end(); }
|
||
|
|
else socket.write('250 ok\r\n');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ port: server.address().port, sessions, close: () => server.close() })));
|
||
|
|
}
|
||
|
|
|
||
|
|
await t('1.0.0 설정 파일: 첫 시작에 환경 변수 값을 가져와 만들고, 파일 값이 환경 변수보다 우선하며, 저장은 즉시 env 에 적용되고 비밀값은 가려서 보여 준다', async () => {
|
||
|
|
const dir = tmp(); const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), RESEND_API_KEY: 're_oldkey_from_env_1234', MAIL_FROM_NAME: '예전 이름', STATS_TIMEZONE: 'Asia/Seoul' });
|
||
|
|
const cfg = loadConfig(env);
|
||
|
|
ok(existsSync(configFile(env)) && configFile(env) === join(dir, 'config.json'), '설정 파일이 DB 옆에 생김');
|
||
|
|
eq(cfg.RESEND_API_KEY, 're_oldkey_from_env_1234', '환경 변수 값 가져오기'); eq(cfg.inbound, 'resend', '기본 수신 resend'); eq(cfg.outbound, 'resend', '기본 발신 resend');
|
||
|
|
ok(cfg.HARAKA_TOKEN && cfg.HARAKA_TOKEN.length > 20, '내부 토큰 자동 생성'); ok(env.CONFIG_CREATED, '새로 만듦 표시');
|
||
|
|
ok(JSON.parse(readFileSync(configFile(env), 'utf8'))._meta.importedFromEnv.includes('MAIL_FROM_NAME'), '가져온 키 기록');
|
||
|
|
// 저장 → 즉시 적용 + 알림
|
||
|
|
let notified = 0; env.ON_CONFIG_CHANGE = () => notified++;
|
||
|
|
const pub = saveConfig(env, { RESEND_API_KEY: 're_newkey_abcdefgh_9876', outbound: 'smtp', SMTP_RELAY_HOST: 'smtp.example.test', SMTP_RELAY_PORT: '2525', SITE_TITLE: '새 제목' });
|
||
|
|
eq(env.RESEND_API_KEY, 're_newkey_abcdefgh_9876', '저장 즉시 env 반영'); eq(env.MAIL_OUTBOUND, 'smtp', '발신 방식'); eq(env.SITE_TITLE, '새 제목', '제목'); eq(notified, 1, '변경 알림');
|
||
|
|
eq(pub.values.RESEND_API_KEY, maskSecret('re_newkey_abcdefgh_9876'), '비밀값 가림'); ok(!pub.values.RESEND_API_KEY.includes('abcdefgh'), '중간 글자 숨김');
|
||
|
|
eq(pub.sources.RESEND_API_KEY, 'config', '출처 config'); eq(pub.values.SMTP_RELAY_PORT, 2525, '숫자 변환'); eq(pub.present.HARAKA_TOKEN, true, 'hidden 항목은 있음/없음만'); ok(!('HARAKA_TOKEN' in pub.values), 'hidden 값 비노출');
|
||
|
|
// 다시 읽으면 파일이 기준(환경 변수에 옛 값이 있어도)
|
||
|
|
const env2 = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), RESEND_API_KEY: 're_oldkey_from_env_1234', SITE_TITLE: '환경 제목' });
|
||
|
|
loadConfig(env2); eq(env2.RESEND_API_KEY, 're_newkey_abcdefgh_9876', '파일 값이 환경 변수보다 우선'); eq(env2.SITE_TITLE, '새 제목', '제목도 파일 우선'); ok(!env2.CONFIG_CREATED, '다시 만들지 않음');
|
||
|
|
// 비우기: 파일에 '' 로 저장되면 환경 변수 값이 되살아나지 않음
|
||
|
|
saveConfig(env2, { SITE_TITLE: '' }); eq(env2.SITE_TITLE, '', '비운 값은 빈 값'); eq(publicConfig(env2).sources.SITE_TITLE, 'default', '비운 값은 환경 변수로 되살아나지 않음(출처 default)');
|
||
|
|
// 검증
|
||
|
|
let err = null; try { saveConfig(env2, { outbound: 'pigeon' }); } catch (e) { err = e; } eq(err?.message, 'validation', '모르는 발신 방식 거부'); eq(err?.field, 'outbound', '필드 이름');
|
||
|
|
err = null; try { saveConfig(env2, { HARAKA_PORT: '25a' }); } catch (e) { err = e; } eq(err?.message, 'validation', '숫자 아님 거부');
|
||
|
|
eq(coerceValue(CONFIG_SCHEMA.find((i) => i.key === 'SETUP_DONE'), '1'), true, 'bool 변환');
|
||
|
|
// 숨김 항목은 일반 저장으로 바꿀 수 없음
|
||
|
|
saveConfig(env2, { HARAKA_TOKEN: 'hacked' }); ok(env2.HARAKA_TOKEN !== 'hacked', 'hidden 항목 보호');
|
||
|
|
// 토큰 파일
|
||
|
|
const tok = setupToken(env2); eq(setupToken(env2), tok, '토큰 고정'); ok(existsSync(join(dir, 'setup-token')), '토큰 파일');
|
||
|
|
// DKIM
|
||
|
|
const dns = generateDkim(env2); ok(dns.hasDkim && dns.records.some((r) => r.name.startsWith('arimail._domainkey.') && r.value.startsWith('v=DKIM1; k=rsa; p=')), 'DKIM 레코드');
|
||
|
|
ok(env2.HARAKA_DKIM_PRIVATE.includes('BEGIN RSA PRIVATE KEY'), '비밀키 저장'); const pub1 = env2.HARAKA_DKIM_PUBLIC; generateDkim(env2); eq(env2.HARAKA_DKIM_PUBLIC, pub1, 'force 없이는 유지'); generateDkim(env2, { force: true }); ok(env2.HARAKA_DKIM_PUBLIC !== pub1, 'force 로 교체');
|
||
|
|
ok(dnsRecords(env2).records.find((r) => r.kind === 'MX').value.includes('mail.arihakdang.com'), 'MX 는 호스트 이름');
|
||
|
|
rmSync(dir, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
await t('1.0.0 발송 모듈 선택: resend 는 Resend API, smtp 는 중계 서버(봉투에 Bcc, 헤더에는 없음, 점 스터핑, 일부 거부·전체 거부·인증), haraka 는 로컬 제출 포트', async () => {
|
||
|
|
const calls = [], env = await makeEnv({ RESEND_FETCH: fakeResend({ calls }) });
|
||
|
|
applyConfig(env, { inbound: 'resend', outbound: 'resend', HARAKA_TOKEN: 'x' });
|
||
|
|
eq(outboundMode(env), 'resend', '기본 resend'); ok(usesResend(env) && !usesHaraka(env), '사용 여부');
|
||
|
|
const payload = { from: 'Admin <admin@arihakdang.com>', to: ['a@example.test'], cc: ['c@example.test'], bcc: ['b@example.test'], subject: '제목 한글', text: '본문\n.점 줄\n끝', html: '<p>본문</p>', attachments: [{ filename: '첨부.txt', content: Buffer.from('hello').toString('base64'), content_type: 'text/plain' }] };
|
||
|
|
const r1 = await sendMail(env, payload, 'k1'); ok(r1.id && calls.some((c) => c.path === '/emails'), 'Resend 호출');
|
||
|
|
// smtp 중계
|
||
|
|
const fake = await fakeSmtp({ authUser: 'relayuser', authPass: 'relaypass' });
|
||
|
|
applyConfig(env, { inbound: 'resend', outbound: 'smtp', SMTP_RELAY_HOST: '127.0.0.1', SMTP_RELAY_PORT: fake.port, SMTP_RELAY_SECURITY: 'none', SMTP_RELAY_USER: 'relayuser', SMTP_RELAY_PASS: 'relaypass', HARAKA_TOKEN: 'x' });
|
||
|
|
eq(relaySettings(env).port, fake.port, '중계 설정'); const before = calls.length;
|
||
|
|
const r2 = await sendMail(env, payload, 'k2'); eq(calls.length, before, 'Resend 는 부르지 않음'); ok(r2.id.includes('@arihakdang.com'), 'Message-ID 가 id');
|
||
|
|
const s = fake.sessions.at(-1); eq(s.auth, 'relayuser', 'AUTH PLAIN'); eq(s.from, 'admin@arihakdang.com', '봉투 보낸 사람'); eq(JSON.stringify(s.rcpt), JSON.stringify(['a@example.test', 'c@example.test', 'b@example.test']), '봉투 받는 사람(To·Cc·Bcc)');
|
||
|
|
ok(s.data.includes('Subject: =?UTF-8?') && s.data.includes('To: a@example.test') && s.data.includes('Cc: c@example.test') && !/^Bcc:/mi.test(s.data), '헤더에 Bcc 없음'); ok(!/\r\n\.\./.test(s.data), '점 스터핑이 풀린 뒤 이중 점 없음'); ok(s.data.includes('Content-Disposition: attachment'), '첨부 포함');
|
||
|
|
const st = await outboundStatus(env, { force: true }); eq(st.mode, 'smtp', '상태 모드'); eq(st.provider, 'ok', '연결 확인'); eq(st.relay.auth, true, '인증 표시');
|
||
|
|
// 일부 거부 → 성공(나머지), 전체 거부 → mail_rejected
|
||
|
|
const fake2 = await fakeSmtp({ rejectRcpt: ['b@example.test'] });
|
||
|
|
applyConfig(env, { inbound: 'resend', outbound: 'smtp', SMTP_RELAY_HOST: '127.0.0.1', SMTP_RELAY_PORT: fake2.port, SMTP_RELAY_SECURITY: 'none', SMTP_RELAY_USER: '', SMTP_RELAY_PASS: '', HARAKA_TOKEN: 'x' });
|
||
|
|
const r3 = await sendMail(env, payload); eq(r3.rejected.length, 1, '일부 거부 기록'); eq(r3.accepted.length, 2, '나머지 발송');
|
||
|
|
let err = null; try { await sendMail(env, { ...payload, to: ['b@example.test'], cc: [], bcc: [] }); } catch (e) { err = e; } eq(err?.message, 'mail_rejected', '전체 거부'); ok(err.providerMessage.includes('550'), '서버 응답 보존');
|
||
|
|
// 인증 실패 / 접속 실패 / DATA 거부
|
||
|
|
const fake3 = await fakeSmtp({ authUser: 'u', authPass: 'p' });
|
||
|
|
err = null; try { await smtpSend({ host: '127.0.0.1', port: fake3.port, security: 'none', user: 'u', pass: 'wrong', from: 'a@x.test', to: ['b@x.test'], eml: Buffer.from('Subject: x\r\n\r\nbody\r\n') }); } catch (e) { err = e; } eq(err?.message, 'mail_auth_failed', '인증 실패 코드');
|
||
|
|
err = null; try { await smtpSend({ host: '127.0.0.1', port: 1, security: 'none', from: 'a@x.test', to: ['b@x.test'], eml: Buffer.from('x\r\n'), timeoutMs: 2000 }); } catch (e) { err = e; } eq(err?.message, 'mail_provider_unavailable', '접속 실패 코드');
|
||
|
|
const dotFake = await fakeSmtp(); await smtpSend({ host: '127.0.0.1', port: dotFake.port, security: 'none', from: 'a@x.test', to: ['b@x.test'], eml: Buffer.from('Subject: x\r\n\r\n.dot line\r\nend\r\n') }); ok(dotFake.sessions[0].data.includes('.dot line\r\nend'), '점으로 시작하는 줄 전송(점 스터핑)'); dotFake.close();
|
||
|
|
const fake4 = await fakeSmtp({ dataCode: 552 }); err = null; try { await smtpSend({ host: '127.0.0.1', port: fake4.port, security: 'none', from: 'a@x.test', to: ['b@x.test'], eml: Buffer.from('x\r\n') }); } catch (e) { err = e; } eq(err?.message, 'mail_rejected', 'DATA 거부');
|
||
|
|
eq(smtpErrorCode(451, 'data'), 'mail_rate_limited', '4xx 는 일시 오류'); eq((await smtpProbe({ host: '127.0.0.1', port: 1, security: 'none', timeoutMs: 1000 })).ok, false, '연결 확인 실패');
|
||
|
|
// haraka 모드: 127.0.0.1:HARAKA_RELAY_PORT
|
||
|
|
const fake5 = await fakeSmtp(); applyConfig(env, { inbound: 'resend', outbound: 'haraka', HARAKA_RELAY_PORT: fake5.port, HARAKA_TOKEN: 'x' });
|
||
|
|
eq(relaySettings(env).host, '127.0.0.1', '로컬 고정'); await sendMail(env, payload); eq(fake5.sessions.length, 1, 'Haraka 제출 포트로 전달'); eq(fake5.sessions[0].auth, null, '인증 없음');
|
||
|
|
// payloadToEml: 인라인 그림 cid
|
||
|
|
const { eml } = payloadToEml({ from: 'a@x.test', to: ['b@x.test'], subject: 's', html: '<img src="cid:pic">', attachments: [{ filename: 'p.png', content: Buffer.from('PNG').toString('base64'), content_type: 'image/png', content_id: 'pic' }] }, 'x.test');
|
||
|
|
ok(eml.toString().includes('Content-ID: <pic>') && eml.toString().includes('multipart/related'), '인라인 첨부');
|
||
|
|
// 웹 발송(sendDraft)도 같은 길을 씁니다
|
||
|
|
const fake6 = await fakeSmtp(); applyConfig(env, { inbound: 'resend', outbound: 'smtp', SMTP_RELAY_HOST: '127.0.0.1', SMTP_RELAY_PORT: fake6.port, SMTP_RELAY_SECURITY: 'none', SMTP_RELAY_USER: '', SMTP_RELAY_PASS: '', HARAKA_TOKEN: 'x' });
|
||
|
|
const draftId = await saveDraft(env, { to: 'friend@example.test', subject: '웹 발송', html: '<p>안녕</p>' }); const sent = await sendDraft(env, draftId);
|
||
|
|
eq(sent.state, 'sent', '발송 완료'); eq(fake6.sessions.length, 1, '중계 서버로'); ok(sent.provider_id.includes('@arihakdang.com'), 'provider_id 는 Message-ID');
|
||
|
|
for (const f of [fake, fake2, fake3, fake4, fake5, fake6]) f.close();
|
||
|
|
});
|
||
|
|
|
||
|
|
await t('1.0.0 Haraka 수신 전달 API: 토큰·수신 방식 확인, RCPT 조회, 원본 저장(봉투 수신자로 메일함 결정)·중복·사본·용량, 상태 기록', async () => {
|
||
|
|
const env = await makeEnv({}); applyConfig(env, { inbound: 'resend', outbound: 'resend', HARAKA_TOKEN: 'tok-secret-1234567890' });
|
||
|
|
const hdr = (extra = {}) => ({ 'x-arimail-token': 'tok-secret-1234567890', ...extra });
|
||
|
|
eq((await api(env, 'inbound/haraka/rcpt?addr=admin@arihakdang.com', '', 'GET', undefined, hdr())).status, 404, '수신 방식이 resend 면 닫힘');
|
||
|
|
applyConfig(env, { inbound: 'haraka', outbound: 'resend', HARAKA_TOKEN: 'tok-secret-1234567890' });
|
||
|
|
eq((await api(env, 'inbound/haraka/rcpt?addr=admin@arihakdang.com', '', 'GET', undefined, { 'x-arimail-token': 'wrong' })).status, 401, '토큰 틀림');
|
||
|
|
eq((await (await api(env, 'inbound/haraka/rcpt?addr=admin@arihakdang.com', '', 'GET', undefined, hdr())).json()).local, true, '우리 메일함');
|
||
|
|
eq((await (await api(env, 'inbound/haraka/rcpt?addr=nobody@arihakdang.com', '', 'GET', undefined, hdr())).json()).local, false, '없는 사용자');
|
||
|
|
const eml = 'From: Someone <someone@outside.test>\r\nTo: hidden@elsewhere.test\r\nSubject: =?UTF-8?B?' + Buffer.from('하라카').toString('base64') + '?=\r\nMessage-ID: <h1@outside.test>\r\nDate: Tue, 14 Oct 2026 10:00:00 +0900\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n하라카 본문\r\n';
|
||
|
|
const post = (id, rcpt, body = eml) => api(env, 'inbound/haraka', '', 'POST', Buffer.from(body), hdr({ 'content-type': 'message/rfc822', 'x-arimail-id': id, 'x-arimail-from': 'someone@outside.test', 'x-arimail-rcpt': JSON.stringify(rcpt) }));
|
||
|
|
const r1 = await (await post('tx-1', ['admin@arihakdang.com'])).json();
|
||
|
|
ok(r1.ok && r1.imported && r1.mailbox === 'admin@arihakdang.com', '봉투 수신자(Bcc 로 받은 메일)로 메일함 결정 — ' + JSON.stringify(r1));
|
||
|
|
const row = await env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(r1.id).first();
|
||
|
|
eq(row.folder, 'inbox', '받은 메일함'); eq(row.direction, 'inbound', '수신'); eq(row.provider_id, 'haraka:tx-1', 'provider_id'); eq(row.subject, '하라카', '제목 디코딩'); eq(row.raw_status, 'stored', '원본 그대로'); ok(!row.read_at, '읽지 않음'); eq(row.sender, 'Someone <someone@outside.test>', '보낸 사람');
|
||
|
|
const r2 = await (await post('tx-1', ['admin@arihakdang.com'])).json(); eq(r2.skipped, 'exists', '같은 트랜잭션 중복 저장 안 함');
|
||
|
|
eq((await (await post('tx-2', ['stranger@other.test'])).json()).skipped, 'other_mailbox', '우리 메일함 없음');
|
||
|
|
// 사본: 두 메일함에 함께 온 메일
|
||
|
|
const { createUser } = await import('../server/auth.mjs'); await createUser(env, { login: 'staff', name: '직원', password: 'staff-pass-123', role: 'member' });
|
||
|
|
const r3 = await (await post('tx-3', ['admin@arihakdang.com', 'staff@arihakdang.com'])).json(); eq(r3.copies?.length, 1, '다른 메일함 사본 — ' + JSON.stringify(r3)); eq(r3.copies[0].mailbox, 'staff@arihakdang.com', '사본 메일함');
|
||
|
|
// 상태 기록과 관리자 상태의 via_haraka
|
||
|
|
const events = (await env.DB.prepare("SELECT status, COUNT(*) n FROM webhook_events WHERE type='haraka.received' GROUP BY status ORDER BY status").all()).results;
|
||
|
|
eq(JSON.stringify(events), JSON.stringify([{ status: 'skipped', n: 1 }, { status: 'stored', n: 2 }]), '수신 기록(중복은 같은 id 로 덮어씀)');
|
||
|
|
const admin = await adminLogin(env); const st = await (await api(env, 'admin/status', admin, 'GET', undefined, { 'x-mail-scope': 'admin' })).json();
|
||
|
|
eq(st.storage.via_haraka, 3, 'Haraka 수신 통계(사본 포함)'); eq(st.mail.inbound, 'haraka', '상태의 수신 방식'); eq(st.mail.outbound, 'resend', '상태의 발신 방식');
|
||
|
|
eq((await api(env, 'inbound/haraka', '', 'POST', Buffer.alloc(0), hdr({ 'x-arimail-id': 'tx-4', 'x-arimail-rcpt': '["admin@arihakdang.com"]' }))).status, 400, '빈 본문 거부');
|
||
|
|
});
|
||
|
|
|
||
|
|
await t('1.0.0 초기 설정 API: 관리자 세션(/api/setup)과 전용 포트(토큰) 모두 같은 응답, 저장·연결 확인·DKIM·재시작 불가 안내, 토큰 오류 제한', async () => {
|
||
|
|
const dir = tmp(); const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), APP_VERSION: '1.0.0-test', PUBLIC_HOST: 'mail.arihakdang.com' }); loadConfig(env);
|
||
|
|
// 관리자 세션 없이·웹 메일 세션으로는 403
|
||
|
|
eq((await api(env, 'setup/config')).status, 401, '로그인 없음');
|
||
|
|
const admin = await adminLogin(env); const A = { 'x-mail-scope': 'admin' };
|
||
|
|
const d = await (await api(env, 'setup/config', admin, 'GET', undefined, A)).json();
|
||
|
|
ok(d.ok && d.values && d.schema.length && d.meta.host === 'mail.arihakdang.com' && d.meta.webhookUrl.endsWith('/api/webhooks/resend'), '설정 응답'); eq(d.meta.inbound, 'resend', '메타 수신'); ok(d.meta.dns.records.length >= 3, 'DNS 안내'); ok(d.meta.haraka && d.meta.haraka.installed === false, 'Haraka 미설치 표시');
|
||
|
|
const saved = await (await api(env, 'setup/config', admin, 'PUT', { values: { MAIL_FROM_NAME: '새 발신자', SETUP_DONE: true } }, A)).json();
|
||
|
|
eq(saved.values.MAIL_FROM_NAME, '새 발신자', '저장'); eq(saved.values.SETUP_DONE, true, '완료 표시'); eq(env.MAIL_FROM_NAME, '새 발신자', '즉시 적용');
|
||
|
|
const bad = await api(env, 'setup/config', admin, 'PUT', { values: { HARAKA_PORT: 'abc' } }, A); eq(bad.status, 422, '검증 실패'); eq((await bad.json()).field, 'HARAKA_PORT', '필드');
|
||
|
|
const chk = await (await api(env, 'setup/check', admin, 'POST', { what: 'outbound' }, A)).json(); eq(chk.outboundStatus.mode, 'resend', '연결 확인 응답');
|
||
|
|
const dk = await (await api(env, 'setup/dkim', admin, 'POST', {}, A)).json(); ok(dk.dns.hasDkim, 'DKIM 생성');
|
||
|
|
eq((await api(env, 'setup/restart', admin, 'POST', {}, A)).status, 409, 'systemd 아닐 때 재시작 불가');
|
||
|
|
// 전용 포트 서버(토큰)
|
||
|
|
const webDir = join(dir, 'web'); writeFileSync; const { mkdirSync } = await import('node:fs'); mkdirSync(webDir); writeFileSync(join(webDir, 'setup.html'), '<!doctype html><title>__TITLE__</title><body data-mode="__MODE__">v__VERSION__ __HOST__</body>'); writeFileSync(join(webDir, 'setup.js'), '//'); writeFileSync(join(webDir, 'setup.css'), '');
|
||
|
|
const server = createSetupServer(env, { webDir, tlsOptions: null }); const bound = await server.listen({ port: 0, host: '127.0.0.1' });
|
||
|
|
const base = `http://127.0.0.1:${bound.port}`; const token = setupToken(env);
|
||
|
|
const page = await (await fetch(base + '/')).text(); ok(page.includes('data-mode="token"') && page.includes('v1.0.0-test'), '화면 자리표시자 치환');
|
||
|
|
eq((await fetch(base + '/api/setup/config')).status, 401, '토큰 없음');
|
||
|
|
eq((await fetch(base + '/api/setup/config', { headers: { 'X-Setup-Token': 'nope' } })).status, 401, '토큰 틀림');
|
||
|
|
const ok1 = await (await fetch(base + '/api/setup/config?token=' + encodeURIComponent(token))).json(); eq(ok1.values.MAIL_FROM_NAME, '새 발신자', '주소 토큰으로 읽기');
|
||
|
|
const put = await (await fetch(base + '/api/setup/config', { method: 'PUT', headers: { 'X-Setup-Token': token, 'Content-Type': 'application/json' }, body: JSON.stringify({ values: { SITE_TITLE: '토큰 저장' } }) })).json(); eq(put.values.SITE_TITLE, '토큰 저장', '헤더 토큰으로 저장');
|
||
|
|
for (let i = 0; i < 10; i++) await fetch(base + '/api/setup/config', { headers: { 'X-Setup-Token': 'guess' + i } });
|
||
|
|
eq((await fetch(base + '/api/setup/config', { headers: { 'X-Setup-Token': token } })).status, 429, '틀린 시도 10회 뒤 차단(맞는 토큰도)');
|
||
|
|
ok(server.urls(token)[0].includes(':' + bound.port + '/?token='), '접속 주소'); server.close();
|
||
|
|
ok(harakaStatus(env).installed === false && harakaStatus(env).status === null, 'Haraka 상태 파일 없음');
|
||
|
|
rmSync(dir, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
done();
|