Files
AriWeb-Suite/test/hal-mailimport.test.mjs
T
zzabisagent-labandClaude Fable 5.1 79b45ca961 기존 AriMail 인스턴스 ↔ HAL 관리형 MailService/Node 가져오기: hal/mailsvc.mjs(등록부·dry-run·트랜잭션 적용·되돌리기·heartbeat 동기화) + scripts/mail-discover.mjs(읽기 전용 discovery) + CLI/운영자 API/화면, 테스트 9건, RUNBOOK §9
- mail_services(도메인 단위 논리 리소스, 불변 id, 출처 권한, 도메인별 active owner 1 — 부분 유일 인덱스)·mail_imports 표 추가(기존 표 변경 없음)
- discovery 문서에 비밀값이 있으면 거부(참조만 저장), 충돌·중복·누락은 전체 거부, 적용은 트랜잭션 + 감사(immutable), 재적용 멱등, 적용 단위 되돌리기
- hal/index.mjs: mail-import · mail-import-rollback · mail-services · server-token(시스템 신원 토큰, 감사) / app.mjs: /ops/api/mail-services* / ui: /ops/mail HAL 관리형 표 + 포털 중계(#portal-mail)
- core.heartbeat → syncFromHeartbeat(Agent 의 arimail 모듈 상태를 last_sync·health 로 반영, 행 없으면 무관)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 05:25:05 +00:00

148 lines
16 KiB
JavaScript

/** test/hal-mailimport.test.mjs — HAL 기존(legacy) 메일 서비스 가져오기(hal/mailsvc.mjs) 회귀 테스트.
* 읽기 전용 discovery 문서 → 검증(비밀값 차단) → dry-run 계획 → 트랜잭션 적용 → 감사 → readback → 되돌리기. 도메인별 active owner 는 정확히 하나. */
import { test, eq, ok, rejects, tmpDir, done } from '../lib/test.mjs';
import { invoke } from '../lib/http.mjs';
import { signHeaders } from '../lib/serviceauth.mjs';
import { createHalDeps } from '../hal/deps.mjs';
import { buildApp } from '../hal/app.mjs';
import * as core from '../hal/core.mjs';
import * as ms from '../hal/mailsvc.mjs';
import { listAudit } from '../lib/audit.mjs';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
function makeHal() {
const dataDir = join(tmpDir(), 'hal'); mkdirSync(dataDir, { recursive: true });
const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'https://hal.example.test', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: ['127.0.0.1/32'], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 3600000, emergencyTtlMinutes: 30, values: {}, set(p) { Object.assign(this, p); } };
const d = createHalDeps({ config, dbFile: ':memory:' }); d.jobsRunning = true; return { d, app: buildApp(d) };
}
/** 실제 환경(mail.arihakdang.com)과 같은 모양의 discovery 문서. 비밀값 없음, 참조만. */
function discovery(over = {}) {
return {
format: 1, collectedAt: '2026-10-27T05:00:00.000Z', collector: 'scripts/mail-discover.mjs',
source: { hostname: 'srv1953374', unit: 'arihakdang-mail.service', appDir: '/srv/arihakdang-mail', currentTarget: '/srv/arihakdang-mail/releases/0.1.1', user: 'arihakdang', nodeBin: '/opt/arihakdang-node/bin/node',
unitState: { active: 'active', enabled: 'enabled', nRestarts: 0, mainPid: 1046648, fragmentPath: '/etc/systemd/system/arihakdang-mail.service', environmentFiles: ['/etc/arihakdang-mail/gateway.env'] },
revision: { module: 'arimail', version: '0.1.1', upstreamVersion: '1.0.11', installedAt: '2026-10-26T14:09:29.902Z' }, packageVersion: '1.0.11', healthUrl: 'http://127.0.0.1:8790/api/health', health: { ok: true, version: '1.0.11', domains: 2 }, dataDir: '/srv/arihakdang-mail/data' },
domains: [
{ domain: 'arihakdang.com', enabled: true, primary: true, hosts: ['mail.arihakdang.com'], authMode: 'www', operating: 'active', ariweb: null },
{ domain: 'ariweb.net', enabled: true, primary: false, hosts: ['webmail.ariweb.net'], authMode: 'webmaker', operating: 'active', ariweb: null },
],
listeners: [{ port: 8790, bind: '127.0.0.1', owner: 'unit' }, { port: 587, bind: '0.0.0.0', owner: 'unit' }, { port: 993, bind: '0.0.0.0', owner: 'unit' }],
aggregates: { accounts: 4, mailboxes: 4, messages: 195, storageBytes: 545259520, byDomain: { 'arihakdang.com': { accounts: 2, mailboxes: 2, messages: 189, dbBytes: 22863872 }, 'ariweb.net': { accounts: 2, mailboxes: 2, messages: 6, dbBytes: 372736 } } },
provider: { kind: 'resend', haraka: { enabled: false, running: false }, configPath: '/srv/arihakdang-mail/data/config.json', configKeys: ['RESEND_API_KEY', 'inbound', 'outbound'] },
secretRefs: [{ name: 'gateway.env', ref: 'file:/etc/arihakdang-mail/gateway.env', mode: '600', owner: 'arihakdang' }, { name: 'config.json', ref: 'file:/srv/arihakdang-mail/data/config.json', mode: '600' }, { name: 'tls', ref: 'dir:/etc/arihakdang-mail/tls' }],
evidence: { nginx: { vhostFile: '/etc/nginx/sites-available/mail.arihakdang.conf', serverNames: ['mail.arihakdang.com'], proxyPass: 'http://127.0.0.1:8790' }, tls: { host: 'mail.arihakdang.com', subject: 'CN=mail.arihakdang.com', validTo: '2027-01-12T08:26:09.000Z' }, dns: { 'mail.arihakdang.com': { a: ['187.53.133.173'] }, 'arihakdang.com': { mx: ['inbound-smtp.ap-northeast-1.amazonaws.com'] } } },
...over,
};
}
const sig = (keyId, secret, method, path, body) => Object.fromEntries(Object.entries(signHeaders(keyId, secret, method, path, body === undefined ? undefined : JSON.stringify(body))).map(([k, v]) => [k.toLowerCase(), v]));
await test('discovery 검증: 형식·필수 항목·도메인 형식·중복 도메인·비밀값 포함 거부, 참조(secretRefs)만 허용', () => {
eq(ms.validateDiscovery(discovery()).ok, true);
ok(ms.validateDiscovery({ format: 2 }).errors.some((e) => /format/.test(e)), '형식 버전');
ok(ms.validateDiscovery(discovery({ source: null })).errors.some((e) => /source/.test(e)), 'source 필수');
ok(ms.validateDiscovery(discovery({ domains: [{ domain: 'Bad Domain', enabled: true, hosts: [] }] })).errors.some((e) => /도메인 형식/.test(e)), '도메인 형식');
ok(ms.validateDiscovery(discovery({ domains: [...discovery().domains, discovery().domains[0]] })).errors.some((e) => /중복/.test(e)), '문서 안 중복 도메인');
ok(ms.validateDiscovery(discovery({ secretRefs: [{ name: 'gateway.env', ref: 'file:/etc/x', value: 'abc' }] })).errors.some((e) => /비밀/.test(e)), 'secretRefs 에 값 금지');
ok(ms.validateDiscovery(discovery({ provider: { kind: 'resend', apiKey: 're_abcdefghijklmnopqrstuvwxyz0123456789' } })).errors.some((e) => /비밀/.test(e)), '비밀 형태 값 거부');
ok(ms.validateDiscovery(discovery({ provider: { kind: 'resend', password: 'hunter2hunter2' } })).errors.some((e) => /비밀/.test(e)), '비밀 이름 키의 값 거부');
ok(ms.validateDiscovery(discovery({ secretRefs: [{ name: 'x', ref: 'plain-value' }] })).errors.some((e) => /ref/.test(e)), 'ref 는 file:/dir:/vault:/env: 형식');
});
await test('dry-run 계획: 서버 없음·역할·호스트 불일치 거부, 도메인별 create, 비활성 도메인 skip, DB 변경 없음, 계획은 mail_imports 에 기록', async () => {
const { d } = makeHal(); const doc = discovery();
await rejects(() => ms.applyImport(d, doc, { serverId: 'srv_none', by: 'test', dryRun: true }), 'server_not_found');
const web = core.createServer(d, { name: 'web', role: 'web' });
await rejects(() => ms.applyImport(d, doc, { serverId: web.id, by: 'test', dryRun: true }), 'bad_role');
const srv = core.createServer(d, { name: 'srv1953374 (mail)', role: 'mail', location: 'hostinger' });
d.store.run('UPDATE servers SET facts_json=? WHERE id=?', JSON.stringify({ hostname: 'other-host' }), srv.id);
await rejects(() => ms.applyImport(d, doc, { serverId: srv.id, by: 'test', dryRun: true }), 'authority_mismatch');
d.store.run('UPDATE servers SET facts_json=NULL WHERE id=?', srv.id);
const r = ms.applyImport(d, { ...doc, domains: [...doc.domains, { domain: 'off.example', enabled: false, hosts: ['mail.off.example'] }] }, { serverId: srv.id, by: 'test', dryRun: true });
eq(r.mode, 'dry_run'); eq(r.plan.ok, true); eq(r.plan.summary, { create: 2, update: 0, reject: 0, skip: 1, missingInSource: 0 });
eq(r.plan.items.find((i) => i.domain === 'off.example').action, 'skip');
eq(ms.listMailServices(d).length, 0, 'dry-run 은 mail_services 를 바꾸지 않음');
const imp = d.store.get('SELECT * FROM mail_imports WHERE id=?', r.importId); eq(imp.mode, 'dry_run'); eq(imp.status, 'planned'); ok(!imp.applied_at);
ok(listAudit(d.store, { action: 'mail_service.import_dry_run' }).length === 1, '감사 기록');
});
await test('적용: 트랜잭션으로 생성, 불변 id, unassigned legacy managed, 도메인별 active owner 1, 비밀 참조만, 감사(immutable), 재적용은 update 멱등', async () => {
const { d } = makeHal(); const srv = core.createServer(d, { name: 'srv1953374 (mail)', role: 'mail' }); const doc = discovery();
const r = ms.applyImport(d, doc, { serverId: srv.id, by: 'test', dryRun: false });
eq(r.mode, 'apply'); eq(r.created.length, 2); eq(r.updated.length, 0);
const list = ms.listMailServices(d); eq(list.length, 2);
const a = list.find((x) => x.domain === 'arihakdang.com'); ok(/^mls_/.test(a.id)); eq(a.kind, 'legacy_managed'); eq(a.ownership, 'unassigned_legacy_managed'); eq(a.ownerGroupId, null); eq(a.active, true); eq(a.serverId, srv.id);
eq(a.authority, 'arimail:srv1953374:arihakdang-mail.service'); eq(a.instance.revision.version, '0.1.1'); eq(a.instance.revision.upstreamVersion, '1.0.11'); eq(a.publicHost, 'mail.arihakdang.com'); eq(a.webUrl, 'https://mail.arihakdang.com/');
eq(a.aggregates, { accounts: 2, mailboxes: 2, messages: 189, dbBytes: 22863872 }); eq(a.secretRefs.map((s) => s.ref), ['file:/etc/arihakdang-mail/gateway.env', 'file:/srv/arihakdang-mail/data/config.json', 'dir:/etc/arihakdang-mail/tls']);
ok(a.evidence.tls.subject === 'CN=mail.arihakdang.com' && a.evidence.dns['arihakdang.com'].mx[0].includes('amazonaws'), '증거 보존');
eq(a.lastSyncSource, 'import'); ok(a.lastSyncAt);
const au = listAudit(d.store, { action: 'mail_service.import' }); eq(au.length, 2); ok(au.every((x) => x.immutable === 1 && x.actor_type === 'operator' && x.actor_id === 'test'));
ok(!JSON.stringify(d.store.all('SELECT * FROM mail_services')).includes('re_'), 'DB 에 비밀 없음');
// 재적용: 같은 authority·서버 → update(id 유지), 집계만 갱신
const doc2 = discovery(); doc2.aggregates.byDomain['arihakdang.com'].messages = 190;
const r2 = ms.applyImport(d, doc2, { serverId: srv.id, by: 'test', dryRun: false }); eq(r2.created.length, 0); eq(r2.updated.length, 2);
const a2 = ms.getMailService(d, a.id); eq(a2.id, a.id); eq(a2.aggregates.messages, 190); eq(ms.listMailServices(d).length, 2, '중복 생성 없음');
const rb = ms.readback(d); eq(rb.counts.total, 2); eq(rb.counts.active, 2); eq(rb.duplicates, []); eq(rb.secretLeaks, 0); eq(rb.perServer[srv.id], 2);
});
await test('충돌·누락: 다른 서버/인스턴스가 이미 active 인 도메인은 collision 으로 전체 거부(부분 적용 없음), 원본에서 사라진 도메인은 missing_in_source 로 보고(삭제하지 않음)', async () => {
const { d } = makeHal(); const s1 = core.createServer(d, { name: 'h1', role: 'mail' }); const s2 = core.createServer(d, { name: 'h2', role: 'mail' });
ms.applyImport(d, discovery(), { serverId: s1.id, by: 'test', dryRun: false });
const other = discovery({ source: { ...discovery().source, hostname: 'jitsi', unit: 'arimail.service', appDir: '/srv/arimail' }, domains: [{ domain: 'ibit.kr', enabled: true, primary: true, hosts: ['webmail.ibit.kr'] }, { domain: 'ariweb.net', enabled: true, hosts: ['webmail.ariweb.net'] }] });
const r = ms.applyImport(d, other, { serverId: s2.id, by: 'test', dryRun: false });
eq(r.mode, 'rejected'); eq(r.plan.ok, false); eq(r.plan.items.find((i) => i.domain === 'ariweb.net').action, 'reject'); ok(/collision/.test(r.plan.items.find((i) => i.domain === 'ariweb.net').reason));
eq(ms.listMailServices(d).filter((x) => x.serverId === s2.id).length, 0, '부분 적용 없음(ibit.kr 도 만들지 않음)');
eq(d.store.get('SELECT status FROM mail_imports WHERE id=?', r.importId).status, 'rejected');
ok(listAudit(d.store, { action: 'mail_service.import_rejected' }).length === 1);
// 충돌 도메인을 뺀 문서는 적용된다
const okDoc = { ...other, domains: other.domains.slice(0, 1) }; const r2 = ms.applyImport(d, okDoc, { serverId: s2.id, by: 'test', dryRun: false }); eq(r2.created.length, 1);
eq(ms.readback(d).duplicates, []); eq(ms.readback(d).counts.active, 3);
// 원본에서 ariweb.net 이 사라지면: 보고만, 삭제 없음
const shrunk = discovery({ domains: [discovery().domains[0]] }); const r3 = ms.applyImport(d, shrunk, { serverId: s1.id, by: 'test', dryRun: true });
eq(r3.plan.summary.missingInSource, 1); eq(r3.plan.missingInSource, ['ariweb.net']); eq(ms.listMailServices(d).length, 3);
});
await test('되돌리기: 적용으로 만든 행 삭제·갱신한 행 복원, 두 번 되돌리기 거부, dry-run 되돌리기 거부', async () => {
const { d } = makeHal(); const srv = core.createServer(d, { name: 'h1', role: 'mail' });
const r1 = ms.applyImport(d, discovery(), { serverId: srv.id, by: 'test', dryRun: false });
const before = ms.getMailService(d, r1.created[0]).aggregates.messages;
const doc2 = discovery(); doc2.aggregates.byDomain['arihakdang.com'].messages = 999; doc2.domains.push({ domain: 'new.example', enabled: true, hosts: ['mail.new.example'] });
const r2 = ms.applyImport(d, doc2, { serverId: srv.id, by: 'test', dryRun: false }); eq(r2.created.length, 1); eq(r2.updated.length, 2);
const rb = ms.rollbackImport(d, r2.importId, 'test'); eq(rb.deleted, 1); eq(rb.restored, 2);
eq(ms.listMailServices(d).length, 2); eq(ms.getMailService(d, r1.created[0]).aggregates.messages, before, '갱신 전 값 복원');
await rejects(() => ms.rollbackImport(d, r2.importId, 'test'), 'already_rolled_back');
const dr = ms.applyImport(d, discovery(), { serverId: srv.id, by: 'test', dryRun: true }); await rejects(() => ms.rollbackImport(d, dr.importId, 'test'), 'not_applied');
ok(listAudit(d.store, { action: 'mail_service.import_rollback' }).length === 1);
});
await test('heartbeat 연동: 서버의 Agent 가 arimail 모듈 상태를 보고하면 last_sync·health 갱신(Data Plane 과 독립: HAL 행이 없어도 heartbeat 정상)', async () => {
const { d, app } = makeHal(); const srv = core.createServer(d, { name: 'srv1953374', role: 'mail' });
const t = core.issueRegistrationToken(d, { kind: 'managed', serverId: srv.id, label: 'm' }); const reg = await invoke(app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: 'srv1953374' }, version: '0.1.1' } }); ok(reg.json.ok);
const hb = (modules) => invoke(app, 'POST', '/agent/v1/heartbeat', { body: { version: '0.1.1', metrics: {}, modules, clockSkewMs: 0 }, headers: sig(`agent:${reg.json.agentId}`, reg.json.secret, 'POST', '/agent/v1/heartbeat', { version: '0.1.1', metrics: {}, modules, clockSkewMs: 0 }) });
eq((await hb({ arimail: { version: '0.1.1', active: true, health: 'ok' } })).json.ok, true, 'mail_services 행 없이도 정상');
const r = ms.applyImport(d, discovery(), { serverId: srv.id, by: 'test', dryRun: false }); const beforeSync = ms.getMailService(d, r.created[0]).lastSyncAt;
await new Promise((x) => setTimeout(x, 5)); await hb({ arimail: { version: '0.1.1', active: false, health: 'down' } });
const a = ms.getMailService(d, r.created[0]); eq(a.health, 'down'); eq(a.lastSyncSource, 'agent'); ok(a.lastSyncAt > beforeSync); eq(a.instance.observedVersion, '0.1.1');
eq(ms.readback(d).services[0].agentId, reg.json.agentId);
});
await test('운영자 API: 목록·상세·dry-run/적용(재인증)·되돌리기, 응답에 비밀 없음; CLI 와 같은 함수', async () => {
const { d, app } = makeHal(); const srv = core.createServer(d, { name: 'h1', role: 'mail' });
const { createAccount, login } = await import('../hal/ops.mjs'); createAccount(d, { login: 'op1', password: 'operator-pass-1', displayName: 'op' });
const l = await invoke(app, 'POST', '/ops/api/login', { body: { login: 'op1', password: 'operator-pass-1' } }); const cookie = l.cookies.aw_ops; const csrf = l.json.csrf; ok(cookie && csrf, '로그인');
const opsCall = (m, p, b) => invoke(app, m, p, { body: b, cookies: { aw_ops: cookie }, headers: { 'x-csrf': csrf } });
let r = await opsCall('GET', '/ops/api/mail-services'); eq(r.json.ok, true); eq(r.json.services, []); eq(r.json.readback.counts.total, 0);
r = await opsCall('POST', '/ops/api/mail-services/import', { discovery: discovery(), serverId: srv.id }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.result.mode, 'dry_run');
r = await opsCall('POST', '/ops/api/mail-services/import', { discovery: discovery(), serverId: srv.id, apply: true }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.result.mode, 'apply'); eq(r.json.result.created.length, 2);
r = await opsCall('GET', '/ops/api/mail-services'); eq(r.json.services.length, 2); ok(!JSON.stringify(r.json).includes('re_abc'));
r = await opsCall('GET', `/ops/api/mail-services/${r.json.services[0].id}`); eq(r.json.ok, true); ok(r.json.service.domain); ok(Array.isArray(r.json.audit));
r = await opsCall('GET', '/ops/api/mail-services/imports'); eq(r.json.imports.length, 2);
const applied = r.json.imports.find((i) => i.mode === 'apply'); r = await opsCall('POST', `/ops/api/mail-services/imports/${applied.id}/rollback`, {}); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.result.deleted, 2);
eq((await opsCall('GET', '/ops/api/mail-services')).json.services.length, 0);
// 메일 서비스 화면(HAL 관리형 표 + 포털 중계)
ms.applyImport(d, discovery(), { serverId: srv.id, by: 'op1', dryRun: false });
const page = await invoke(app, 'GET', '/ops/mail', { cookies: { aw_ops: cookie } }); ok(page.text.includes('arihakdang.com') && page.text.includes('legacy') && page.text.includes('unassigned'), '화면에 HAL 관리형 메일 서비스 표');
});
done();