Files

82 lines
8.7 KiB
JavaScript
Raw Permalink Normal View History

/** test/webmaker-inventory.test.mjs — 운영 Webmaker ↔ 포털 ↔ HAL inventory/dry-run 도구(scripts/webmaker-inventory.mjs) 회귀 테스트.
* 가짜 Webmaker 데이터 루트(JSON)와 실제 스키마로 만든 포털·HAL SQLite 로 판정·충돌·누락·중복·비밀 차단을 확인한다. */
import { test, eq, ok, tmpDir, done } from '../lib/test.mjs';
import { readWebmaker, readPortal, readHal, dryRun, assertNoSecrets } from '../scripts/webmaker-inventory.mjs';
import { openDb } from '../lib/db.mjs';
import { allMigrations } from '../portal/schema.mjs';
import { MIGRATIONS as HAL_MIGRATIONS } from '../hal/schema.mjs';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const P1 = 'a'.repeat(32), P2 = 'b'.repeat(32), P3 = 'c'.repeat(32), P4 = 'd'.repeat(32);
function webmakerRoot() {
const root = tmpDir('wm-inv-'); const data = join(root, 'data'); mkdirSync(join(data, 'sites'), { recursive: true }); mkdirSync(join(root, 'sites-root', P1), { recursive: true }); writeFileSync(join(root, 'sites-root', P1, 'index.html'), '<html>');
writeFileSync(join(data, 'users.json'), JSON.stringify({ icqmomo: { id: 'icqmomo', name: '오즈', salt: 'S', passHash: 'H', role: 'admin', accountType: 'domain', createdAt: '2026-10-14T00:00:00Z' }, 'aw-01a17894-01a17894': { id: 'aw-01a17894-01a17894', name: '검증', salt: 'S', passHash: 'H', role: 'user', accountType: 'domain', sso: 'ariweb' }, 'aw-deadbeef-deadbeef': { id: 'aw-deadbeef-deadbeef', name: '고아', salt: 'S', passHash: 'H', role: 'user', sso: 'ariweb' } }));
const site = (id, extra) => writeFileSync(join(data, 'sites', id + '.json'), JSON.stringify({ id, title: 'T' + id.slice(0, 2), ownerId: 'icqmomo', status: 'idle', domains: [], deployments: [], git: { changes: [] }, sessionId: 'SECRET', ...extra }));
site(P1, { domains: ['sample.example'], deployments: [{ targetId: 't1', at: '2026-10-15T07:27:00Z', url: 'https://x/' }], git: { changes: [{ no: 1, kind: 'deploy', branch: `sites/${P1}/main`, commit: '1111', pushed: true }] } });
site(P2, { ownerId: 'aw-01a17894-01a17894' }); site(P3, {}); site(P4, {});
writeFileSync(join(data, 'ariweb-links.json'), JSON.stringify({ version: 1, links: { [P2]: { siteId: 's_01a17894dd8ab3df984ae079f3', groupId: 'g_01a17894dd24bf64a49ff70600', siteName: '검증', linkedAt: 'x' }, [P4]: { siteId: 's_gone', groupId: 'g_gone' } } }));
writeFileSync(join(data, 'domains.json'), JSON.stringify({ domains: [{ name: 'sample.example', siteId: P1, ownerId: 'icqmomo', status: 'active' }], contacts: {} }));
writeFileSync(join(data, 'deploy-targets.json'), JSON.stringify({ defaultId: 't1', targets: [{ id: 't1', name: '기본', type: 'local', baseDir: '/var/www/x', publicBaseUrl: 'https://x', privateKey: 'SECRET' }] }));
writeFileSync(join(data, 'mail-servers.json'), JSON.stringify({ version: 1, items: [{ id: 'm1', name: 'AriMail', provider: 'arimail', apiToken: 'SECRET' }], defaultId: 'm1' }));
writeFileSync(join(data, 'config.json'), JSON.stringify({ version: 1, values: { service: { publicBaseUrl: 'https://wm.example' }, source: { provider: 'gitea', token: 'SECRET' } } }));
writeFileSync(join(root, 'REVISION.json'), JSON.stringify({ module: 'webmaker', version: '0.1.1', upstreamVersion: '1.7.3' }));
mkdirSync(join(root, 'nginx')); writeFileSync(join(root, 'nginx', `wh-${P1}.conf`), 'server {}');
return { root, data };
}
async function portalDb(root, rows) {
const db = openDb(join(root, 'portal.sqlite'), await allMigrations()); const now = '2026-10-26T00:00:00Z';
db.run('INSERT INTO users (id,email,display_name,status,created_at,updated_at) VALUES (?,?,?,?,?,?)', 'u_01a17894dce305ff35231f3676', 'alice@check.example', '앨리스', 'active', now, now);
db.run('INSERT INTO groups (id,name,kind,owner_user_id,status,created_at,updated_at) VALUES (?,?,?,?,?,?,?)', 'g_01a17894dd24bf64a49ff70600', '팀', 'team', 'u_01a17894dce305ff35231f3676', 'active', now, now);
for (const [id, pid] of rows) db.run('INSERT INTO sites (id,group_id,name,webmaker_project_id,lifecycle,deploy_mode,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)', id, 'g_01a17894dd24bf64a49ff70600', 'S', pid, 'draft', 'managed', now, now);
db.close(); return join(root, 'portal.sqlite');
}
function halDb(root, { webmakerTarget = true, health = 'ok' } = {}) {
const db = openDb(join(root, 'hal.sqlite'), HAL_MIGRATIONS); const now = '2026-10-26T00:00:00Z';
db.run('INSERT INTO servers (id,name,role,location,status,agent_id,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)', 'srv_1', 'srv', 'control', 'x', 'ready', 'ag_1', now, now);
db.run('INSERT INTO agents (id,server_id,kind,status,version,registered_at,secret_hash) VALUES (?,?,?,?,?,?,?)', 'ag_1', 'srv_1', 'managed', 'active', '0.1.1', now, 'x');
if (webmakerTarget) db.run('INSERT INTO module_targets (module,server_id,target_version,actual_version,health,active,updated_at) VALUES (?,?,?,?,?,?,?)', 'webmaker', 'srv_1', '0.1.1', '0.1.1', health, 1, now);
db.close(); return join(root, 'hal.sqlite');
}
await test('Webmaker inventory: 비밀 없이 회원·프로젝트·도메인·배포 대상·연결 등록부를 읽는다', async () => {
const { root, data } = webmakerRoot(); const w = readWebmaker(data, { sitesRoot: join(root, 'sites-root'), nginxEnabled: join(root, 'nginx'), appDir: root });
eq(w.users.length, 3); eq(w.projects.length, 4); eq(w.domains.length, 1); eq(w.deployTargets.length, 1); eq(w.mailServers.length, 1); eq(w.links.length, 2);
const p1 = w.projects.find((p) => p.id === P1); eq(p1.deployments, 1); eq(p1.siteDirFiles, 1); eq(p1.vhost, true); eq(p1.git.lastPushed.commit, '1111'); eq(w.revision.version, '0.1.1');
ok(!JSON.stringify(w).includes('SECRET'), '비밀값이 출력에 없어야 한다'); ok(!JSON.stringify(w).includes('passHash'));
});
await test('dry-run: 양쪽 등록부 일치는 linked_consistent, 고유 프로젝트는 unlinked_native, 모두 일치하면 no-op', async () => {
const { root, data } = webmakerRoot(); const w = readWebmaker(data);
// P4 는 Webmaker 쪽에만 연결이 있으므로 충돌 — 이 테스트는 P4 연결을 지운 데이터로 본다
w.projects.find((p) => p.id === P4).link = null; w.links = w.links.filter((l) => l.projectId !== P4); w.users = w.users.filter((u) => u.id !== 'aw-deadbeef-deadbeef'); // 고아 SSO 회원은 충돌 테스트용
const portal = readPortal(await portalDb(root, [['s_01a17894dd8ab3df984ae079f3', P2]])); const hal = readHal(halDb(root));
const r = dryRun({ webmaker: w, portal, hal, giteaRefs: `1111\trefs/heads/sites/${P1}/main\n` });
eq(r.summary.verdicts.linked_consistent, 1); eq(r.summary.verdicts.unlinked_native, 3); eq(r.summary.conflicts, 0); eq(r.summary.missing, 0);
eq(r.users.find((u) => u.userId === 'aw-01a17894-01a17894').verdict, 'sso_consistent'); eq(r.users.find((u) => u.userId === 'icqmomo').verdict, 'native_no_portal_identity');
eq(r.domains[0].verdict, 'webmaker_only'); eq(r.gitea[0].verdict, 'ok'); eq(r.halModule.registered, true); eq(r.halModule.agentOnline, true);
ok(r.applyPlan.startsWith('no-op'), r.applyPlan);
});
await test('dry-run: 한쪽만 있는 연결·다른 사이트를 가리키는 연결·중복·없는 프로젝트·HAL 등록 누락·Gitea ref 누락을 모두 충돌/누락으로 잡는다', async () => {
const { root, data } = webmakerRoot(); const w = readWebmaker(data);
const portal = readPortal(await portalDb(root, [['s_x', P2], ['s_y', P3], ['s_z', P3], ['s_ghost', 'f'.repeat(32)]])); const hal = readHal(halDb(root, { webmakerTarget: false }));
const r = dryRun({ webmaker: w, portal, hal, giteaRefs: `2222\trefs/heads/sites/${P1}/main\n` });
const kinds = r.findings.map((f) => f.kind).sort();
eq(kinds, ['duplicate_portal_sites', 'gitea_ref', 'hal_module_target_missing', 'link_mismatch', 'link_webmaker_only', 'missing_project', 'sso_identity_missing']);
eq(r.projects.find((p) => p.projectId === P2).verdict, 'link_mismatch'); eq(r.projects.find((p) => p.projectId === P3).verdict, 'duplicate'); eq(r.projects.find((p) => p.projectId === P4).verdict, 'link_webmaker_only');
ok(r.applyPlan.startsWith('blocked'), r.applyPlan); ok(r.summary.conflicts >= 4 && r.summary.missing >= 3);
});
await test('포털 없이(HAL 만) 돌리면 연결은 unverified 로 두고 판정하지 않는다', async () => {
const { root, data } = webmakerRoot(); const w = readWebmaker(data); const r = dryRun({ webmaker: w, hal: readHal(halDb(root)) });
eq(r.projects.find((p) => p.projectId === P2).verdict, 'link_unverified'); eq(r.summary.portal, null); eq(r.summary.conflicts, 0);
});
await test('assertNoSecrets 는 비밀로 보이는 키를 거부한다', async () => {
let threw = false; try { assertNoSecrets({ a: { passHash: 'x' } }); } catch (_) { threw = true; } ok(threw);
assertNoSecrets({ sessionGen: 1, isDefault: true, lastCheckAt: 'x' });
});
done();