관리형 사이트 백업·복원, 서버 이전, 소유권 이전, 중지·해지·보존 생명주기와 운영 사이트 수 반환 (§14·§19~§23)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
c397452acd
commit
a014e3cd05
@@ -0,0 +1,99 @@
|
||||
/** 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();
|
||||
@@ -0,0 +1,80 @@
|
||||
/** 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();
|
||||
@@ -0,0 +1,66 @@
|
||||
/** test/relocation.test.mjs — 서버 이전(§21) 회귀 테스트 */
|
||||
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 svc from '../portal/relocation/service.mjs';
|
||||
import { newId } from '../lib/crypto.mjs';
|
||||
|
||||
const now = () => new Date().toISOString();
|
||||
function seedPolicy(deps) { 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_reloc', 'relocation', 1, '서버 이전', 'service', 'once', 30000, 'KRW', 0.1, '{}', JSON.stringify({ target_downtime_min: 10, revert_hours: 48, old_retention_days: 7, customer_cost: 30000 }), '2026-01-01T00:00:00Z', 1, now()); }
|
||||
function seedSite(deps, group, { runtime = 'static', mode = 'managed', server = 'srv-a' } = {}) { const id = newId('s_'); deps.store.run('INSERT INTO sites(id, group_id, name, lifecycle, deploy_mode, server_id, primary_domain, settings_json, created_at, updated_at) VALUES(?,?,?,\'live\',?,?,\'ex.example.test\',?,?,?)', id, group.id, '사이트', mode, server, JSON.stringify({ runtime, diskBytes: 1000 }), now(), now()); deps.store.run('INSERT INTO domains(id, group_id, name, purpose, site_id, is_primary, cert_state, dns_state, created_at, updated_at) VALUES(?,?,?,\'web\',?,1,\'valid\',\'ok\',?,?)', newId('d_'), group.id, 'ex' + id.slice(-6) + '.example.test', id, now(), now()); return deps.store.get('SELECT * FROM sites WHERE id=?', id); }
|
||||
async function setup() { const hal = createFakeHal(); hal.behave['site.relocate'] = () => ({ __pending: true, status: 'running' }); const deps = await makeDeps({ hal }); const w = seedGroup(deps); seedPolicy(deps); deps.vault.set('service:hal', 'ops-secret-example'); const app = await appFor(deps, ['relocation'], 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])));
|
||||
async function runUntilSettled(deps, jobId, max = 10) { for (let i = 0; i < max; i++) { const j = deps.jobs.get(jobId); if (!['queued', 'running'].includes(j.status)) return j; if (j.status === 'queued') deps.store.run('UPDATE jobs SET next_run_at=? WHERE id=?', '2000-01-01T00:00:00Z', jobId); await deps.jobs.drain(); } return deps.jobs.get(jobId); }
|
||||
|
||||
await test('경계값·자동 이전 조건·외부 배포 거부', async () => {
|
||||
const { deps, w, app } = await setup(); const s = seedSite(deps, w.group); const d = (await call(app, 'GET', `/api/relocation/sites/${s.id}`)).json;
|
||||
eq(d.auto.ok, true); eq(d.boundaries.customer.cost, 30000); eq(d.boundaries.platform.cost, 0); eq(d.boundaries.customer.revertHours, 48); eq(d.boundaries.customer.writeFreeze, null); ok(d.boundaries.customer.lastCancelLabel);
|
||||
const dyn = seedSite(deps, w.group, { runtime: 'node' }); const d2 = (await call(app, 'GET', `/api/relocation/sites/${dyn.id}`)).json; eq(d2.auto.ok, false); ok(d2.boundaries.customer.writeFreeze);
|
||||
const ext = seedSite(deps, w.group, { mode: 'external' }); eq((await call(app, 'POST', `/api/relocation/sites/${ext.id}`, { confirm: true })).json.error, 'relocation_not_managed');
|
||||
});
|
||||
await test('자동(정적) 이전: 승인 없이 시작, 전환 전 멈춤 → 재확인 → 전환 → 완료, 서버는 전환 뒤에만 바뀜, 과금 없음', async () => {
|
||||
const { deps, hal, w, app } = await setup(); const s = seedSite(deps, w.group);
|
||||
const r = await opsCall(app, 'POST', '/internal/ops/relocation', { subjectId: s.id, toServerId: 'srv-c', reason: '장애 회피', operator: 'ops' }); eq(r.status, 200); eq(r.json.relocation.kind, 'auto_static'); eq(r.json.relocation.charged, 0); ok(r.json.relocation.job_id);
|
||||
const jid = r.json.relocation.job_id; await deps.jobs.drain(); let j = deps.jobs.get(jid); eq(j.status, 'queued', '복사 대기(retryLater)'); eq(j.steps.find((x) => x.id === 'wait_copy').state, 'pending'); eq(deps.store.get('SELECT server_id FROM sites WHERE id=?', s.id).server_id, 'srv-a', '전환 전 서버 유지');
|
||||
hal.reachGate(deps.store.get('SELECT hal_job_id FROM relocations WHERE id=?', r.json.relocation.id).hal_job_id); j = await runUntilSettled(deps, jid); eq(j.status, 'done', j.error); eq(j.steps.map((x) => x.state).every((x) => x === 'done'), true); eq(deps.store.get('SELECT server_id FROM sites WHERE id=?', s.id).server_id, 'srv-c');
|
||||
const rel = deps.store.get('SELECT * FROM relocations WHERE id=?', r.json.relocation.id); eq(rel.status, 'done'); ok(rel.old_retained_until); ok(hal.calls.some((c) => c.kind === 'site.relocate' && c.payload.pauseBeforeSwitch === true));
|
||||
ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE incident_key=? AND title LIKE \'%완료%\'', `relocation:${rel.id}`)); ok(deps.store.get('SELECT immutable FROM audit_log WHERE action=\'relocate.complete\'').immutable === 1);
|
||||
});
|
||||
await test('전환 직전 조건이 달라지면 자동 중지하고 이전 위치 유지(두 서버 동시 운영 없음)', async () => {
|
||||
const { deps, hal, w, app } = await setup(); const s = seedSite(deps, w.group);
|
||||
const r = await opsCall(app, 'POST', '/internal/ops/relocation', { subjectId: s.id, toServerId: 'srv-c', reason: '용량 조정', operator: 'ops' }); const jid = r.json.relocation.job_id; await deps.jobs.drain();
|
||||
hal.servers.find((x) => x.id === 'srv-c').acceptsNew = false; // 조건 변경
|
||||
hal.reachGate(deps.store.get('SELECT hal_job_id FROM relocations WHERE id=?', r.json.relocation.id).hal_job_id);
|
||||
const j = await runUntilSettled(deps, jid); eq(j.status, 'failed'); eq(j.error_code, 'relocation_conditions_changed'); eq(deps.store.get('SELECT server_id FROM sites WHERE id=?', s.id).server_id, 'srv-a'); eq(hal.jobs.get(deps.store.get('SELECT hal_job_id FROM relocations WHERE id=?', r.json.relocation.id).hal_job_id).status, 'cancelled');
|
||||
ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE incident_key=? AND mandatory=1', `relocation:${r.json.relocation.id}:stopped`)); eq(deps.store.get('SELECT status FROM relocations WHERE id=?', r.json.relocation.id).status, 'failed');
|
||||
});
|
||||
await test('승인형(동적): 고객 요청은 유료·승인 대기, 최종 확인 불일치 거부, 결제 확인 전 승인 거부, 승인 멱등, 취소·되돌리기', async () => {
|
||||
const { deps, hal, w, app } = await setup(); const s = seedSite(deps, w.group, { runtime: 'node' });
|
||||
let r = await call(app, 'POST', `/api/relocation/sites/${s.id}`, { confirm: true, reason: '성능' }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.relocation.status, 'awaiting_approval'); eq(r.json.relocation.charged, 1); eq(r.json.relocation.cost, 30000); const id = r.json.relocation.id;
|
||||
eq((await call(app, 'POST', `/api/relocation/sites/${s.id}`, { confirm: true })).json.created, false, '중복 요청 없음');
|
||||
r = await opsCall(app, 'POST', `/internal/ops/relocation/${id}/approve`, { operator: 'ops', confirm: { subjectId: s.id, toServerId: 'nope' } }); eq(r.json.error, 'final_confirmation_mismatch');
|
||||
r = await opsCall(app, 'POST', `/internal/ops/relocation/${id}/approve`, { operator: 'ops', confirm: { subjectId: s.id, toServerId: 'srv-c' } }); eq(r.json.error, 'relocation_unpaid');
|
||||
r = await opsCall(app, 'POST', `/internal/ops/relocation/${id}/approve`, { operator: 'ops', scheduledAt: '2000-01-01T00:00:00Z', confirm: { subjectId: s.id, toServerId: 'srv-c', paymentConfirmed: true } }); eq(r.status, 200); eq(r.json.created, true);
|
||||
eq((await opsCall(app, 'POST', `/internal/ops/relocation/${id}/approve`, { operator: 'ops', confirm: { subjectId: s.id, toServerId: 'srv-c', paymentConfirmed: true } })).json.created, false, '승인 멱등');
|
||||
const jid = r.json.job.id; await deps.jobs.drain(); // 복사 대기
|
||||
// 전환 전 취소 가능
|
||||
r = await call(app, 'POST', `/api/relocation/${id}/cancel`); eq(r.status, 200); eq(r.json.relocation.status, 'cancelled'); eq(deps.store.get('SELECT server_id FROM sites WHERE id=?', s.id).server_id, 'srv-a');
|
||||
// 새 요청 → 완료 → 되돌리기
|
||||
delete hal.behave['site.relocate']; hal.gateRelocate = true;
|
||||
r = await call(app, 'POST', `/api/relocation/sites/${s.id}`, { confirm: true }); const id2 = r.json.relocation.id; r = await opsCall(app, 'POST', `/internal/ops/relocation/${id2}/approve`, { operator: 'ops', confirm: { subjectId: s.id, toServerId: 'srv-c', paymentConfirmed: true } }); const j2 = await runUntilSettled(deps, r.json.job.id); eq(j2.status, 'done', j2.error); eq(deps.store.get('SELECT server_id FROM sites WHERE id=?', s.id).server_id, 'srv-c');
|
||||
eq((await call(app, 'POST', `/api/relocation/${id2}/cancel`)).status, 409, '전환 뒤 취소 불가');
|
||||
hal.gateRelocate = false; r = await call(app, 'POST', `/api/relocation/${id2}/revert`, { confirm: true }); eq(r.status, 200); const rj = await runUntilSettled(deps, r.json.job.id); eq(rj.status, 'reverted', rj.error); eq(deps.store.get('SELECT server_id FROM sites WHERE id=?', s.id).server_id, 'srv-a'); eq(deps.store.get('SELECT status FROM relocations WHERE id=?', id2).status, 'reverted');
|
||||
});
|
||||
await test('최종 외부 확인 실패면 새 위치를 확정하지 않고 실패·알림', async () => {
|
||||
const { deps, hal, w, app } = await setup(); hal.behave['site.relocate'] = (req) => ({ newServerId: req.payload.toServerId, externalCheck: { ok: false } }); const s = seedSite(deps, w.group);
|
||||
const r = await opsCall(app, 'POST', '/internal/ops/relocation', { subjectId: s.id, toServerId: 'srv-c', reason: 'x', operator: 'ops' }); const j = await runUntilSettled(deps, r.json.relocation.job_id); eq(j.status, 'failed'); eq(j.error_code, 'relocation_final_check_failed'); eq(deps.store.get('SELECT server_id FROM sites WHERE id=?', s.id).server_id, 'srv-a');
|
||||
});
|
||||
await test('진행 중인 작업이 있으면 요청 거부, 메일 이전은 메일 영역 없이는 needs_setup', async () => {
|
||||
const { deps, w, app } = await setup(); const s = seedSite(deps, w.group); deps.store.run('INSERT INTO restores(id, site_id, group_id, backup_id, status, requested_by, created_at, updated_at) VALUES(?,?,?,?,\'running\',?,?,?)', newId('rs_'), s.id, w.group.id, 'b', w.user.id, now(), now());
|
||||
eq((await call(app, 'POST', `/api/relocation/sites/${s.id}`, { confirm: true })).json.error, 'conflict');
|
||||
deps.store.run('INSERT INTO mail_services(id, group_id, kind, name, domain, arimail_server_id, status, created_at, updated_at) VALUES(?,?,\'standalone\',\'m\',\'m.example.test\',\'srv-m1\',\'active\',?,?)', 'ms_1', w.group.id, now(), now());
|
||||
const r = await opsCall(app, 'POST', '/internal/ops/relocation', { subjectType: 'mail_service', subjectId: 'ms_1', toServerId: 'srv-m2', operator: 'ops' }); eq(r.json.error, 'needs_setup');
|
||||
eq((await call(app, 'GET', `/sites/${s.id}/relocation`)).status, 200);
|
||||
});
|
||||
done();
|
||||
@@ -0,0 +1,69 @@
|
||||
/** test/transfer.test.mjs — 소유권 이전(§22) 회귀 테스트 */
|
||||
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/transfer/service.mjs';
|
||||
import { newId } from '../lib/crypto.mjs';
|
||||
|
||||
const now = () => new Date().toISOString();
|
||||
function seedSite(deps, group, 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(?,?,?,\'live\',\'managed\',\'srv-a\',?,\'in_use\',?,?)', id, group.id, name, 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()); 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()); deps.store.run('INSERT INTO mailboxes(id, mail_service_id, group_id, address, created_at, updated_at) VALUES(?,?,?,?,?,?)', newId('mb_'), 'ms_' + id, group.id, 'admin@' + name + '.example.test', now(), now()); deps.store.run('INSERT INTO site_permissions(site_id, user_id, perms_json, updated_at) VALUES(?,?,?,?)', id, 'u_other', '["view"]', now()); return deps.store.get('SELECT * FROM sites WHERE id=?', id); }
|
||||
async function setup() { const hal = createFakeHal(); const deps = await makeDeps({ hal }); const a = seedGroup(deps, { email: 'a@example.test', name: 'A그룹' }); const b = seedGroup(deps, { email: 'b@example.test', name: 'B그룹' }); deps.vault.set('service:hal', 'ops-secret-example'); for (const g of [a, b]) { quota.grantEntitlement(deps.store, { groupId: g.group.id, resource: 'sites', amount: 1 }); quota.grantEntitlement(deps.store, { groupId: g.group.id, resource: 'web_disk', amount: 1000 }); } const appA = await appFor(deps, ['transfer'], a); const appB = await appFor(deps, ['transfer'], b); return { deps, a, b, appA, appB }; }
|
||||
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])));
|
||||
function alloc(deps, g, site) { quota.setAllocation(deps.store, { groupId: g.id, resource: 'sites', subjectType: 'site', subjectId: site.id, amount: 1 }); quota.setAllocation(deps.store, { groupId: g.id, resource: 'web_disk', subjectType: 'site', subjectId: site.id, amount: 500 }); }
|
||||
|
||||
await test('범위: 사이트·도메인·대표 메일·백업·배정·진행 작업·미납 표시, 제외 항목, 독립 메일은 명시 선택만', async () => {
|
||||
const { deps, a, appA } = await setup(); const s = seedSite(deps, a.group, 'alpha'); alloc(deps, a.group, s);
|
||||
const r = await call(appA, 'POST', '/api/transfer/preview', { siteIds: [s.id] }); eq(r.status, 200); const sc = r.json.scope; eq(sc.sites[0].domains.length, 1); eq(sc.sites[0].siteMail.length, 1); eq(sc.needed, { sites: 1, web_disk: 500 }); eq(sc.excluded.length, 3);
|
||||
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\',?,?)', a.group.id, now(), now());
|
||||
eq((await call(appA, 'POST', '/api/transfer/preview', { siteIds: [s.id], mailServiceIds: ['ms_std'] })).json.scope.mailServices.length, 1);
|
||||
eq((await call(appA, 'POST', '/api/transfer/preview', { siteIds: [s.id], mailServiceIds: ['ms_' + s.id] })).status, 404, '사이트 대표 메일은 독립 메일로 고를 수 없음');
|
||||
});
|
||||
await test('흐름: 요청 → 수락 → 심사 → 승인 → 원자적 완료, 권한 소멸·청구 시각·감사, 진행 중 작업 차단, 중복 요청 차단', async () => {
|
||||
const { deps, a, b, appA, appB } = await setup(); const s = seedSite(deps, a.group, 'beta'); alloc(deps, a.group, s);
|
||||
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, a.group.id, now(), now());
|
||||
let r = await call(appA, 'POST', '/api/transfer', { toEmail: 'b@example.test', siteIds: [s.id], confirm: true }); eq(r.json.error, 'conflict');
|
||||
deps.store.run('UPDATE restores SET status=\'done\' WHERE id=\'rs1\'');
|
||||
r = await call(appA, 'POST', '/api/transfer', { toEmail: 'B@example.test', siteIds: [s.id], confirm: true }); eq(r.status, 200, JSON.stringify(r.json)); const id = r.json.transfer.id; eq(r.json.transfer.status, 'requested'); eq(r.json.transfer.canCancel, true);
|
||||
eq((await call(appA, 'POST', '/api/transfer', { toEmail: 'b@example.test', siteIds: [s.id], confirm: true })).json.error, 'transfer_open');
|
||||
ok(deps.store.get('SELECT 1 AS x FROM notifications WHERE user_id=? AND subject_id=?', b.user.id, id), '받는 사용자 알림'); ok(!JSON.stringify(deps.store.all('SELECT detail_json FROM notifications')).includes('"acceptToken":"'), '토큰 원문은 저장하지 않음');
|
||||
// 다른 그룹(무관)은 볼 수 없음
|
||||
const c = seedGroup(deps, { email: 'c@example.test' }); const appC = await appFor(deps, ['transfer'], c); eq((await call(appC, 'GET', `/api/transfer/${id}`)).status, 404);
|
||||
// 받는 쪽: 권리 부족 → 보류 → 구매 뒤 재확인 → 심사
|
||||
quota.setAllocation(deps.store, { groupId: b.group.id, resource: 'sites', subjectType: 'site', subjectId: 'other', amount: 1 });
|
||||
r = await call(appB, 'POST', `/api/transfer/${id}/accept`, { confirm: true }); eq(r.status, 200); eq(r.json.transfer.status, 'on_hold'); eq(r.json.transfer.hold[0].resource, 'sites'); eq(r.json.transfer.hold[0].shortage, 1);
|
||||
quota.grantEntitlement(deps.store, { groupId: b.group.id, resource: 'sites', amount: 1 }); r = await call(appB, 'POST', `/api/transfer/${id}/recheck`); eq(r.json.transfer.status, 'reviewing');
|
||||
// 운영자: 심사 내용 없으면 거부, 최종 확인 불일치 거부
|
||||
r = await opsCall(appA, 'POST', `/internal/ops/transfer/${id}/approve`, { operator: 'ops', review: {}, confirm: { fromGroupId: a.group.id, toGroupId: b.group.id } }); eq(r.json.error, 'review_incomplete');
|
||||
r = await opsCall(appA, 'POST', `/internal/ops/transfer/${id}/approve`, { operator: 'ops', review: { ownershipProof: 'ok', domainChecks: 'ok' }, confirm: { fromGroupId: a.group.id, toGroupId: 'x' } }); eq(r.json.error, 'final_confirmation_mismatch');
|
||||
r = await opsCall(appA, 'POST', `/internal/ops/transfer/${id}/approve`, { operator: 'ops', review: { ownershipProof: 'ok', domainChecks: 'ok' }, confirm: { fromGroupId: a.group.id, toGroupId: b.group.id } }); eq(r.status, 200); eq(r.json.transfer.status, 'completed'); ok(r.json.transfer.billing_cutover_at);
|
||||
eq(deps.store.get('SELECT group_id FROM sites WHERE id=?', s.id).group_id, b.group.id); eq(deps.store.get('SELECT group_id FROM domains WHERE site_id=?', s.id).group_id, b.group.id); eq(deps.store.get('SELECT group_id FROM mail_services WHERE site_id=?', s.id).group_id, b.group.id); eq(deps.store.get('SELECT group_id FROM mailboxes WHERE mail_service_id=?', 'ms_' + s.id).group_id, b.group.id);
|
||||
eq(deps.store.all('SELECT * FROM site_permissions WHERE site_id=?', s.id).length, 0, '이전 구성원 권한 소멸'); eq(quota.figures(deps.store, a.group.id, 'sites').allocated, 0); eq(quota.figures(deps.store, b.group.id, 'sites').allocated, 2); eq(quota.figures(deps.store, b.group.id, 'web_disk').allocated, 500);
|
||||
eq(deps.store.all('SELECT * FROM audit_log WHERE action=\'ownership.complete\' AND immutable=1').length, 2); eq(deps.store.all('SELECT * FROM notifications WHERE subject_id=? AND mandatory=1 AND title LIKE \'%완료%\' OR title LIKE \'%넘겨받%\'', id).length >= 2, true);
|
||||
eq((await opsCall(appA, 'POST', `/internal/ops/transfer/${id}/approve`, { operator: 'ops', review: { ownershipProof: 'ok', domainChecks: 'ok' }, confirm: { fromGroupId: a.group.id, toGroupId: b.group.id } })).json.created, false, '승인 멱등');
|
||||
// 완료 뒤 보내는 쪽은 사이트를 볼 수 없음(그룹 경계)
|
||||
const appB2 = await appFor(deps, ['transfer', 'backup'], b); eq((await call(appB2, 'GET', `/api/backup/sites/${s.id}`)).status, 200); const appA2 = await appFor(deps, ['backup'], a); eq((await call(appA2, 'GET', `/api/backup/sites/${s.id}`)).status, 404);
|
||||
});
|
||||
await test('원자성: 완료 도중 실패하면 아무것도 옮겨지지 않는다', async () => {
|
||||
const { deps, a, b, appA, appB } = await setup(); const s1 = seedSite(deps, a.group, 'g1'); const s2 = seedSite(deps, a.group, 'g2'); quota.grantEntitlement(deps.store, { groupId: a.group.id, resource: 'sites', amount: 1 }); alloc(deps, a.group, s1); quota.setAllocation(deps.store, { groupId: a.group.id, resource: 'sites', subjectType: 'site', subjectId: s2.id, amount: 1 }); quota.grantEntitlement(deps.store, { groupId: b.group.id, resource: 'sites', amount: 2 });
|
||||
const id = (await call(appA, 'POST', '/api/transfer', { toEmail: 'b@example.test', siteIds: [s1.id, s2.id], confirm: true })).json.transfer.id; await call(appB, 'POST', `/api/transfer/${id}/accept`, { confirm: true });
|
||||
let n = 0; deps._transferTxHook = () => { if (++n === 2) throw new Error('simulated failure'); };
|
||||
const r = await opsCall(appA, 'POST', `/internal/ops/transfer/${id}/approve`, { operator: 'ops', review: { ownershipProof: 'ok', domainChecks: 'ok' }, confirm: { fromGroupId: a.group.id, toGroupId: b.group.id } }); eq(r.status, 500);
|
||||
eq(deps.store.get('SELECT group_id FROM sites WHERE id=?', s1.id).group_id, a.group.id, '첫 사이트도 되돌려짐'); eq(deps.store.all('SELECT * FROM site_permissions WHERE site_id=?', s1.id).length, 1); eq(deps.store.get('SELECT status FROM ownership_transfers WHERE id=?', id).status, 'approved', '승인 기록은 남고 완료는 아님');
|
||||
delete deps._transferTxHook; const t2 = svc.complete(deps, id, 'ops'); eq(t2.status, 'completed'); eq(deps.store.get('SELECT group_id FROM sites WHERE id=?', s2.id).group_id, b.group.id);
|
||||
});
|
||||
await test('취소·거절·만료·분쟁 동결', async () => {
|
||||
const { deps, a, b, appA, appB } = await setup(); const s = seedSite(deps, a.group, 'd'); alloc(deps, a.group, s);
|
||||
let id = (await call(appA, 'POST', '/api/transfer', { toEmail: 'b@example.test', siteIds: [s.id], confirm: true })).json.transfer.id;
|
||||
eq((await call(appB, 'POST', `/api/transfer/${id}/decline`, { reason: '아니오' })).json.transfer.status, 'declined');
|
||||
id = (await call(appA, 'POST', '/api/transfer', { toEmail: 'b@example.test', siteIds: [s.id], confirm: true })).json.transfer.id; eq((await call(appA, 'POST', `/api/transfer/${id}/cancel`)).json.transfer.status, 'cancelled');
|
||||
id = (await call(appA, 'POST', '/api/transfer', { toEmail: 'b@example.test', siteIds: [s.id], confirm: true })).json.transfer.id; deps.store.run('UPDATE ownership_transfers SET expires_at=? WHERE id=?', '2000-01-01T00:00:00Z', id); eq(svc.runDue(deps), [id]); eq(svc.get(deps, id).status, 'expired'); eq((await call(appB, 'POST', `/api/transfer/${id}/accept`, { confirm: true })).json.error, 'transfer_not_acceptable');
|
||||
id = (await call(appA, 'POST', '/api/transfer', { toEmail: 'b@example.test', siteIds: [s.id], confirm: true })).json.transfer.id; const r = await call(appB, 'POST', `/api/transfer/${id}/dispute`, { confirm: true, note: '합의 안 됨' }); eq(r.json.transfer.status, 'disputed');
|
||||
eq(svc.isFrozen(deps, 'site', s.id), true); let threw = null; try { svc.assertNotFrozen(deps, 'site', s.id); } catch (e) { threw = e; } eq(threw.code, 'transfer_disputed');
|
||||
eq((await call(appA, 'POST', '/api/transfer', { toEmail: 'c@example.test', siteIds: [s.id], confirm: true })).json.error, 'transfer_disputed', '추가 이전 동결');
|
||||
ok(deps.store.all('SELECT * FROM notifications WHERE incident_key LIKE ? AND mandatory=1', `transfer:${id}:dispute:%`).length === 2, '양쪽 알림');
|
||||
{ const rr = await opsCall(appA, 'POST', `/internal/ops/transfer/${id}/resolve-dispute`, { operator: 'ops', outcome: 'cancel', note: '취소' }); eq(rr.json.transfer?.status, 'cancelled', JSON.stringify(rr.json)); } eq(svc.isFrozen(deps, 'site', s.id), false);
|
||||
for (const p of ['/transfers', '/transfers/new', `/transfers/${id}`]) eq((await call(appA, 'GET', p)).status, 200, p);
|
||||
});
|
||||
done();
|
||||
Reference in New Issue
Block a user