108 lines
12 KiB
JavaScript
108 lines
12 KiB
JavaScript
/** 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();
|