Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
165 lines
21 KiB
JavaScript
165 lines
21 KiB
JavaScript
/**
|
|
* 1.0.9 시험: 메일 사용량 정보 제공 API(server/usage.mjs) — 키 생성·교체·삭제(설정 API), 인증(없음/틀림/맞음), IP 허용 목록(IPv4 CIDR·IPv6), 시도 제한,
|
|
* 보고서 내용(계정·저장 용량·메일 수·기간 통계·일별 추이·계정별 기간 합계·외부(웹메이커) 계정 수), 도메인 하나/전체, 쿼리(days·accounts·series), Resend 연결 오류 1회 재시도.
|
|
* 실행: node test/usage.test.mjs (npm test 와 deploy.sh 가 함께 돌립니다)
|
|
*/
|
|
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
import { scryptSync, randomBytes } from 'node:crypto';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { makeEnv, runner, eq, ok, request, params } from './_helpers.mjs';
|
|
import { handle } from '../server/api.mjs';
|
|
import { loadConfig, setupToken } from '../server/config.mjs';
|
|
import { openTenants } from '../server/domains.mjs';
|
|
import { setupApi } from '../server/setup.mjs';
|
|
import { usageApi, authorizeUsage, ipAllowed, parseAllowList, invalidAllowEntries, clientIp, domainUsage, USAGE_FAIL_LIMIT } from '../server/usage.mjs';
|
|
import { periodBounds } from '../server/limits.mjs';
|
|
import { createUser } from '../server/auth.mjs';
|
|
import { resend } from '../server/providers/resend.mjs';
|
|
import { clearSnapshotCache } from '../server/webmaker.mjs';
|
|
|
|
const { t, done } = runner();
|
|
const HERE = dirname(fileURLToPath(import.meta.url)), ROOT = join(HERE, '..');
|
|
const tmp = (p = 'arimail-usage-') => mkdtempSync(join(tmpdir(), p));
|
|
const api = (env, path, cookie, method, body, headers) => handle(env, request(path, cookie, method, body, headers), params(path));
|
|
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 };
|
|
}
|
|
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 });
|
|
/** /api/usage 호출 흉내(main.mjs 가 하는 것처럼 path 조각·ip·쿼리를 넘김). */
|
|
const usage = (tenants, path = '', { key, ip = '203.0.113.10', query = '' } = {}) => usageApi(tenants, new Request('https://x/api/usage' + path + (query ? '?' + query : ''), { headers: key ? { Authorization: 'Bearer ' + key } : {} }), { path: path.split('/').filter(Boolean), ip, query: new URLSearchParams(query) });
|
|
/** 메일 행 넣기(통계용): direction, 시각, 크기, 메일함. */
|
|
async function putMail(env, { direction, at, size = 1000, mailbox, state }) {
|
|
const id = randomBytes(8).toString('hex'), outbound = direction === 'outbound';
|
|
await env.DB.prepare(`INSERT INTO messages(id,direction,folder,sender,recipients_json,subject,text,source,state,received_at,created_at,updated_at,sent_at,raw_status,raw_size,mailbox) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
.bind(id, direction, outbound ? 'sent' : 'inbox', 'a@b.test', '["x@y.test"]', 's', 'body', outbound ? 'web' : 'sync', state || (outbound ? 'sent' : 'received'), at, at, at, outbound ? at : null, 'stored', size, mailbox).run();
|
|
return id;
|
|
}
|
|
// [Ariweb Suite 변경] 결정적 시각: 예전에는 UTC 12시 기준이라 UTC 15:00~24:00(= KST 다음 날 0~9시)에 실행하면 "오늘" 이 KST 어제가 되어 오늘 칸이 비었습니다.
|
|
// 이제 통계 시간대(Asia/Seoul)의 오늘 0시를 periodBounds 로 구하고 그 날 정오(KST)에서 n 일을 뺍니다 — 어느 시각에 실행해도 같은 칸에 들어갑니다.
|
|
const daysAgo = (n) => new Date(Date.parse(periodBounds('day', 'Asia/Seoul').start) + 12 * 3600 * 1000 - n * 86400000).toISOString();
|
|
|
|
await t('IP 허용 목록: 빈 목록은 모두 허용, IPv4 정확히·CIDR, IPv6 정확히·접두사, ::ffff: 로 감싼 IPv4, 잘못된 주소는 거부', () => {
|
|
eq(parseAllowList(' 1.2.3.4, 10.0.0.0/8 ,2001:db8::1 ').join('|'), '1.2.3.4|10.0.0.0/8|2001:db8::1', '파싱');
|
|
ok(ipAllowed('9.9.9.9', []), '빈 목록'); ok(ipAllowed('1.2.3.4', ['1.2.3.4']), '정확히'); ok(!ipAllowed('1.2.3.5', ['1.2.3.4']), '다른 주소');
|
|
ok(ipAllowed('10.20.30.40', ['10.0.0.0/8']) && !ipAllowed('11.0.0.1', ['10.0.0.0/8']) && ipAllowed('192.168.1.77', ['192.168.1.64/26']) && !ipAllowed('192.168.1.128', ['192.168.1.64/26']), 'CIDR');
|
|
ok(ipAllowed('::ffff:10.1.1.1', ['10.0.0.0/8']), 'IPv6 소켓의 IPv4'); ok(ipAllowed('2001:db8::1', ['2001:DB8::1']) && ipAllowed('2001:db8:0:0:0:0:0:2', ['2001:db8::/32']) && !ipAllowed('2001:db9::1', ['2001:db8::/32']), 'IPv6');
|
|
ok(!ipAllowed('not-an-ip', ['0.0.0.0/0']) && !ipAllowed('', ['1.2.3.4']), '잘못된 주소'); ok(ipAllowed('8.8.8.8', ['0.0.0.0/0']), '/0 은 모두');
|
|
ok(ipAllowed('1.2.3.4', ['garbage', '1.2.3.4']), '목록의 잘못된 항목은 건너뜀');
|
|
eq(invalidAllowEntries('1.2.3.4, 10.0.0.0/8, 2001:db8::/32').length, 0, '유효한 목록'); eq(invalidAllowEntries('1.2.3.4/33, webmaker.example.com, 10.0.0.0/8/x, 300.1.1.1').join('|'), '1.2.3.4/33|webmaker.example.com|10.0.0.0/8/x|300.1.1.1', '잘못된 항목'); eq(invalidAllowEntries('').length, 0, '빈 값');
|
|
// 접속 IP: 클라이언트가 넣을 수 있는 헤더(XFF 첫 값, CF-Connecting-IP)가 아니라 nginx 가 보증하는 값(X-Real-IP, XFF 마지막)
|
|
const H = (o) => new Headers(o);
|
|
eq(clientIp(H({ 'x-forwarded-for': '203.0.113.10, 198.51.100.7' }), '127.0.0.1'), '198.51.100.7', 'XFF 마지막(nginx 가 덧붙인 실제 주소)');
|
|
eq(clientIp(H({ 'x-real-ip': '198.51.100.8', 'x-forwarded-for': '203.0.113.10, 1.1.1.1' }), '127.0.0.1'), '198.51.100.8', 'X-Real-IP 우선');
|
|
eq(clientIp(H({ 'cf-connecting-ip': '203.0.113.10', 'x-forwarded-for': '9.9.9.9' }), '127.0.0.1'), '9.9.9.9', 'CF-Connecting-IP 는 기본적으로 무시');
|
|
eq(clientIp(H({}), '10.0.0.5'), '10.0.0.5', '헤더 없으면 소켓 주소');
|
|
});
|
|
|
|
await t('키 관리(설정 API): 키가 없으면 API 404, 만들면 한 번만 돌려주고 설정에는 가린 값, 다시 만들면 옛 키 무효, 삭제하면 다시 404, 추가 도메인에서는 primary_only', async () => {
|
|
const { env } = await base(); const tenants = open(env); await tenants.add({ domain: 'second.test', adminPassword: 'secondpw1' });
|
|
setupToken(env);
|
|
eq((await usage(tenants, '', { key: 'anything' })).status, 404, '키 없음 → API 꺼짐'); eq((await (await usage(tenants, '')).json()).error, 'usage_api_disabled', '오류 코드');
|
|
let r = await (await setup(env, 'usage-key', 'POST', { action: 'generate' }, tenants)).json(); ok(r.ok && r.key.startsWith('au_') && r.key.length > 40, '키 생성: ' + JSON.stringify(r).slice(0, 80)); eq(r.endpoint, 'https://mail.arihakdang.com/api/usage', '주소');
|
|
const key1 = r.key; eq(env.USAGE_API_KEY, key1, 'env 에 적용');
|
|
const cfg = await (await setup(env, 'config', 'GET', undefined, tenants)).json(); eq(cfg.present.USAGE_API_KEY, true, 'hidden 항목은 있음/없음만'); ok(!JSON.stringify(cfg).includes(key1), '설정 응답에 키 원문 없음'); eq(cfg.meta.usageApi.enabled, true, 'meta'); ok(cfg.meta.usageApi.keyMasked.includes('••••'), '가린 값'); ok(cfg.meta.usageApi.updatedAt, '생성 시각');
|
|
eq((await usage(tenants, '', { key: key1 })).status, 200, '새 키로 200');
|
|
r = await (await setup(env, 'usage-key', 'POST', { action: 'generate' }, tenants)).json(); ok(r.key !== key1, '교체'); eq((await usage(tenants, '', { key: key1 })).status, 401, '옛 키 무효'); eq((await usage(tenants, '', { key: r.key })).status, 200, '새 키');
|
|
eq((await setup(tenants.byDomain('second.test').env, 'usage-key', 'POST', { action: 'generate' }, tenants)).status, 409, '추가 도메인에서는 불가');
|
|
eq((await setup(env, 'usage-key', 'POST', { action: 'nope' }, tenants)).status, 400, '모르는 동작');
|
|
r = await (await setup(env, 'usage-key', 'POST', { action: 'revoke' }, tenants)).json(); eq(r.enabled, false, '삭제'); eq((await usage(tenants, '', { key: key1 })).status, 404, '다시 꺼짐'); eq(env.USAGE_API_KEY, '', 'env 비움');
|
|
tenants.close();
|
|
});
|
|
|
|
await t('인증: Bearer 또는 X-Usage-Key, 틀린 키는 401 이고 IP 마다 10회 뒤 429, 허용 IP 밖은 403, GET 만, 성공 호출은 감사 기록', async () => {
|
|
const { env } = await base(); const tenants = open(env); setupToken(env);
|
|
const { key } = await (await setup(env, 'usage-key', 'POST', { action: 'generate' }, tenants)).json();
|
|
eq((await usageApi(tenants, new Request('https://x/api/usage', { headers: { 'X-Usage-Key': key } }), { path: [], ip: '1.1.1.1', query: new URLSearchParams() })).status, 200, 'X-Usage-Key');
|
|
eq((await usage(tenants, '', {})).status, 401, '키 없음'); eq((await usage(tenants, '', { key: key + 'x' })).status, 401, '틀린 키');
|
|
for (let i = 0; i < USAGE_FAIL_LIMIT; i++) await usage(tenants, '', { key: 'wrong', ip: '5.5.5.5' });
|
|
eq((await usage(tenants, '', { key, ip: '5.5.5.5' })).status, 429, '실패 10회 뒤 맞는 키도 막힘(15분)'); eq((await usage(tenants, '', { key, ip: '5.5.5.6' })).status, 200, '다른 IP 는 정상');
|
|
// 허용 IP
|
|
env.USAGE_API_ALLOW = '203.0.113.0/24, 2001:db8::/32';
|
|
eq((await usage(tenants, '', { key, ip: '203.0.113.77' })).status, 200, '허용 범위'); eq((await (await usage(tenants, '', { key, ip: '198.51.100.1' })).json()).error, 'forbidden_ip', '범위 밖'); eq((await usage(tenants, '', { key, ip: '2001:db8:1::9' })).status, 200, 'IPv6 허용');
|
|
const a = authorizeUsage(env, new Request('https://x/', { headers: { Authorization: 'Bearer ' + key } }), '198.51.100.1'); eq(a.status, 403, 'authorizeUsage 직접');
|
|
env.USAGE_API_ALLOW = '';
|
|
eq((await usageApi(tenants, new Request('https://x/api/usage', { method: 'POST', headers: { Authorization: 'Bearer ' + key } }), { path: [], ip: '9.9.9.9', query: new URLSearchParams() })).status, 405, 'GET 만');
|
|
eq((await usage(tenants, '/%E0%A4%A', { key })).status, 404, '잘못된 퍼센트 인코딩은 404(500 아님)');
|
|
// 허용 IP 저장 검증(설정 API): 잘못된 항목이 있으면 422 로 거부해 모든 호출이 403 이 되는 사고를 막음
|
|
const bad = await setup(env, 'config', 'PUT', { values: { USAGE_API_ALLOW: '203.0.113.10, webmaker.example.com' } }, tenants); eq(bad.status, 422, '잘못된 허용 목록 거부'); eq((await bad.json()).detail, 'webmaker.example.com', '어느 항목인지');
|
|
eq((await setup(env, 'config', 'PUT', { values: { USAGE_API_ALLOW: '203.0.113.0/24' } }, tenants)).status, 200, '유효한 목록 저장'); eq(env.USAGE_API_ALLOW, '203.0.113.0/24', '적용'); env.USAGE_API_ALLOW = '';
|
|
const logs = (await env.DB.prepare("SELECT action,actor,target FROM audit_log WHERE action='usage_read'").all()).results; ok(logs.length >= 3 && logs.every((l) => l.actor === 'usage-api'), '감사 기록: ' + logs.length);
|
|
tenants.close();
|
|
});
|
|
|
|
await t('보고서: 계정 수(로컬+웹메이커)·저장 용량·메일 수·오늘/이번 주/이달 통수와 바이트·일별 추이·계정별 기간 합계·지운 계정의 메일함·제한 값, 쿼리 days/accounts/series', async () => {
|
|
clearSnapshotCache();
|
|
const { env } = await base({ LIMIT_ACCOUNTS: '10', LIMIT_TOTAL_BYTES: '1000000', LIMIT_MONTHLY_MESSAGES: '100' }); const tenants = open(env); setupToken(env);
|
|
const { key } = await (await setup(env, 'usage-key', 'POST', { action: 'generate' }, tenants)).json();
|
|
// 둘째 도메인: webmaker 모드(관리자 admin + 회원 guest = 외부 계정 2) + 로컬 계정 1
|
|
const wm = tmp('arimail-wm-'); mkdirSync(join(wm, 'sites')); mkdirSync(join(wm, 'modules'));
|
|
const h = (pw, salt) => scryptSync(pw, salt, 64).toString('hex'); const salt = randomBytes(16).toString('hex');
|
|
writeFileSync(join(wm, 'users.json'), JSON.stringify({ admin: { id: 'admin', name: '관리자', salt, passHash: h('pw', salt), role: 'admin' }, guest: { id: 'guest', name: '손님', salt, passHash: h('pw', salt), role: 'user' } }));
|
|
await tenants.add({ domain: 'second.test', auth: { mode: 'webmaker', webmakerDir: wm } });
|
|
const sec = tenants.byDomain('second.test').env; await createUser(sec, { login: 'extra', password: 'extra-pw-12' }, 'test');
|
|
// 기본 도메인 메일: 오늘 발신 2(3000B)·수신 1(500B), 어제 수신 2, 40일 전 발신 1(기간 밖), 초안 1, 실패 1, 지운 계정의 메일함에 수신 1
|
|
await createUser(env, { login: 'kim', password: 'kim-pw-1234' }, 'test');
|
|
const today = daysAgo(0), yesterday = daysAgo(1);
|
|
await putMail(env, { direction: 'outbound', at: today, size: 1000, mailbox: 'kim@arihakdang.com' }); await putMail(env, { direction: 'outbound', at: today, size: 2000, mailbox: 'kim@arihakdang.com' });
|
|
await putMail(env, { direction: 'inbound', at: today, size: 500, mailbox: 'kim@arihakdang.com' }); await putMail(env, { direction: 'inbound', at: yesterday, size: 100, mailbox: 'admin@arihakdang.com' }); await putMail(env, { direction: 'inbound', at: yesterday, size: 100, mailbox: 'admin@arihakdang.com' });
|
|
await putMail(env, { direction: 'outbound', at: daysAgo(40), size: 700, mailbox: 'kim@arihakdang.com' });
|
|
await putMail(env, { direction: 'outbound', at: today, size: 10, mailbox: 'kim@arihakdang.com', state: 'draft' }); await putMail(env, { direction: 'outbound', at: today, size: 10, mailbox: 'kim@arihakdang.com', state: 'failed' });
|
|
await putMail(env, { direction: 'inbound', at: today, size: 300, mailbox: 'gone@arihakdang.com' });
|
|
// 전체 요약
|
|
const all = await (await usage(tenants, '', { key })).json();
|
|
eq(all.totals.domains, 2, '도메인 수'); eq(all.primary, 'arihakdang.com', '기본'); eq(all.domains[0].domain, 'arihakdang.com', '기본 먼저'); ok(!all.domains[0].accountList && !all.domains[0].daily, '요약에는 계정 목록·일별 없음');
|
|
const p = all.domains[0];
|
|
eq(p.accounts.local, 1, '로컬 계정'); eq(p.accounts.limit, 10, '계정 제한'); eq(p.messages.total, 9, '메일 수'); eq(p.messages.inbound, 4, '수신'); eq(p.messages.outbound, 3, '발신 완료'); eq(p.messages.drafts, 1, '초안'); eq(p.messages.failed, 1, '실패');
|
|
eq(p.storage.bytes, 1000 + 2000 + 500 + 200 + 700 + 20 + 300, '저장 용량(모든 원본 크기)'); eq(p.storage.limit, 1000000, '제한'); eq(p.storage.percent, 0, '비율'); eq(p.storage.largeFiles.count, 0, '대용량 파일');
|
|
eq(p.traffic.day.sent, 2, '오늘 발신'); eq(p.traffic.day.sentBytes, 3000, '오늘 발신 바이트'); eq(p.traffic.day.received, 2, '오늘 수신(gone 포함)'); eq(p.traffic.month.total >= 4, true, '이달'); eq(p.traffic.monthlyLimit, 100, '월간 제한'); eq(p.limits.system.accounts, 10, '시스템 제한');
|
|
eq(p.authMode, 'www', '로그인 방식'); eq(p.inbound, 'resend', '수신 방식'); eq(p.enabled, true, '사용 중'); eq(p.hosts[0], 'mail.arihakdang.com', '호스트');
|
|
const s = all.domains[1]; eq(s.accounts.local, 1, '둘째 로컬'); eq(s.accounts.external, 2, '둘째 웹메이커 계정'); eq(s.accounts.total, 3, '합계'); eq(s.accounts.externalSource.kind, 'webmaker', '외부 출처'); eq(s.authMode, 'webmaker', '둘째 방식');
|
|
// 도메인 상세: 일별·계정 목록 기본 포함
|
|
const d = await (await usage(tenants, '/arihakdang.com', { key, query: 'days=7' })).json();
|
|
eq(d.domain, 'arihakdang.com', '상세'); eq(d.period.days, 7, '기간'); eq(d.daily.length, 7, '일별 7칸'); const last = d.daily[6]; eq(last.sent, 2, '오늘 칸 발신'); eq(last.received, 2, '오늘 칸 수신'); eq(last.sentBytes, 3000, '오늘 바이트'); eq(d.daily[5].received, 2, '어제 칸'); ok(/^\d{4}-\d{2}-\d{2}$/.test(last.date), '날짜 형식');
|
|
const kim = d.accountList.find((a) => a.login === 'kim'); ok(kim, '계정 행'); eq(kim.source, 'local', '출처'); eq(kim.messages, 6, 'kim 메일 수'); eq(kim.bytes, 1000 + 2000 + 500 + 700 + 20, 'kim 용량'); eq(kim.sent, 2, '기간 발신(40일 전 제외)'); eq(kim.received, 1, '기간 수신'); eq(kim.sentBytes, 3000, '기간 발신 바이트'); eq(kim.limit, 0, '크기 제한 없음');
|
|
const adminRow = d.accountList.find((a) => a.mailbox === 'admin@arihakdang.com'); ok(adminRow && adminRow.source === 'www', 'www 관리자 메일함 행'); eq(adminRow.received, 2, '관리자 수신');
|
|
const gone = d.accountList.find((a) => a.mailbox === 'gone@arihakdang.com'); ok(gone && gone.source === 'orphan' && gone.status === 'deleted' && gone.messages === 1, '지운 계정의 메일함');
|
|
const d2 = await (await usage(tenants, '/second.test', { key, query: 'days=3&series=0' })).json(); ok(!d2.daily, 'series=0'); eq(d2.accountList.length, 3, '둘째 계정 목록(로컬 1 + 웹메이커 2)'); eq(d2.accountList.filter((a) => a.source === 'webmaker').length, 2, '웹메이커 행');
|
|
eq((await usage(tenants, '/nope.test', { key })).status, 404, '모르는 도메인');
|
|
const d3 = await (await usage(tenants, '', { key, query: 'accounts=1&days=9999' })).json(); ok(d3.domains[0].accountList, 'accounts=1 로 요약에도 계정 목록'); eq(d3.domains[0].period.days, 30, '범위 밖 days 는 30');
|
|
// 비밀값이 섞이지 않는지
|
|
const text = JSON.stringify(all) + JSON.stringify(d); ok(!text.includes(key) && !text.includes('RESEND') && !text.includes('password'), '키·비밀값 없음');
|
|
const direct = await domainUsage(env, { days: 2, accounts: false, series: true }); eq(direct.daily.length, 2, 'domainUsage 직접');
|
|
// 서머타임 시간대: 기간 시작이 늘 그 날의 0시(통계 시간대)이고 첫 칸의 날짜와 같다(24시간씩 빼기만 하면 한 시간 밀림)
|
|
env.STATS_TIMEZONE = 'America/New_York';
|
|
for (const n of [7, 30, 120, 366]) { const r = await domainUsage(env, { days: n, accounts: false, series: true }); const startMs = Date.parse(r.period.start); eq(periodBounds('day', 'America/New_York', new Date(startMs + 43200000)).start, r.period.start, `DST 시간대 ${n}일: 시작은 0시`); eq(r.daily.length, n, `${n}칸`); eq(r.daily[0].date, new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York' }).format(new Date(startMs + 43200000)), '첫 칸 날짜'); }
|
|
env.STATS_TIMEZONE = 'Asia/Seoul';
|
|
// 도메인 하나가 깨져도 전체 요약은 나머지를 돌려준다
|
|
const brokenPrepare = sec.DB.prepare; sec.DB.prepare = () => { throw new Error('SQLITE_IOERR'); };
|
|
const partial = await (await usage(tenants, '', { key })).json(); eq(partial.ok, true, '요약 ok'); eq(partial.domains[1].error, 'server_error', '깨진 도메인 표시'); eq(partial.domains[1].domain, 'second.test', '도메인 이름'); eq(partial.totals.domains, 2, '합계는 정상 도메인만'); eq(partial.totals.messages, 9, '합계 메일 수');
|
|
eq((await usage(tenants, '/second.test', { key })).status, 500, '상세는 500'); sec.DB.prepare = brokenPrepare;
|
|
tenants.close();
|
|
});
|
|
|
|
await t('Resend 연결 오류(ECONNRESET 등)는 2초 뒤 한 번 더 시도하고, 두 번째도 실패하면 mail_provider_unavailable, 응답 오류(4xx)는 재시도하지 않는다', async () => {
|
|
const env = await makeEnv({ RESEND_RETRY_MS: 10 }); let calls = 0;
|
|
env.RESEND_FETCH = async () => { calls++; if (calls === 1) { const e = new TypeError('fetch failed'); e.cause = { code: 'ECONNRESET' }; throw e; } return Response.json({ data: [] }); };
|
|
eq(JSON.stringify(await resend(env, '/emails')), '{"data":[]}', '재시도 성공'); eq(calls, 2, '2회 호출');
|
|
calls = 0; env.RESEND_FETCH = async () => { calls++; const e = new TypeError('fetch failed'); e.cause = { code: 'UND_ERR_CONNECT_TIMEOUT' }; throw e; };
|
|
let err = null; try { await resend(env, '/emails'); } catch (e) { err = e; } eq(err?.message, 'mail_provider_unavailable', '두 번 실패'); eq(err?.providerMessage, 'UND_ERR_CONNECT_TIMEOUT', '사유'); eq(calls, 2, '정확히 2회');
|
|
calls = 0; env.RESEND_FETCH = async () => { calls++; return Response.json({ message: 'nope' }, { status: 403 }); };
|
|
err = null; try { await resend(env, '/emails'); } catch (e) { err = e; } eq(err?.message, 'mail_forbidden', '응답 오류'); eq(calls, 1, '응답 오류는 재시도 없음');
|
|
// 응답 대기 시간 초과(TimeoutError)와 멱등 키 없는 POST(웹훅 만들기 등)는 재시도하지 않는다 — 요청이 이미 처리됐을 수 있음
|
|
calls = 0; env.RESEND_FETCH = async () => { calls++; const e = new DOMException('The operation was aborted due to timeout', 'TimeoutError'); throw e; };
|
|
err = null; try { await resend(env, '/emails'); } catch (e) { err = e; } eq(err?.message, 'mail_provider_unavailable', '시간 초과'); eq(calls, 1, 'TimeoutError 는 재시도 없음');
|
|
calls = 0; env.RESEND_FETCH = async () => { calls++; if (calls === 1) { const e = new TypeError('fetch failed'); e.cause = { code: 'ECONNRESET' }; throw e; } return Response.json({ id: 'wh_1' }); };
|
|
err = null; try { await resend(env, '/webhooks', 'POST', { endpoint: 'x' }); } catch (e) { err = e; } eq(err?.message, 'mail_provider_unavailable', '멱등 키 없는 POST 는 재시도 없음'); eq(calls, 1, '1회');
|
|
calls = 0; eq((await resend(env, '/emails', 'POST', { to: 'a' }, 'idem-1')).id, 'wh_1', '멱등 키 있는 POST 는 재시도'); eq(calls, 2, '2회');
|
|
});
|
|
|
|
done();
|