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

70 lines
12 KiB
JavaScript

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