/** test/lib.test.mjs — 공통 라이브러리 회귀 테스트(db·http·session·jobs·quota·kst·redact·vault). */ import { test, eq, ok, rejects, tmpDir, done } from '../lib/test.mjs'; import { openDb } from '../lib/db.mjs'; import { createApp, invoke, HttpError } from '../lib/http.mjs'; import * as session from '../lib/session.mjs'; import * as jobs from '../lib/jobs.mjs'; import * as quota from '../lib/quota.mjs'; import * as kst from '../lib/kst.mjs'; import { redact, looksSecret } from '../lib/redact.mjs'; import { openVault } from '../lib/vault.mjs'; import { notify, listNotifications, resolveIncident, MIGRATIONS as NOTIFY_M } from '../lib/notify.mjs'; import { audit, listAudit, MIGRATIONS as AUDIT_M } from '../lib/audit.mjs'; import { hashPassword, verifyPassword } from '../lib/crypto.mjs'; import { join } from 'node:path'; const ALL = [...session.MIGRATIONS, ...jobs.MIGRATIONS, ...quota.MIGRATIONS, ...NOTIFY_M, ...AUDIT_M]; const mem = () => openDb(':memory:', ALL); await test('db: 마이그레이션은 한 번만, tx 는 예외 시 롤백·중첩 합류', () => { const s = mem(); eq(s.all('SELECT id FROM _migrations').length, ALL.length); s.exec('CREATE TABLE t(x)'); try { s.tx(() => { s.run('INSERT INTO t VALUES(1)'); s.tx(() => s.run('INSERT INTO t VALUES(2)')); throw new Error('boom'); }); } catch (_) {} eq(s.get('SELECT COUNT(*) AS n FROM t').n, 0, '롤백'); }); await test('crypto: scrypt 비밀번호 검증', () => { const { salt, hash } = hashPassword('pw-example-1'); ok(verifyPassword('pw-example-1', salt, hash)); ok(!verifyPassword('wrong', salt, hash)); }); await test('http: 라우트 매개변수·JSON 본문·오류 형식·정적 금지 경로', async () => { const app = createApp({ log: () => {} }); app.get('/api/x/:id', (c) => ({ id: c.params.id, q: c.query.a })); app.post('/api/y', async (c) => { const b = await c.body(); if (!b.name) throw c.error(400, 'name_required', '이름이 필요합니다'); return { name: b.name }; }); let r = await invoke(app, 'GET', '/api/x/42?a=1'); eq(r.status, 200); eq(r.json, { id: '42', q: '1' }); eq(r.headers['Cache-Control'], 'no-store'); r = await invoke(app, 'POST', '/api/y', { body: {} }); eq(r.status, 400); eq(r.json.error, 'name_required'); r = await invoke(app, 'POST', '/api/y', { body: 'not json' }); eq(r.json.error, 'bad_json'); r = await invoke(app, 'GET', '/nope'); eq(r.status, 404); app.use((c) => { if (c.path === '/blocked') throw new HttpError(403, 'forbidden', '권한 없음'); }); r = await invoke(app, 'GET', '/blocked'); eq(r.status, 403); // X-Real-IP 는 소켓이 루프백일 때만 app.get('/ip', (c) => ({ ip: c.ip })); eq((await invoke(app, 'GET', '/ip', { headers: { 'x-real-ip': '1.2.3.4' } })).json.ip, '1.2.3.4'); eq((await invoke(app, 'GET', '/ip', { headers: { 'x-real-ip': '1.2.3.4' }, ip: '9.9.9.9' })).json.ip, '9.9.9.9'); }); await test('session: 생성·해석·종류 분리·개별/전체 종료·CSRF', () => { const s = mem(); const a = session.createSession(s, { userId: 'u1', kind: 'customer', ua: 'Mozilla Chrome/1' }); const b = session.createSession(s, { userId: 'u1', kind: 'customer' }); const o = session.createSession(s, { userId: 'u1', kind: 'operator' }); ok(session.resolveSession(s, a.token, 'customer')); ok(!session.resolveSession(s, a.token, 'operator'), '종류가 다르면 거부'); ok(!session.resolveSession(s, 'bogus', 'customer')); eq(session.listSessions(s, 'u1', 'customer').length, 2); ok(session.csrfOk(session.resolveSession(s, a.token, 'customer'), a.csrf)); ok(!session.csrfOk(session.resolveSession(s, a.token, 'customer'), b.csrf)); eq(session.revokeOtherSessions(s, 'u1', a.id, 'customer'), 1); ok(!session.resolveSession(s, b.token, 'customer')); ok(session.resolveSession(s, o.token, 'operator'), '다른 종류는 유지'); eq(session.revokeSession(s, a.id, 'u1'), 1); ok(!session.resolveSession(s, a.token, 'customer')); }); await test('jobs: 멱등 enqueue·단계 재개·재시도 백오프·외부 대기·사용자 확인·취소·되돌리기·부모 요약', async () => { const s = mem(); const eng = jobs.createJobEngine(s, { log: () => {} }); let runs = 0; let sideEffects = 0; eng.register('demo', { title: '데모', steps: [{ id: 'a', title: 'A' }, { id: 'b', title: 'B' }], maxAttempts: 3, async run(job, ctx) { runs++; await ctx.step('a', () => { sideEffects++; return { a: 1 }; }); if (job.subject.failOnce && job.attempt === 1) { const e = new Error('temp'); e.retryable = true; throw e; } if (job.subject.wait && !job.subject.resumed) ctx.waitExternal('DNS 반영 대기'); if (job.subject.ask) ctx.needsUser('계속할까요?', [{ id: 'go', label: '계속' }]); await ctx.step('b', () => ({ b: 2 })); return { ok: true }; }, async revert(job, ctx) { await ctx.step('undo', () => ({ undone: true })); } }); const { job, created } = eng.enqueue({ kind: 'demo', idempotencyKey: 'k1', groupId: 'g' }); ok(created); eq(eng.enqueue({ kind: 'demo', idempotencyKey: 'k1' }).created, false, '멱등'); await eng.drain(); eq(eng.get(job.id).status, 'done'); eq(eng.get(job.id).steps.map((x) => x.state), ['done', 'done']); // 일시 오류 → 재시도 큐(next_run_at 미래) → 단계 a 는 다시 실행되지 않음 const f = eng.enqueue({ kind: 'demo', subject: { failOnce: true } }).job; await eng.drain(); let j = eng.get(f.id); eq(j.status, 'queued'); eq(j.attempt, 1); ok(Date.parse(j.next_run_at) > Date.now()); const before = sideEffects; s.run('UPDATE jobs SET next_run_at=? WHERE id=?', new Date(0).toISOString(), f.id); await eng.drain(); j = eng.get(f.id); eq(j.status, 'done'); eq(sideEffects, before, '끝난 단계는 건너뜀'); // 외부 대기 → resume const w = eng.enqueue({ kind: 'demo', subject: { wait: true } }).job; await eng.drain(); j = eng.get(w.id); eq(j.status, 'awaiting_external'); eq(j.wait_reason, 'DNS 반영 대기'); eq(jobs.describe(j).currentStep.state, 'waiting'); s.run('UPDATE jobs SET subject_json=? WHERE id=?', JSON.stringify({ wait: true, resumed: true }), w.id); eng.resume(w.id, 'u'); await eng.drain(); eq(eng.get(w.id).status, 'done'); // 사용자 확인 const u = eng.enqueue({ kind: 'demo', subject: { ask: true } }).job; await eng.drain(); j = eng.get(u.id); eq(j.status, 'needs_user'); eq(j.actions[0].id, 'go'); ok(jobs.describe(j).canResume); // 취소(대기 중)·되돌리기(완료 뒤) const c = eng.enqueue({ kind: 'demo', runAt: new Date(Date.now() + 3600000).toISOString() }).job; eng.cancel(c.id); eq(eng.get(c.id).status, 'cancelled'); await eng.revert(job.id); j = eng.get(job.id); eq(j.status, 'reverted'); ok(j.steps.find((x) => x.id === 'undo')); // 재시작 복구: running 으로 남은 작업을 큐로 s.run('UPDATE jobs SET status=\'running\' WHERE id=?', u.id); eq(eng.recover(), 1); eq(eng.get(u.id).status, 'queued'); // 부모/자식 요약 — 일부 실패는 partial const p = eng.enqueue({ kind: 'demo', initialStatus: 'awaiting_external' }).job; eng.enqueue({ kind: 'demo', parentId: p.id }); const bad = eng.enqueue({ kind: 'demo', parentId: p.id, subject: { ask: true } }).job; await eng.drain(); const sum = eng.childrenSummary(p.id); eq(sum.total, 2); eq(sum.overall, 'partial'); ok(sum.children.find((x) => x.id === bad.id).status === 'needs_user'); }); await test('quota: 권리·예약·확정·반환·동시 요청 중 하나만 성공·사용량 미만 축소 거부·경고 구간', () => { const s = mem(); quota.grantEntitlement(s, { groupId: 'g', resource: 'sites', amount: 2 }); quota.grantEntitlement(s, { groupId: 'g', resource: 'web_disk', amount: 1000 }); eq(quota.figures(s, 'g', 'sites').unallocated, 2); const r1 = quota.reserve(s, { groupId: 'g', resource: 'sites', amount: 1, subjectType: 'site', subjectId: 's1' }); eq(quota.figures(s, 'g', 'sites').reserved, 1); eq(quota.reserve(s, { groupId: 'g', resource: 'sites', amount: 1, subjectType: 'site', subjectId: 's1' }).id, r1.id, '같은 대상 중복 예약 없음'); quota.commit(s, r1.id); const f = quota.figures(s, 'g', 'sites'); eq([f.allocated, f.reserved, f.unallocated], [1, 0, 1]); // 마지막 한 단위: 두 작업이 요청 → 하나만 성공 quota.reserve(s, { groupId: 'g', resource: 'sites', amount: 1, subjectType: 'site', subjectId: 's2' }); const e = rejectsSync(() => quota.reserve(s, { groupId: 'g', resource: 'sites', amount: 1, subjectType: 'site', subjectId: 's3' })); eq(e.code, 'quota_insufficient'); eq(e.detail.shortage, 1); // 반환 뒤 다시 가능 const r2 = s.get('SELECT id FROM quota_reservations WHERE subject_id=\'s2\''); quota.release(s, r2.id); ok(quota.reserve(s, { groupId: 'g', resource: 'sites', amount: 1, subjectType: 'site', subjectId: 's3' })); // 배정 변경: 사용량 미만 거부, 미배정 초과 거부 quota.setAllocation(s, { groupId: 'g', resource: 'web_disk', subjectType: 'site', subjectId: 's1', amount: 600 }); quota.recordUsage(s, { groupId: 'g', resource: 'web_disk', subjectType: 'site', subjectId: 's1', used: 500 }); eq(rejectsSync(() => quota.setAllocation(s, { groupId: 'g', resource: 'web_disk', subjectType: 'site', subjectId: 's1', amount: 400, usedNow: 500 })).code, 'allocation_below_usage'); eq(rejectsSync(() => quota.setAllocation(s, { groupId: 'g', resource: 'web_disk', subjectType: 'site', subjectId: 's2', amount: 500 })).code, 'quota_insufficient'); eq(quota.setAllocation(s, { groupId: 'g', resource: 'web_disk', subjectType: 'site', subjectId: 's2', amount: 400 }).after, 400); eq(quota.summary(s, 'g').find((x) => x.resource === 'web_disk').used, 500); eq([quota.warnLevel(69, 100), quota.warnLevel(70, 100), quota.warnLevel(85, 100), quota.warnLevel(95, 100), quota.warnLevel(100, 100), quota.warnLevel(5, 0)], [0, 70, 80, 95, 100, 0]); // 만료 예약 자동 반환 s.run('UPDATE quota_reservations SET expires_at=? WHERE subject_id=\'s3\'', new Date(0).toISOString()); eq(quota.expireReservations(s).length, 1); }); function rejectsSync(fn) { try { fn(); } catch (e) { return e; } throw new Error('예외가 나야 합니다'); } await test('kst: 월 경계(UTC 저장값 기준)', () => { eq(kst.monthStart('2026-10-31T15:30:00Z'), '2026-10-31T15:00:00.000Z', '11-01 00:30 KST 는 11월'); eq(kst.monthStart('2026-10-31T14:30:00Z'), '2026-09-30T15:00:00.000Z', '10-31 23:30 KST 는 10월'); eq(kst.nextMonthStart('2026-12-31T16:00:00Z'), '2027-01-31T15:00:00.000Z'); eq(kst.kstDate('2026-01-31T15:00:00Z'), '2026-02-01'); eq(kst.kstMonth('2026-01-31T14:59:59Z'), '2026-01'); }); await test('redact: 비밀 키·값 가리기, 식별자 키는 유지', () => { const r = redact({ password: 'x', apiKey: 'y', token: 't', site_id: 'abc', idempotency_key: 'k', nested: { secret: 's', note: 'hello' }, jwt: 'eyJabcdefghijk.abcdefghijklmn.abcdefghijklmnop', ok: 'short' }); eq([r.password, r.apiKey, r.token, r.site_id, r.idempotency_key, r.nested.secret, r.nested.note, r.jwt, r.ok], ['[redacted]', '[redacted]', '[redacted]', 'abc', 'k', '[redacted]', 'hello', '[redacted]', 'short']); ok(looksSecret('re_abcdefghijklmnopqrstuvwxyz')); ok(!looksSecret('hello world')); }); await test('vault: 암호화 저장·읽기·목록에는 값 없음', () => { const v = openVault(join(tmpDir(), 'data')); v.set('arimail-usage', 'au_examplevalue1234567890'); eq(v.get('arimail-usage'), 'au_examplevalue1234567890'); ok(!JSON.stringify(v.list()).includes('examplevalue')); ok(v.remove('arimail-usage')); eq(v.get('arimail-usage'), null); }); await test('notify/audit: 같은 사건 묶기·해결·감사 기록 불변 표시·비밀값 제거', () => { const s = mem(); const a = notify(s, { groupId: 'g', category: 'usage', incidentKey: 'g:web_disk:90', title: '90%', severity: 'warning' }); const b = notify(s, { groupId: 'g', category: 'usage', incidentKey: 'g:web_disk:90', title: '90% 다시', severity: 'warning' }); eq(a.id, b.id); ok(b.merged); const l = listNotifications(s, { groupId: 'g' }); eq(l.length, 1); eq(l[0].count, 2); eq(l[0].title, '90% 다시'); eq(resolveIncident(s, 'g:web_disk:90'), 1); eq(listNotifications(s, { groupId: 'g' })[0].state, 'resolved'); audit(s, { actorType: 'user', actorId: 'u', groupId: 'g', action: 'deploy.start', detail: { token: 'abc', site: 's1' } }); const al = listAudit(s, { groupId: 'g' }); eq(al[0].immutable, 1); eq(al[0].detail.token, '[redacted]'); eq(al[0].detail.site, 's1'); }); done();