/** * 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; } const daysAgo = (n, hour = 12) => { const d = new Date(); d.setUTCDate(d.getUTCDate() - n); d.setUTCHours(hour, 0, 0, 0); return d.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();