Files
AriWeb-Suite/test/lifecycle.test.mjs
T

81 lines
15 KiB
JavaScript

/** test/lifecycle.test.mjs — 중지·해지·보존·삭제 생명주기(§23)와 운영 사이트 수 반환(§14) 회귀 테스트 */
import { test, eq, ok, 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/lifecycle/service.mjs';
import * as backup from '../portal/backup/service.mjs';
import { newId } from '../lib/crypto.mjs';
const now = () => new Date().toISOString(); const DAY = 86400000;
function seedSite(deps, group, { lifecycle = 'live', mail = true, name = '사이트' } = {}) { const id = newId('s_'); deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, deploy_mode, server_id, primary_domain, site_count_state, created_at, updated_at) VALUES(?,?,?,?,\'managed\',\'srv-a\',?,\'in_use\',?,?)', id, group.id, name, lifecycle, name + '.example.test', now(), now()); deps.store.run('INSERT INTO domains(id, group_id, name, purpose, site_id, is_primary, created_at, updated_at) VALUES(?,?,?,\'web\',?,1,?,?)', newId('d_'), group.id, name + '.example.test', id, now(), now()); quota.setAllocation(deps.store, { groupId: group.id, resource: 'sites', subjectType: 'site', subjectId: id, amount: 1 }); if (mail) { deps.store.run('INSERT INTO mail_services(id, group_id, kind, site_id, name, domain, status, created_at, updated_at) VALUES(?,?,\'site_bound\',?,?,?,\'active\',?,?)', 'ms_' + id, group.id, id, name + ' 메일', name + '.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); deps.vault.set('service:hal', 'ops-secret-example'); quota.grantEntitlement(deps.store, { groupId: w.group.id, resource: 'sites', amount: 2 }); const app = await appFor(deps, ['backup', 'transfer', 'relocation', 'lifecycle'], w); return { deps, hal, w, app }; }
const opsCall = (app, 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])));
const site = (deps, id) => deps.store.get('SELECT * FROM sites WHERE id=?', id);
await test('§23.3 한 화면 보기와 영향 안내(중지·해지), 기본 보존 기간', async () => {
const { deps, w, app } = await setup(); const s = seedSite(deps, w.group); const d = (await call(app, 'GET', `/api/lifecycle/sites/${s.id}`)).json;
eq(d.view.site.label, '운영 중'); eq(d.view.siteCountConsumed, true); eq(d.view.publicAccess, '제공 중'); eq(d.view.retentionPolicy.site_days, 30); eq(d.view.retentionPolicy.backup_days, 7); ok(d.pauseImpact.usageAndCost.includes('계속')); ok(d.cancelImpact.pointOfNoReturn); eq(d.cancelImpact.mailOptions.keep.available, false, '독립 메일 권리 없음'); eq(d.siteCount.steps.length, 8); eq(d.siteCount.steps[0].ok, false, '공개 중');
eq((await call(app, 'GET', `/sites/${s.id}/lifecycle`)).status, 200);
});
await test('일시 중지: 공개 중지 작업·백업 중지·사이트 수 유지 → 재개', async () => {
const { deps, hal, w, app } = await setup(); const s = seedSite(deps, w.group); deps.store.run('INSERT INTO backup_options(id, site_id, group_id, status, schedule_json, retention_json, created_at, updated_at) VALUES(\'bo1\',?,?,\'active\',\'{}\',\'{}\',?,?)', s.id, w.group.id, now(), now());
let r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/pause`, {}); eq(r.json.error, 'confirmation_required');
r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/pause`, { confirm: true }); eq(r.status, 200); eq(r.json.site.lifecycle, 'paused'); await deps.jobs.drain(); ok(hal.calls.some((c) => c.kind === 'domain.vhost' && c.payload.maintenance === true)); eq(deps.jobs.get(r.json.job.id).status, 'done');
eq(backup.optionOf(deps, s.id).status, 'paused'); eq(quota.figures(deps.store, w.group.id, 'sites').allocated, 1, '사이트 수 계속 소비'); const v = svc.lifecycleView(deps, site(deps, s.id)); eq(v.resumeAvailable, true); eq(v.publicAccess, '중지됨');
eq((await call(app, 'POST', `/api/lifecycle/sites/${s.id}/pause`, { confirm: true })).status, 409);
r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/resume`); eq(r.json.site.lifecycle, 'live'); eq(backup.optionOf(deps, s.id).status, 'active'); eq(deps.store.all('SELECT * FROM lifecycle_events WHERE subject_id=?', s.id).length, 2);
});
await test('해지: 선택·확인 필수, 메일 유지는 권리 필요, 예약 → 되돌리기 → 즉시 해지 → 읽기 전용 보존 → 사이트 수 자동 반환 → 삭제 대기 → 삭제', async () => {
const { deps, hal, w, app } = await setup(); const s = seedSite(deps, w.group);
let r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/cancel`, { confirm: true }); eq(r.json.error, 'acknowledge_required');
r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/cancel`, { confirm: true, acknowledge: true }); eq(r.json.error, 'mail_choice_required');
r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/cancel`, { confirm: true, acknowledge: true, mailChoice: 'keep' }); eq(r.json.error, 'quota_insufficient'); eq(r.json.detail.resource, 'mail_services');
const later = new Date(Date.now() + DAY).toISOString(); r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/cancel`, { confirm: true, acknowledge: true, mailChoice: 'end', at: later }); eq(r.status, 200); eq(r.json.site.lifecycle, 'cancel_scheduled'); eq(r.json.executed, false); eq(r.json.view.resumeAvailable, true); ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE incident_key=? AND mandatory=1', `lifecycle:${s.id}:cancel`));
r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/undo-cancel`); eq(r.json.site.lifecycle, 'live'); eq(r.json.site.cancel_at, null);
r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/cancel`, { confirm: true, acknowledge: true, mailChoice: 'end' }); eq(r.json.executed, true); let st = site(deps, s.id); eq(st.lifecycle, 'readonly_retention'); ok(st.retention_until); ok(st.delete_after); eq(deps.store.get('SELECT routed FROM domains WHERE site_id=?', s.id).routed, 0);
eq(deps.store.get('SELECT lifecycle, status FROM mail_services WHERE site_id=?', s.id).lifecycle, 'readonly_retention', '대표 메일 종료(로컬 fallback)');
eq(st.site_count_state, 'none', '조건 충족 → 자동 반환'); eq(quota.figures(deps.store, w.group.id, 'sites').allocated, 0); ok(deps.store.get('SELECT 1 AS x FROM audit_log WHERE action=\'site_count.return\' AND immutable=1'));
eq((await call(app, 'POST', `/api/lifecycle/sites/${s.id}/undo-cancel`)).status, 409, '되돌릴 수 없는 시점 뒤'); eq((await call(app, 'POST', `/api/lifecycle/sites/${s.id}/resume`)).status, 409);
const v = svc.lifecycleView(deps, site(deps, s.id)); eq(v.adminAccess, '읽기 전용(내보내기 가능)'); eq(v.exportAvailable, true); eq(v.resumeAvailable, false); eq((await call(app, 'POST', `/api/lifecycle/sites/${s.id}/export`)).json.requested, true);
// 시간 진행: 사전 알림 → 삭제 대기 → 삭제
deps.store.run('UPDATE sites SET next_notice_at=? WHERE id=?', '2000-01-01T00:00:00Z', s.id); await svc.runDue(deps); ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE incident_key=? AND mandatory=1', `lifecycle:${s.id}:notice`));
deps.store.run('UPDATE sites SET retention_until=? WHERE id=?', '2000-01-01T00:00:00Z', s.id); await svc.runDue(deps); eq(site(deps, s.id).lifecycle, 'pending_delete'); ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE incident_key=? AND severity=\'critical\'', `lifecycle:${s.id}:delete`));
// 삭제 보류 → 삭제 작업이 멈춤 → 해제 → 삭제
await opsCall(app, 'POST', `/internal/ops/lifecycle/sites/${s.id}/hold`, { operator: 'ops', reason: '법적 보존' }); eq(site(deps, s.id).lifecycle, 'delete_hold'); eq(svc.lifecycleView(deps, site(deps, s.id)).deleteHold.contact, '운영자 지원 창구');
deps.store.run('UPDATE sites SET delete_after=? WHERE id=?', '2000-01-01T00:00:00Z', s.id); await svc.runDue(deps); await deps.jobs.drain(); eq(site(deps, s.id).lifecycle, 'delete_hold', '보류 중에는 삭제 안 됨');
await opsCall(app, 'POST', `/internal/ops/lifecycle/sites/${s.id}/release-hold`, { operator: 'ops' }); eq(site(deps, s.id).lifecycle, 'pending_delete'); await svc.runDue(deps); await deps.jobs.drain(); st = site(deps, s.id); eq(st.lifecycle, 'deleted'); ok(st.deleted_at); ok(hal.calls.some((c) => c.kind === 'site.remove' && c.payload.keepData === false));
const vd = svc.lifecycleView(deps, st); eq(vd.exportAvailable, false); ok(vd.pointOfNoReturn.includes('복구할 수 없습니다')); eq((await call(app, 'POST', `/api/lifecycle/sites/${s.id}/export`)).status, 409);
});
await test('§14 반환 체크리스트: 공개 중·도메인 연결·진행 작업·메일 미선택이면 거부, 메일 유지 권리 부족 거부, 종료 선택 후 반환 성공', async () => {
const { deps, w, app } = await setup(); const s = seedSite(deps, w.group);
let r = await call(app, 'GET', `/api/lifecycle/sites/${s.id}/site-count-return`); const ck = r.json.checklist; eq(ck.ok, false); ok(ck.blocking.includes('public_stopped')); ok(ck.blocking.includes('domains_unrouted')); ok(ck.blocking.includes('mail_choice'));
r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/site-count-return`, { mailChoice: 'end', acknowledge: true }); eq(r.json.error, 'confirmation_required');
r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/site-count-return`, { mailChoice: 'end', acknowledge: true, confirm: true }); eq(r.json.error, 'site_public');
await call(app, 'POST', `/api/lifecycle/sites/${s.id}/pause`, { confirm: true }); await deps.jobs.drain();
deps.store.run('INSERT INTO restores(id, site_id, group_id, backup_id, status, requested_by, created_at, updated_at) VALUES(\'rs1\',?,?,\'b\',\'running\',\'u\',?,?)', s.id, w.group.id, now(), now());
r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/site-count-return`, { mailChoice: 'end', acknowledge: true, confirm: true }); eq(r.json.error, 'site_count_return_blocked'); ok(r.json.detail.checklist.blocking.includes('no_jobs'));
deps.store.run('UPDATE restores SET status=\'done\' WHERE id=\'rs1\'');
r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/site-count-return`, { mailChoice: 'keep', acknowledge: true, confirm: true }); eq(r.json.error, 'quota_insufficient', '메일 유지 권리 부족이면 반환하지 않음'); eq(quota.figures(deps.store, w.group.id, 'sites').allocated, 1);
r = await call(app, 'POST', `/api/lifecycle/sites/${s.id}/site-count-return`, { mailChoice: 'end', acknowledge: true, confirm: true }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.returned, true); eq(r.json.figures.unallocated, 2); eq(site(deps, s.id).site_count_state, 'none'); eq(deps.store.get('SELECT routed FROM domains WHERE site_id=?', s.id).routed, 0); eq(deps.store.get('SELECT lifecycle FROM mail_services WHERE site_id=?', s.id).lifecycle, 'readonly_retention');
// 마지막 사이트 수를 두 사이트가 동시에 요구 → 하나만(lib/quota)
const s2 = seedSite(deps, w.group, { mail: false, name: 'b' }); const s3 = { id: newId('s_') }; deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, created_at, updated_at) VALUES(?,?,\'c\',\'draft\',?,?)', s3.id, w.group.id, now(), now());
quota.reserve(deps.store, { groupId: w.group.id, resource: 'sites', amount: 1, subjectType: 'site', subjectId: s3.id }); let threw = null; try { quota.reserve(deps.store, { groupId: w.group.id, resource: 'sites', amount: 1, subjectType: 'site', subjectId: 'another' }); } catch (e) { threw = e; } eq(threw && threw.code, 'quota_insufficient');
});
await test('메일 서비스 해지: 송수신 중지·30일 보존·삭제 예정, 사전 알림 → 삭제 대기 → 삭제(배정 반환)', async () => {
const { deps, w, app } = await setup(); deps.store.run('INSERT INTO mail_services(id, group_id, kind, name, domain, status, created_at, updated_at) VALUES(\'ms_std\',?,\'standalone\',\'독립\',\'std.example.test\',\'active\',?,?)', w.group.id, now(), now()); quota.grantEntitlement(deps.store, { groupId: w.group.id, resource: 'mailboxes', amount: 5 }); quota.setAllocation(deps.store, { groupId: w.group.id, resource: 'mailboxes', subjectType: 'mail_service', subjectId: 'ms_std', amount: 3 });
eq((await call(app, 'GET', '/mail/ms_std/lifecycle')).status, 200); let r = await call(app, 'GET', '/api/lifecycle/mail/ms_std'); eq(r.json.view.sendReceive, '송수신 가능');
r = await call(app, 'POST', '/api/lifecycle/mail/ms_std/cancel', { confirm: true }); eq(r.json.error, 'acknowledge_required');
r = await call(app, 'POST', '/api/lifecycle/mail/ms_std/cancel', { confirm: true, acknowledge: true }); eq(r.status, 200); eq(r.json.view.sendReceive, '송수신 중지(읽기·내보내기만)'); ok(r.json.view.retentionUntil); ok(r.json.view.backupNote.includes('제공하지 않습니다')); ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE incident_key IN (\'lifecycle:mail:ms_std\',\'mail:ms_std:cancel\') AND mandatory=1'));
deps.store.run('UPDATE mail_services SET next_notice_at=? WHERE id=\'ms_std\'', '2000-01-01T00:00:00Z'); await svc.runDue(deps); ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE incident_key=\'lifecycle:mail:ms_std:notice\''));
deps.store.run('UPDATE mail_services SET retention_until=? WHERE id=\'ms_std\'', '2000-01-01T00:00:00Z'); await svc.runDue(deps); eq(deps.store.get('SELECT lifecycle FROM mail_services WHERE id=\'ms_std\'').lifecycle, 'pending_delete'); eq(quota.figures(deps.store, w.group.id, 'mailboxes').allocated, 3, '삭제 전까지 소비');
deps.store.run('UPDATE mail_services SET delete_after=? WHERE id=\'ms_std\'', '2000-01-01T00:00:00Z'); await svc.runDue(deps); eq(deps.store.get('SELECT lifecycle FROM mail_services WHERE id=\'ms_std\'').lifecycle, 'deleted'); eq(quota.figures(deps.store, w.group.id, 'mailboxes').allocated, 0);
const r2 = await opsCall(app, 'GET', '/internal/ops/lifecycle'); eq(r2.status, 200); ok(r2.json.mail.some((m) => m.id === 'ms_std'));
});
await test('분쟁 동결 중에는 중지·해지·반환이 거부된다', async () => {
const { deps, w, app } = await setup(); const s = seedSite(deps, w.group, { mail: false }); deps.store.run('INSERT INTO ownership_transfers(id, from_group_id, to_email, scope_json, status, expires_at, created_at, updated_at) VALUES(\'tr1\',?,\'x@example.test\',?,\'disputed\',?,?,?)', w.group.id, JSON.stringify({ siteIds: [s.id] }), now(), now(), now());
eq((await call(app, 'POST', `/api/lifecycle/sites/${s.id}/pause`, { confirm: true })).json.error, 'transfer_disputed'); eq((await call(app, 'POST', `/api/lifecycle/sites/${s.id}/cancel`, { confirm: true, acknowledge: true })).json.error, 'transfer_disputed'); eq((await call(app, 'GET', `/api/lifecycle/sites/${s.id}`)).json.view.frozen, true);
});
done();