/** * 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 ', to: ['a@example.test'], cc: ['c@example.test'], bcc: ['b@example.test'], subject: '제목 한글', text: '본문\n.점 줄\n끝', html: '

본문

', 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: '', 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: ') && 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: '

안녕

' }); 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 \r\nTo: hidden@elsewhere.test\r\nSubject: =?UTF-8?B?' + Buffer.from('하라카').toString('base64') + '?=\r\nMessage-ID: \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 ', '보낸 사람'); 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'), '__TITLE__v__VERSION__ __HOST__'); 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();