Files
AriWeb-Suite/test/relocation.test.mjs

67 lines
11 KiB
JavaScript

/** 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();