Files
AriWeb-Suite/modules/arimail/test/ariweb.test.mjs
T

170 lines
19 KiB
JavaScript

/**
* [Ariweb Suite 변경] Ariweb Suite 0.1.1 도입 때 고친·더한 동작의 회귀 테스트(Dev-Order §0.4).
* 1) KST 월 경계(고정 시각) 2) Haraka/SMTP 중계 발신 때 Resend 키를 요구하지 않음 3) 등록부 ariweb·operating 4) 도메인별 보장량·수신자 기준 통수·대용량 첨부 포함 저장 공간
* 5) 100% 동작(저장 공간·월간 통수)과 다음 KST 월 초기화 6) 발송 속도·제출 제한·대기열 7) 운영 상태(동결) 8) 포털 SSO 티켓
* 실행: node test/ariweb.test.mjs
*/
import net from 'node:net';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHmac, randomBytes } from 'node:crypto';
import { makeEnv, runner, eq, ok, request, params, fakeResend } from './_helpers.mjs';
import { handle } from '../server/api.mjs';
import { loadConfig, saveConfig, setupToken } from '../server/config.mjs';
import { openTenants, normalizeEntry } from '../server/domains.mjs';
import { setupApi } from '../server/setup.mjs';
import { usageApi } from '../server/usage.mjs';
import { periodBounds, monthlyCount, countMail, systemLimits, checkInbound, checkOutbound, totalUsage, limitState, mailboxUsage } from '../server/limits.mjs';
import { submitMessage } from '../server/smtp.mjs';
import { buildEml } from '../server/mime.mjs';
import { ingestRaw, saveDraft } from '../server/store.mjs';
import * as sq from '../server/sendqueue.mjs';
const { t, done } = runner();
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const tmp = () => mkdtempSync(join(tmpdir(), 'arimail-ariweb-'));
const api = (env, path, cookie, method, body, headers) => handle(env, request(path, cookie, method, body, headers), { ...params(path), prefix: '' });
const open = (env) => openTenants(env, { migrationsDir: join(ROOT, 'migrations'), log: { log() {}, error() {} } });
const setup = (env, path, method = 'GET', body, tenants = null) => setupApi(env, new Request('https://mail.arihakdang.com/api/setup/' + path, { method, headers: { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) }), path.split('/'), { ip: '127.0.0.1', tenants });
async function base(over = {}) { const dir = tmp(); const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), PUBLIC_HOST: 'mail.arihakdang.com', ...over }); loadConfig(env); return { env, dir }; }
/** 발신 메일 행(수신자 n명) */
async function sentRow(env, { at, recipients = ['a@x.test'], cc = [], bcc = [], mailbox = 'admin@arihakdang.com', size = 1000 }) {
const id = randomBytes(8).toString('hex');
await env.DB.prepare("INSERT INTO messages(id,direction,folder,sender,recipients_json,cc_json,bcc_json,subject,text,source,state,received_at,created_at,updated_at,sent_at,raw_status,raw_size,mailbox) VALUES(?,'outbound','sent','a',?,?,?,'s','b','web','sent',?,?,?,?,'stored',?,?)").bind(id, JSON.stringify(recipients), JSON.stringify(cc), JSON.stringify(bcc), at, at, at, at, size, mailbox).run();
return id;
}
async function inboundRow(env, { at, mailbox = 'admin@arihakdang.com', size = 1000 }) {
const id = randomBytes(8).toString('hex');
await env.DB.prepare("INSERT INTO messages(id,direction,folder,sender,recipients_json,subject,text,source,state,received_at,created_at,updated_at,raw_status,raw_size,mailbox) VALUES(?,'inbound','inbox','x@y.test','[]','s','b','sync','received',?,?,?,'stored',?,?)").bind(id, at, at, at, size, mailbox).run();
return id;
}
await t('1) KST 월 경계는 고정 시각으로 결정적이다: 10-31 23:59:59 KST 는 10월, 11-01 00:00:00 KST 는 11월; monthlyCount 는 STATS_NOW 를 따른다', async () => {
const b1 = periodBounds('month', 'Asia/Seoul', new Date('2026-10-31T14:59:59Z')); eq(b1.start, '2026-09-30T15:00:00.000Z', '10월 시작'); eq(b1.end, '2026-10-31T15:00:00.000Z', '10월 끝');
const b2 = periodBounds('month', 'Asia/Seoul', new Date('2026-10-31T15:00:00Z')); eq(b2.start, '2026-10-31T15:00:00.000Z', '11월 시작'); eq(b2.end, '2026-11-30T15:00:00.000Z', '11월 끝');
const { env } = await base();
await sentRow(env, { at: '2026-10-31T14:30:00Z' }); await sentRow(env, { at: '2026-10-31T15:30:00Z' }); await inboundRow(env, { at: '2026-10-31T15:10:00Z' });
env.STATS_NOW = '2026-10-31T14:59:59Z'; eq(await monthlyCount(env), 1, '10월: 발신 1');
env.STATS_NOW = '2026-10-31T15:00:00Z'; eq(await monthlyCount(env), 2, '11월: 발신 1 + 수신 1');
eq((await limitState(env)).monthReset, '2026-11-30T15:00:00.000Z', '다음 KST 월 초기화 시각');
});
await t('2) 발신 방식이 smtp/haraka 면 RESEND_API_KEY 없이도 SMTP 제출이 발송된다(예전에는 451)', async () => {
// 가짜 중계 서버: EHLO/MAIL/RCPT/DATA/QUIT 에 성공 응답
const got = []; const relay = net.createServer((s) => { s.write('220 relay.test ESMTP\r\n'); let data = false, buf = ''; s.on('data', (c) => { buf += c.toString('latin1'); if (data) { if (buf.includes('\r\n.\r\n')) { data = false; buf = ''; s.write('250 2.0.0 queued as relay-1\r\n'); } return; } let i; while ((i = buf.indexOf('\r\n')) >= 0) { const line = buf.slice(0, i); buf = buf.slice(i + 2); got.push(line); if (/^EHLO/i.test(line)) s.write('250-relay.test\r\n250 8BITMIME\r\n'); else if (/^(MAIL|RCPT)/i.test(line)) s.write('250 OK\r\n'); else if (/^DATA/i.test(line)) { data = true; s.write('354 go\r\n'); } else if (/^QUIT/i.test(line)) { s.write('221 bye\r\n'); s.end(); } else s.write('250 OK\r\n'); } }); });
await new Promise((r) => relay.listen(0, '127.0.0.1', r)); const port = relay.address().port;
try {
const env = await makeEnv({ RESEND_API_KEY: '', MAIL_OUTBOUND: 'smtp', SMTP_RELAY_HOST: '127.0.0.1', SMTP_RELAY_PORT: String(port), SMTP_RELAY_SECURITY: 'none' });
const user = { id: 'www:admin-1', login: 'arihakdang', name: '관리자', role: 'admin', mailbox: 'admin@arihakdang.com', source: 'www' };
const eml = buildEml({ from: 'admin@arihakdang.com', to: ['friend@example.test'], subject: 'relay', text: 'hi', messageId: '<r1@arihakdang.com>' });
const r = await submitMessage(env, user, { mailFrom: 'admin@arihakdang.com', rcpt: ['friend@example.test'], eml });
ok(r.id && r.providerId, '발송·저장: ' + JSON.stringify(r)); ok(got.some((l) => /^RCPT TO:<friend@example.test>/i.test(l)), '중계 서버가 받음');
// resend 방식이면 여전히 키가 필요(기존 동작 유지)
let threw = null; try { await submitMessage({ ...env, MAIL_OUTBOUND: 'resend' }, user, { mailFrom: 'admin@arihakdang.com', rcpt: ['friend@example.test'], eml }); } catch (e) { threw = e; } eq(threw?.code, 451, 'resend 방식은 키 없으면 451');
} finally { relay.close(); }
});
await t('3) 등록부 ariweb(사이트 대표/독립)·operating 을 설정 API 로 정하고 목록·사용량 API 에 보인다', async () => {
const { env } = await base(); const tenants = open(env); setupToken(env);
const e = normalizeEntry({ domain: 'x.test', ariweb: { kind: 'standalone', mailServiceId: 'ms_1', groupId: 'g_1' }, operating: 'migrating_in' }); eq(e.ariweb.kind, 'standalone', '정규화'); eq(e.operating, 'migrating_in', '운영 상태');
let threw = null; try { normalizeEntry({ domain: 'x.test', ariweb: { kind: 'nope' } }); } catch (x) { threw = x; } eq(threw?.field, 'ariweb.kind', '잘못된 종류 거부');
threw = null; try { normalizeEntry({ domain: 'x.test', operating: 'gone' }); } catch (x) { threw = x; } eq(threw?.field, 'operating', '잘못된 운영 상태 거부');
await tenants.add({ domain: 'second.test', adminPassword: 'secondpw1' });
let r = await (await setup(env, 'domains/second.test', 'PATCH', { ariweb: { kind: 'site_bound', siteId: 's_abc', groupId: 'g_1', mailServiceId: 'ms_2' } }, tenants)).json(); eq(r.domain.ariweb.siteId, 's_abc', 'PATCH 반영');
r = await (await setup(env, 'domains/arihakdang.com', 'PATCH', { ariweb: { kind: 'standalone', mailServiceId: 'ms_0' }, operating: 'frozen' }, tenants)).json(); eq(r.domain.ariweb.kind, 'standalone', '기본 도메인도 가능'); eq(r.domain.operating, 'frozen', '기본 도메인 운영 상태');
const list = (await (await setup(env, 'domains', 'GET', undefined, tenants)).json()).domains; eq(list.find((d) => d.domain === 'second.test').ariweb.kind, 'site_bound', '목록'); eq(list[0].operating, 'frozen', '목록 운영 상태');
const { key } = await (await setup(env, 'usage-key', 'POST', { action: 'generate' }, tenants)).json();
const u = await (await usageApi(tenants, new Request('https://x/api/usage/second.test', { headers: { Authorization: 'Bearer ' + key } }), { path: ['second.test'], ip: '1.1.1.1', query: new URLSearchParams() })).json();
eq(u.ariweb.kind, 'site_bound', '사용량 API 에 ariweb'); eq(u.operating, 'active', '사용량 API 에 운영 상태'); eq(u.warnLevel, 0, '경고 구간'); eq(u.limitedReasons.length, 0, '제한 없음'); eq(u.traffic.countingRule, 'per_final_recipient', '통수 규칙');
tenants.close();
});
await t('4) 도메인별 보장량은 설정 파일이 환경보다 우선, 발신 통수는 최종 수신자 기준, 저장 공간은 대용량 첨부 포함', async () => {
const { env } = await base({ LIMIT_MONTHLY_MESSAGES: '100', LIMIT_TOTAL_BYTES: '1000000', LIMIT_ACCOUNTS: '5' });
eq(systemLimits(env).monthlyMessages, 100, '환경 값');
saveConfig(env, { LIMIT_MONTHLY_MESSAGES: 10, LIMIT_TOTAL_BYTES: '5000', LIMIT_ACCOUNTS: 2 });
const l = systemLimits(env); eq(l.monthlyMessages, 10, '설정 파일 우선'); eq(l.totalBytes, 5000, '크기 접미사 없는 값'); eq(l.accounts, 2, '계정 수');
saveConfig(env, { LIMIT_MONTHLY_MESSAGES: '' }); eq(systemLimits(env).monthlyMessages, 100, '비우면 환경 값으로');
env.STATS_NOW = '2026-10-15T03:00:00Z';
await sentRow(env, { at: '2026-10-10T03:00:00Z', recipients: ['a@x.test', 'b@x.test'], cc: ['c@x.test'], bcc: ['d@x.test'] }); await sentRow(env, { at: '2026-10-11T03:00:00Z', recipients: [] }); await inboundRow(env, { at: '2026-10-12T03:00:00Z' });
const c = await countMail(env, periodBounds('month', 'Asia/Seoul', new Date(env.STATS_NOW))); eq(c.sent, 5, '수신자 4 + 기록 없는 메일 1'); eq(c.sentMessages, 2, '메일 수'); eq(c.received, 1, '수신'); eq(c.total, 6, '합계');
const before = await totalUsage(env);
await env.DB.prepare("INSERT INTO large_files(id,message_id,mailbox,filename,size,state,created_at) VALUES('lf1',NULL,'admin@arihakdang.com','big.bin',4000,'sent',?)").bind(new Date().toISOString()).run();
eq(await totalUsage(env), before + 4000, '대용량 첨부 포함'); eq(await mailboxUsage(env, 'admin@arihakdang.com'), before + 4000, '메일함 사용량에도 포함');
});
await t('5) 100%: 저장 공간이 차면 새 수신 임시 거부(읽기·삭제 유지), 월간 통수가 차면 발신 차단(mail_monthly_limit)·수신 임시 거부, 다음 KST 월에 풀린다', async () => {
const { env } = await base({ LIMIT_MONTHLY_MESSAGES: '2', RESEND_FETCH: fakeResend({}) });
env.STATS_NOW = '2026-10-20T03:00:00Z';
await sentRow(env, { at: '2026-10-10T03:00:00Z' }); await sentRow(env, { at: '2026-10-11T03:00:00Z' });
eq(await checkInbound(env, 'admin@arihakdang.com'), 'monthly_limit', '수신 임시 거부');
let threw = null; try { await checkOutbound(env, { mailbox: 'admin@arihakdang.com', recipients: 1 }); } catch (e) { threw = e; } eq(threw?.message, 'monthly_limit_exceeded', '발신 차단'); eq(threw?.code, 'mail_monthly_limit', '공통 코드'); eq(threw?.resetAt, '2026-10-31T15:00:00.000Z', '해제 시각');
const st = await limitState(env); eq(st.warnLevel, 100, '구간 100'); eq(st.limitedReasons.join(','), 'monthly_limit', '사유');
// Haraka 수신 전달도 건너뜀(플러그인이 DENYSOFT)
const eml = buildEml({ from: 'x@example.test', to: ['admin@arihakdang.com'], subject: 'in', text: 'hi', messageId: '<in1@example.test>' });
eq((await ingestRaw(env, eml, { id: 'tx1', rcpt: ['admin@arihakdang.com'], from: 'x@example.test' })).skipped, 'monthly_limit', 'Haraka 수신 건너뜀');
// 기존 메일 읽기·삭제는 그대로(API 목록 조회 가능)
const cookie = (await api(env, 'login', '', 'POST', { login: 'arihakdang', password: 'admin-secret-12' })).headers.get('set-cookie').split(';')[0];
eq((await api(env, 'mail?folder=sent', cookie)).status, 200, '읽기 유지');
const d = await saveDraft(env, { to: 'a@example.test', subject: 's', html: '<p>x</p>' }); const r = await (await api(env, 'mail/' + d + '/send', cookie, 'POST', {})).json(); eq(r.error, 'monthly_limit_exceeded', '웹 발송 차단'); eq(r.code, 'mail_monthly_limit', '응답에 공통 코드'); eq(r.resetAt, '2026-10-31T15:00:00.000Z', '응답에 해제 시각');
env.STATS_NOW = '2026-11-01T03:00:00Z'; eq(await checkInbound(env, 'admin@arihakdang.com'), null, '다음 달엔 수신 가능'); eq((await limitState(env)).warnLevel, 0, '구간 초기화');
// 저장 공간 100%
const env2 = (await base({ LIMIT_TOTAL_BYTES: '1500' })).env; await inboundRow(env2, { at: '2026-10-01T00:00:00Z', size: 1500 });
eq(await checkInbound(env2, 'admin@arihakdang.com'), 'storage_full', '저장 공간 100% 수신 거부'); eq((await limitState(env2)).limitedReasons.join(','), 'storage_full', '사유');
const env3 = (await base({ LIMIT_TOTAL_BYTES: '2000' })).env; await inboundRow(env3, { at: '2026-10-01T00:00:00Z', size: 1900 }); eq((await limitState(env3)).warnLevel, 95, '95% 경고');
});
await t('6) 발송 속도(도메인·서버 초당 5건)·제출 제한(초당 10건 → 60초 제한·악용 표)·대기열 기록·재시작 복구', async () => {
let nowMs = 1_000_000; sq.setClock(() => nowMs); const slept = []; sq.setSleep(async (ms) => { slept.push(ms); nowMs += ms; }); sq.resetRates();
try {
for (let i = 0; i < 5; i++) await sq.pace('a.test'); eq(slept.length, 0, '처음 5건은 즉시');
await sq.pace('a.test'); ok(slept.length >= 1 && slept[0] >= 150 && slept[0] <= 200, '6번째는 ~200ms 대기: ' + slept[0]);
// 다른 도메인이라도 서버 버킷(초당 5)을 함께 씁니다
slept.length = 0; nowMs += 2000; for (let i = 0; i < 5; i++) await sq.pace('b.test'); await sq.pace('c.test'); ok(slept.length >= 1, '서버 전체 버킷 적용');
// 제출 제한
const { env } = await base(); let th = null;
for (let i = 0; i < 12; i++) { th = await sq.submit(env, ['account:k@a.test', 'ip:1.2.3.4']); if (th.throttled) break; }
ok(th.throttled, '초당 10건에서 제한'); eq(th.rate, 10, '제출량'); ok(th.until, '해제 시각');
const flags = await sq.abuseList(env); eq(flags.length, 1, '악용 확인 표 1건'); eq(flags[0].subject, 'account:k@a.test', '대상');
const snap = sq.rateSnapshot(); ok(snap.submissions.find((s) => s.key === 'account:k@a.test')?.throttledUntil, '운영자 화면: 제한 해제 시각'); eq(snap.deliveryRate, 5, '전달 상한');
nowMs += sq.THROTTLE_WINDOW_MS + 1; eq((await sq.submit(env, ['account:k@a.test'])).throttled, false, '창이 지나면 정상 발송 재개');
// 대기열: 발송 성공 → sent, 같은 키 재전송은 발송 모듈 호출 없음, 실패 → failed, 재시작 → interrupted
const calls = []; const env2 = await makeEnv({ RESEND_FETCH: fakeResend({ calls }) }); const { sendMail } = await import('../server/providers/index.mjs');
const r1 = await sendMail(env2, { from: 'admin@arihakdang.com', to: ['a@x.test'], subject: 's', text: 'b' }, 'key-1'); ok(r1.id, '발송');
const r2 = await sendMail(env2, { from: 'admin@arihakdang.com', to: ['a@x.test'], subject: 's', text: 'b' }, 'key-1'); eq(r2.duplicate, true, '같은 키는 중복 발송 안 함'); eq(calls.filter((c) => c.path === '/emails' && c.method === 'POST').length, 1, '발송 모듈 1회');
env2.RESEND_FETCH = fakeResend({ failStatus: 500 }); let err = null; try { await sendMail(env2, { from: 'admin@arihakdang.com', to: ['b@x.test'], subject: 's', text: 'b' }, 'key-2'); } catch (e) { err = e; } ok(err, '실패');
const rows = (await env2.DB.prepare('SELECT send_key, state FROM outbound_queue ORDER BY created_at').all()).results; eq(rows.map((r) => r.state).join(','), 'sent,failed', '대기열 상태');
await env2.DB.prepare("UPDATE outbound_queue SET state='sending' WHERE send_key='key-2'").run(); eq(await sq.recoverQueue(env2), 1, '재시작 복구: interrupted'); eq((await sq.queueList(env2))[0].state, 'interrupted', '목록 맨 앞');
ok(!JSON.stringify(await sq.queueList(env2)).includes('"text"'), '대기열에 본문 없음');
} finally { sq.setClock(null); sq.setSleep(null); sq.resetRates(); }
});
await t('7) 운영 상태가 active 가 아니면 수신 임시 거부(domain_not_active)·발신 차단(mail_domain_frozen)', async () => {
const { env } = await base(); const tenants = open(env); setupToken(env);
await setup(env, 'domains/arihakdang.com', 'PATCH', { operating: 'migrating_out' }, tenants);
eq(await checkInbound(env, 'admin@arihakdang.com'), 'domain_not_active', '수신 거부');
let threw = null; try { await checkOutbound(env, { mailbox: 'admin@arihakdang.com', recipients: 1 }); } catch (e) { threw = e; } eq(threw?.message, 'mail_domain_frozen', '발신 차단');
await setup(env, 'domains/arihakdang.com', 'PATCH', { operating: 'active' }, tenants); eq(await checkInbound(env, 'admin@arihakdang.com'), null, '복귀');
tenants.close();
});
await t('8) 포털 SSO 티켓: 서명·만료·재사용·도메인을 검사하고 메일함 세션 쿠키로 웹 메일에 들어간다', async () => {
const { env } = await base(); const secret = randomBytes(24).toString('base64url');
const ticket = (payload, sec = secret) => { const p = Buffer.from(JSON.stringify(payload)).toString('base64url'); return p + '.' + createHmac('sha256', sec).update(p).digest('base64url'); };
eq((await api(env, 'auth/sso?ticket=' + ticket({ mailbox: 'admin@arihakdang.com', exp: Date.now() + 60000, nonce: 'n0' }))).status, 401, '비밀 미설정이면 거부');
saveConfig(env, { ARIWEB_SSO_SECRET: secret }, { allowHidden: true });
const good = ticket({ mailbox: 'admin@arihakdang.com', exp: Date.now() + 60000, nonce: 'n1' });
const r = await api(env, 'auth/sso?ticket=' + encodeURIComponent(good)); eq(r.status, 302, '세션 발급·이동'); ok(/mg_session=/.test(r.headers.get('set-cookie') || ''), '세션 쿠키'); eq(r.headers.get('location'), '/', '웹 메일로');
const cookie = r.headers.get('set-cookie').split(';')[0]; eq((await (await api(env, 'session', cookie)).json()).user.mailbox, 'admin@arihakdang.com', '세션의 메일함');
eq((await (await api(env, 'auth/sso?ticket=' + encodeURIComponent(good))).json()).error, 'sso_replayed', '재사용 거부');
eq((await (await api(env, 'auth/sso?ticket=' + encodeURIComponent(ticket({ mailbox: 'admin@arihakdang.com', exp: Date.now() + 60000, nonce: 'n2' }, 'other')))).json()).error, 'sso_bad_signature', '서명 불일치');
eq((await (await api(env, 'auth/sso?ticket=' + encodeURIComponent(ticket({ mailbox: 'admin@arihakdang.com', exp: Date.now() - 1, nonce: 'n3' })))).json()).error, 'sso_expired', '만료');
eq((await (await api(env, 'auth/sso?ticket=' + encodeURIComponent(ticket({ mailbox: 'admin@other.test', exp: Date.now() + 60000, nonce: 'n4' })))).json()).error, 'sso_wrong_domain', '다른 도메인');
eq((await api(env, 'auth/sso?ticket=' + encodeURIComponent(ticket({ mailbox: 'nobody@arihakdang.com', exp: Date.now() + 60000, nonce: 'n5' })))).status, 404, '없는 메일함');
ok(!JSON.stringify(await (await api(env, 'admin/rates', cookie, 'GET', undefined, { 'X-Mail-Scope': 'admin' })).json()).includes(secret), '관리자 응답에 비밀 없음');
});
done();