Files

100 lines
16 KiB
JavaScript
Raw Permalink Normal View History

/** test/backup.test.mjs — 백업 옵션·정기 백업·보존·복원(§19·§20) 회귀 테스트 */
import { test, eq, ok, rejects, done } from '../lib/test.mjs';
import { makeDeps, seedGroup, appFor, call } from './helpers.mjs';
import { createFakeHal } from '../portal/lifecycle/testing/fakehal.mjs';
import { signHeaders } from '../lib/serviceauth.mjs';
import * as quota from '../lib/quota.mjs';
import * as svc from '../portal/backup/service.mjs';
import { newId } from '../lib/crypto.mjs';
const now = () => new Date().toISOString();
function seedPolicy(deps, over = {}) { deps.store.run('INSERT INTO product_versions(id, product_id, version, name, kind, billing_cycle, price, currency, tax_rate, limits_json, policy_json, effective_from, published, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)', 'pv_backup_1', 'backup', 1, '사이트 백업 옵션', 'option', 'monthly', 5000, 'KRW', 0.1, JSON.stringify({ storage_bytes: 10000, extra_storage_options: [{ label: '+10GB' }] }), JSON.stringify({ schedule: { daily: '03:00' }, retention: { daily: 2, weekly: 1, monthly: 1 }, first_backup_within_hours: 24, verify_method: '원격 사본 읽기 + 체크섬', restore_cost: 0, restore_completion: 'user_confirm', protect_point_days: 7, revert_hours: 72, failure_notify_after: 2, expiry_retention_days: 7, ...over }), '2026-01-01T00:00:00Z', 1, now()); }
function seedSite(deps, group, { mode = 'managed', lifecycle = 'live', server = 'srv-a', name = '사이트' } = {}) { const id = newId('s_'); deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, deploy_mode, server_id, primary_domain, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?)', id, group.id, name, lifecycle, mode, server, 'ex.example.test', now(), now()); return deps.store.get('SELECT * FROM sites WHERE id=?', id); }
async function setup() { const hal = createFakeHal(); const deps = await makeDeps({ hal }); const w = seedGroup(deps); seedPolicy(deps); quota.grantEntitlement(deps.store, { groupId: w.group.id, resource: 'backup', amount: 1 }); deps.vault.set('service:hal', 'ops-secret-example'); const app = await appFor(deps, ['backup'], w); return { deps, hal, w, app }; }
const opsCall = (app, deps, method, path, body) => call(app, method, path, body, Object.fromEntries(Object.entries(signHeaders('hal', 'ops-secret-example', method, path, body === undefined ? undefined : JSON.stringify(body))).map(([k, v]) => [k.toLowerCase(), v])));
await test('옵션 구매: 관리형 사이트만, 외부 배포 거부, 사이트당 하나, 권리 없으면 상품 안내', async () => {
const { deps, w, app } = await setup(); const managed = seedSite(deps, w.group); const ext = seedSite(deps, w.group, { mode: 'external' });
let r = await call(app, 'POST', `/api/backup/sites/${ext.id}/option`, { confirm: true }); eq(r.status, 409); ok(r.json.message.includes('외부 배포'));
r = await call(app, 'POST', `/api/backup/sites/${managed.id}/option`, {}); eq(r.json.error, 'confirmation_required');
r = await call(app, 'POST', `/api/backup/sites/${managed.id}/option`, { confirm: true }); eq(r.status, 200); eq(r.json.option.status, 'active'); ok(r.json.option.first_backup_due_at);
r = await call(app, 'POST', `/api/backup/sites/${managed.id}/option`, { confirm: true }); eq(r.status, 409, '사이트당 하나');
const second = seedSite(deps, w.group); r = await call(app, 'POST', `/api/backup/sites/${second.id}/option`, { confirm: true }); eq(r.json.error, 'quota_insufficient'); eq(r.json.detail.link, '/billing/products?product=backup');
const v = (await call(app, 'GET', `/api/backup/sites/${ext.id}`)).json; eq(v.eligibility.ok, false); eq(v.purchase.eligible, false);
});
await test('정책 미확정이면 구매 불가하고 미확정 항목을 보인다', async () => {
const hal = createFakeHal(); const deps = await makeDeps({ hal }); const w = seedGroup(deps); seedPolicy(deps, { verify_method: null }); quota.grantEntitlement(deps.store, { groupId: w.group.id, resource: 'backup', amount: 1 }); const app = await appFor(deps, ['backup'], w); const s = seedSite(deps, w.group);
const v = (await call(app, 'GET', `/api/backup/sites/${s.id}`)).json; eq(v.purchase.deferred, ['policy.verify_method']); eq(v.purchase.eligible, false);
});
await test('정기 백업: 저장 서버는 운영 서버와 다름, 검증·보관 기록, 등급 보존 정리와 최소 세트 보호, 실패 알림·재시도', async () => {
const { deps, hal, w, app } = await setup(); const site = seedSite(deps, w.group); await call(app, 'POST', `/api/backup/sites/${site.id}/option`, { confirm: true });
eq(svc.runDue(deps).length, 1); await deps.jobs.drain(); let j = deps.jobs.list({ kind: 'backup.scheduled' })[0]; eq(j.status, 'done', j.error);
const create = hal.calls.find((c) => c.kind === 'backup.create'); eq(create.payload.storeServerId, 'srv-b'); ok(create.payload.storeServerId !== site.server_id);
let b = deps.store.all('SELECT * FROM backups WHERE site_id=?', site.id); eq(b.length, 1); eq(b[0].verify_state, 'verified'); eq(b[0].tier, 'monthly');
const o = svc.optionOf(deps, site.id); ok(o.last_success_at); ok(Date.parse(o.next_run_at) > Date.now());
// 같은 슬롯 재실행은 멱등
deps.store.run('UPDATE backup_options SET next_run_at=? WHERE id=?', '2000-01-01T00:00:00Z', o.id); svc.runDue(deps); svc.runDue(deps); await deps.jobs.drain(); eq(deps.store.all('SELECT * FROM backups WHERE site_id=?', site.id).length, 2);
// 보존 정리: daily 2 — daily 4개 중 최신 2개만, 각 등급 최신 1개와 전체 최신 보호
for (let i = 0; i < 4; i++) deps.store.run('INSERT INTO backups(id, site_id, group_id, option_id, kind, tier, created_at, scope_json, size_bytes, location, status) VALUES(?,?,?,?,\'scheduled\',\'daily\',?,\'{}\',10,?,\'done\')', newId('bk_'), site.id, w.group.id, o.id, `2026-05-0${i + 1}T00:00:00Z`, 'loc' + i);
const del = svc.pruneList(deps, o); eq(del.length, 3, 'daily 5개 중 최신 2개만 유지'); ok(del.every((x) => x.tier === 'daily' && ['loc0', 'loc1', 'loc2'].includes(x.location)));
// 실패 → 재시도 큐 + 알림(2회째부터 고객 경고), 최종 실패 → critical + 운영자
hal.unreachable = true;
deps.store.run('UPDATE backup_options SET next_run_at=? WHERE id=?', '2001-01-01T00:00:00Z', o.id); svc.runDue(deps); await deps.jobs.drain(); j = deps.jobs.list({ kind: 'backup.scheduled' })[0]; eq(j.status, 'queued'); eq(j.attempt, 1);
for (let i = 0; i < 2; i++) { deps.store.run('UPDATE jobs SET next_run_at=? WHERE id=?', '2000-01-01T00:00:00Z', j.id); await deps.jobs.drain(); }
j = deps.jobs.get(j.id); eq(j.status, 'failed'); eq(svc.optionOf(deps, site.id).consecutive_failures, 1);
const n = deps.store.all('SELECT * FROM notifications WHERE incident_key=?', `backup:${o.id}:failed`); eq(n.length, 1); eq(n[0].severity, 'critical'); ok(n[0].body.includes('운영자')); ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE incident_key=?', `ops:backup:${o.id}:failed`));
// 운영자 재시도 → 성공 → 사건 해결
hal.unreachable = false; const r = await opsCall(app, deps, 'POST', `/internal/ops/backup/options/${o.id}/retry`, { operator: 'ops-1' }); eq(r.status, 200); await deps.jobs.drain(); eq(deps.jobs.get(j.id).status, 'done'); eq(deps.store.get('SELECT state FROM notifications WHERE incident_key=?', `backup:${o.id}:failed`).state, 'resolved');
// 서비스 키 없으면 401
eq((await call(app, 'POST', `/internal/ops/backup/options/${o.id}/retry`, {})).status, 401);
});
await test('저장 공간 부족: 최소 세트는 지우지 않고 다음 백업을 중지하며 안내', async () => {
const { deps, w, app } = await setup(); const site = seedSite(deps, w.group); await call(app, 'POST', `/api/backup/sites/${site.id}/option`, { confirm: true }); const o = svc.optionOf(deps, site.id);
deps.store.run('INSERT INTO backups(id, site_id, group_id, option_id, kind, tier, created_at, scope_json, size_bytes, location, status) VALUES(?,?,?,?,\'scheduled\',\'daily\',?,\'{}\',9999,\'l\',\'done\')', newId('bk_'), site.id, w.group.id, o.id, now());
svc.runDue(deps); await deps.jobs.drain(); eq(deps.jobs.list({ kind: 'backup.scheduled' })[0].error_code, 'backup_storage_full'); const o2 = svc.optionOf(deps, site.id); eq(o2.status, 'paused'); eq(o2.paused_reason, 'storage_full');
eq(deps.store.all('SELECT * FROM backups WHERE site_id=? AND deleted_at IS NULL', site.id).length, 1, '최소 세트 유지'); ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE incident_key=? AND mandatory=1', `backup:${o.id}:storage_full`));
eq((await call(app, 'POST', `/api/backup/sites/${site.id}/option/resume`)).json.error, 'backup_storage_full');
});
await test('사이트 중지 → 정기 백업 중지, 재개 방식 표시, 옵션 종료 → 7일 보관 후 삭제·권리 반환', async () => {
const { deps, w, app } = await setup(); const site = seedSite(deps, w.group); await call(app, 'POST', `/api/backup/sites/${site.id}/option`, { confirm: true });
deps.store.run('UPDATE sites SET lifecycle=\'paused\' WHERE id=?', site.id); eq(svc.runDue(deps).length, 0); eq(svc.optionOf(deps, site.id).paused_reason, 'site_paused');
deps.store.run('UPDATE sites SET lifecycle=\'live\' WHERE id=?', site.id); eq(svc.resumeForSite(deps, site.id).status, 'active', 'auto 재개');
svc.runDue(deps); await deps.jobs.drain(); eq(deps.store.all('SELECT * FROM backups WHERE site_id=? AND deleted_at IS NULL', site.id).length, 1);
const r = await call(app, 'POST', `/api/backup/sites/${site.id}/option/end`, { confirm: true }); eq(r.json.option.status, 'ended'); ok(r.json.option.delete_after); eq(quota.figures(deps.store, w.group.id, 'backup').unallocated, 1, '권리 반환');
ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE incident_key=? AND mandatory=1', `backup:${r.json.option.id}:ended`));
deps.store.run('UPDATE backup_options SET delete_after=? WHERE id=?', '2000-01-01T00:00:00Z', r.json.option.id); svc.runDue(deps); await deps.jobs.drain(); eq(deps.store.all('SELECT * FROM backups WHERE site_id=? AND deleted_at IS NULL', site.id).length, 0);
});
await test('복원: 요청 정보·중복 방지·운영자 최종 확인·승인 멱등·9단계·보호 시점·사용자 확인·되돌리기·실패 처리', async () => {
const { deps, hal, w, app } = await setup(); const site = seedSite(deps, w.group); await call(app, 'POST', `/api/backup/sites/${site.id}/option`, { confirm: true }); svc.runDue(deps); await deps.jobs.drain();
const b = deps.store.get('SELECT * FROM backups WHERE site_id=?', site.id);
const info = (await call(app, 'GET', `/api/backup/backups/${b.id}`)).json.restoreInfo; eq(info.canRequest, true); eq(info.completionMode, 'user_confirm'); eq(info.protectPointDays, 7); eq(info.revertHours, 72);
let r = await call(app, 'POST', `/api/backup/sites/${site.id}/restore`, { backupId: b.id, confirm: true }); eq(r.status, 200); eq(r.json.restore.status, 'awaiting_approval'); eq(r.json.finalConfirmation.site, site.name); const rid = r.json.restore.id;
r = await call(app, 'POST', `/api/backup/sites/${site.id}/restore`, { backupId: b.id, confirm: true }); eq(r.json.created, false, '중복 요청 없음');
// 잘못된 최종 확인 → 거부
r = await opsCall(app, deps, 'POST', `/internal/ops/backup/restores/${rid}/approve`, { operator: 'ops', confirm: { groupId: 'wrong', siteId: site.id, backupAt: b.created_at } }); eq(r.json.error, 'final_confirmation_mismatch');
const ok1 = await opsCall(app, deps, 'POST', `/internal/ops/backup/restores/${rid}/approve`, { operator: 'ops', confirm: { groupId: w.group.id, siteId: site.id, backupAt: b.created_at } }); eq(ok1.status, 200); eq(ok1.json.created, true);
const ok2 = await opsCall(app, deps, 'POST', `/internal/ops/backup/restores/${rid}/approve`, { operator: 'ops', confirm: { groupId: w.group.id, siteId: site.id, backupAt: b.created_at } }); eq(ok2.json.created, false, '승인 멱등'); eq(ok2.json.job.id, ok1.json.job.id);
await deps.jobs.drain(); let j = deps.jobs.get(ok1.json.job.id); eq(j.status, 'needs_user'); eq(j.steps.map((s) => s.id).slice(0, 9), ['received', 'approval', 'protect', 'isolated', 'restore', 'function', 'external', 'confirm', 'cleanup']); eq(j.actions.map((a) => a.id), ['confirm', 'revert']);
let rs = deps.store.get('SELECT * FROM restores WHERE id=?', rid); eq(rs.status, 'needs_user'); ok(rs.protect_point_id); ok(rs.revert_until);
// 사용자 확인 → 완료
r = await call(app, 'POST', `/api/backup/restores/${rid}/decision`, { action: 'confirm' }); eq(r.status, 200); await deps.jobs.drain(); eq(deps.jobs.get(j.id).status, 'done'); eq(deps.store.get('SELECT status FROM restores WHERE id=?', rid).status, 'done'); eq(deps.store.get('SELECT current_version FROM sites WHERE id=?', site.id).current_version, 'v-restored');
ok(deps.store.get('SELECT immutable FROM audit_log WHERE action=\'restore.complete\'').immutable === 1);
// 두 번째 복원: 되돌리기
r = await call(app, 'POST', `/api/backup/sites/${site.id}/restore`, { backupId: b.id, confirm: true }); const rid2 = r.json.restore.id; const a2 = await opsCall(app, deps, 'POST', `/internal/ops/backup/restores/${rid2}/approve`, { operator: 'ops', confirm: { groupId: w.group.id, siteId: site.id, backupAt: b.created_at } }); await deps.jobs.drain();
r = await call(app, 'POST', `/api/backup/restores/${rid2}/decision`, { action: 'revert' }); eq(r.json.error, 'confirmation_required'); r = await call(app, 'POST', `/api/backup/restores/${rid2}/decision`, { action: 'revert', confirm: true }); eq(r.json.reverted, true); eq(deps.store.get('SELECT status FROM restores WHERE id=?', rid2).status, 'reverted');
ok(hal.calls.some((c) => c.kind === 'backup.restore' && c.payload.location === deps.store.get('SELECT protect_point_id FROM restores WHERE id=?', rid2).protect_point_id), '보호 시점으로 되돌림');
// 격리 확인 실패 → 복원 시작 안 함, 운영 상태 유지, 고객·운영자 알림
hal.behave['backup.verify'] = () => ({ ok: false }); r = await call(app, 'POST', `/api/backup/sites/${site.id}/restore`, { backupId: b.id, confirm: true }); const rid3 = r.json.restore.id; await opsCall(app, deps, 'POST', `/internal/ops/backup/restores/${rid3}/approve`, { operator: 'ops', confirm: { groupId: w.group.id, siteId: site.id, backupAt: b.created_at } }); await deps.jobs.drain(); for (let i = 0; i < 2; i++) { const jj = deps.store.get('SELECT id FROM restores WHERE id=?', rid3); const job = deps.jobs.get(deps.store.get('SELECT job_id FROM restores WHERE id=?', rid3).job_id); if (job.status === 'queued') { deps.store.run('UPDATE jobs SET next_run_at=? WHERE id=?', '2000-01-01T00:00:00Z', job.id); await deps.jobs.drain(); } }
rs = deps.store.get('SELECT * FROM restores WHERE id=?', rid3); eq(rs.status, 'failed'); ok(!hal.calls.some((c) => c.kind === 'backup.restore' && c.idempotencyKey.includes(rs.job_id) && c.payload.location === b.location), '복원 호출 안 함'); ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE incident_key=? AND mandatory=1', `restore:${rid3}:failed`));
// 승인 전 취소 가능, 승인 뒤 불가
delete hal.behave['backup.verify']; r = await call(app, 'POST', `/api/backup/sites/${site.id}/restore`, { backupId: b.id, confirm: true }); const rid4 = r.json.restore.id; eq((await call(app, 'POST', `/api/backup/restores/${rid4}/cancel`)).status, 200); eq((await call(app, 'POST', `/api/backup/restores/${rid2}/cancel`)).status, 409);
});
await test('HAL 연결 불가: 작업은 일시 오류로 재시도 큐에 남고 실패로 숨기지 않는다', async () => {
const { deps, hal, w, app } = await setup(); const site = seedSite(deps, w.group); await call(app, 'POST', `/api/backup/sites/${site.id}/option`, { confirm: true }); hal.unreachable = true; svc.runDue(deps); await deps.jobs.drain(); const j = deps.jobs.list({ kind: 'backup.scheduled' })[0]; eq(j.status, 'queued'); ok(j.next_run_at > new Date().toISOString());
});
await test('화면: 백업 목록·사이트 탭·복원 화면이 열린다(권한 없음은 404/403)', async () => {
const { deps, w, app } = await setup(); const site = seedSite(deps, w.group); let r = await call(app, 'GET', '/backup'); eq(r.status, 200); ok(r.text.includes('bk-list')); r = await call(app, 'GET', `/sites/${site.id}/backup`); eq(r.status, 200); ok(r.text.includes('bk-site'));
const other = seedGroup(deps, { email: 'o2@example.test' }); const app2 = await appFor(deps, ['backup'], other); eq((await call(app2, 'GET', `/sites/${site.id}/backup`)).status, 404, '다른 그룹은 존재를 알 수 없음');
});
done();