Files
AriWeb-Suite/test/mail-discover.test.mjs
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

47 lines
6.6 KiB
JavaScript

/** test/mail-discover.test.mjs — scripts/mail-discover.mjs(읽기 전용 AriMail discovery) 회귀 테스트.
* 가짜 앱 디렉터리(AriMail 1.0.7+ 등록부 형식·실제 스키마 이름의 SQLite)로 문서 모양·집계·비밀 미포함·HAL 검증 통과를 확인한다. systemctl/ss/네트워크는 없는 환경에서도 돈다(--no-net). */
process.env.ARIWEB_DISCOVER_NO_MAIN = '1';
import { test, eq, ok, tmpDir, done } from '../lib/test.mjs';
import { discover, dbCounts } from '../scripts/mail-discover.mjs';
import { validateDiscovery, authorityOf } from '../hal/mailsvc.mjs';
import { DatabaseSync } from 'node:sqlite';
import { mkdirSync, writeFileSync, symlinkSync } from 'node:fs';
import { join } from 'node:path';
function fakeApp() {
const app = join(tmpDir('mail-disc-'), 'srv', 'arimail'); const rel = join(app, 'releases', '20261024-204656'); mkdirSync(rel, { recursive: true }); mkdirSync(join(app, 'data', 'domains', 'second.example'), { recursive: true }); mkdirSync(join(app, 'data', 'backups'), { recursive: true });
symlinkSync(rel, join(app, 'current')); writeFileSync(join(rel, 'REVISION'), 'a07f69f1ffb6667de6e66b3d1ec43c1c66fab924\n'); writeFileSync(join(rel, 'package.json'), JSON.stringify({ name: 'arimail', version: '1.0.11' }));
writeFileSync(join(app, 'data', 'domains.json'), JSON.stringify({ _meta: { format: 1 }, primary: 'ibit.kr', domains: { 'ibit.kr': { domain: 'ibit.kr', enabled: true, primary: true, hosts: ['webmail.ibit.kr'], mailbox: 'admin@ibit.kr', auth: { mode: 'local' }, note: '' }, 'second.example': { domain: 'second.example', enabled: true, hosts: ['mail.second.example'], mailbox: 'x@second.example', auth: { mode: 'webmaker' }, ariweb: { kind: 'standalone', siteId: '', groupId: 'g_1', mailServiceId: 'msv_1' }, operating: 'frozen' } } }));
writeFileSync(join(app, 'data', 'config.json'), JSON.stringify({ _meta: {}, RESEND_API_KEY: 're_SECRETSECRETSECRETSECRETSECRET1234', inbound: { provider: 'resend' }, outbound: { provider: 'resend' } }), { mode: 0o600 });
writeFileSync(join(app, 'data', 'haraka-status.json'), JSON.stringify({ enabled: false, running: false, inbound: false, outbound: false, listen: ['127.0.0.1:2525'], version: '3.3.4', dkim: false }));
writeFileSync(join(app, 'data', 'setup-token'), 'tokentokentokentokentoken', { mode: 0o600 });
const mk = (f, users, msgs) => { const db = new DatabaseSync(f); db.exec("CREATE TABLE mail_users(id INTEGER PRIMARY KEY, login TEXT, password_hash TEXT, status TEXT); CREATE TABLE messages(id INTEGER PRIMARY KEY, subject TEXT); CREATE TABLE attachments(id INTEGER PRIMARY KEY); CREATE TABLE schema_migrations(id TEXT); CREATE TABLE outbound_queue(id INTEGER PRIMARY KEY)"); for (let i = 0; i < users; i++) db.prepare("INSERT INTO mail_users(login, password_hash, status) VALUES(?, 'HASHHASHHASHHASHHASHHASHHASHHASHHASHHASHHASH', 'active')").run('u' + i); for (let i = 0; i < msgs; i++) db.prepare('INSERT INTO messages(subject) VALUES(?)').run('s' + i); db.close(); };
mk(join(app, 'data', 'mail-gateway.sqlite'), 3, 7); mk(join(app, 'data', 'domains', 'second.example', 'mail.sqlite'), 2, 1);
const etc = join(app, '..', 'etc'); mkdirSync(join(etc, 'tls'), { recursive: true }); writeFileSync(join(etc, 'gateway.env'), 'MAILBOX=admin@ibit.kr\nRESEND_API_KEY=re_SECRETSECRETSECRETSECRETSECRET1234\nPUBLIC_HOST=webmail.ibit.kr\n', { mode: 0o600 }); writeFileSync(join(etc, 'tls', 'privkey.pem'), '-----BEGIN PRIVATE KEY-----\nxxx\n-----END PRIVATE KEY-----\n');
const vhost = join(etc, 'webmail.ibit.kr.conf'); writeFileSync(vhost, 'server { listen 80; server_name webmail.ibit.kr; location / { return 301 https://webmail.ibit.kr$request_uri; } }\nserver { listen 443 ssl; server_name webmail.ibit.kr; ssl_certificate /etc/letsencrypt/live/webmail.ibit.kr/fullchain.pem; location / { proxy_pass http://127.0.0.1:8790; } }');
const hook = join(etc, 'arimail.sh'); writeFileSync(hook, '#!/bin/bash\nsystemctl restart arimail || true\n');
return { app, etc, vhost, hook };
}
await test('dbCounts: 표 이름·행 수·무결성만(주소·해시는 읽지 않음)', () => {
const { app } = fakeApp(); const c = dbCounts(join(app, 'data', 'mail-gateway.sqlite'));
eq(c.accounts, 3); eq(c.activeAccounts, 3); eq(c.messages, 7); eq(c.integrity, 'ok'); eq(c.outboundQueue, 0); ok(!JSON.stringify(c).includes('HASH'));
eq(dbCounts(join(app, 'data', 'nope.sqlite')), null);
});
await test('discover(--no-net): 등록부 도메인별 집계(기본 도메인은 게이트웨이 DB, 나머지는 테넌트 DB), 리비전, 비밀은 참조만(키 이름만), HAL 검증 통과, authority', async () => {
const { app, etc, vhost, hook } = fakeApp();
const doc = await discover({ unit: 'arimail.service', appDir: app, etcDir: etc, vhost, hook, net: false, host: 'jitsi' });
eq(doc.format, 1); eq(doc.source.unit, 'arimail.service'); eq(doc.source.revision.commit, 'a07f69f1ffb6667de6e66b3d1ec43c1c66fab924'); eq(doc.source.packageVersion, '1.0.11'); ok(doc.source.currentTarget.endsWith('releases/20261024-204656')); eq(doc.source.releases, ['20261024-204656']);
eq(doc.domains.length, 2); const p = doc.domains.find((x) => x.domain === 'ibit.kr'); eq(p.primary, true); eq(p.hosts, ['webmail.ibit.kr']); eq(p.authMode, 'local'); ok(p.tenantDb.endsWith('mail-gateway.sqlite'));
const s = doc.domains.find((x) => x.domain === 'second.example'); eq(s.primary, false); eq(s.ariweb, { kind: 'standalone', siteId: null, groupId: 'g_1', mailServiceId: 'msv_1' }, '빈 값은 null 로 정규화'); eq(s.operating, 'frozen');
eq(doc.aggregates.accounts, 5); eq(doc.aggregates.mailboxes, 5); eq(doc.aggregates.messages, 8); eq(doc.aggregates.byDomain['ibit.kr'].accounts, 3); eq(doc.aggregates.byDomain['second.example'].messages, 1); ok(doc.aggregates.storageBytes > 0);
eq(doc.provider.kind, 'resend'); eq(doc.provider.configKeys, ['RESEND_API_KEY', 'inbound', 'outbound']); eq(doc.evidence.haraka.enabled, false);
const txt = JSON.stringify(doc); ok(!txt.includes('re_SECRET') && !txt.includes('HASHHASH') && !txt.includes('BEGIN PRIVATE') && !txt.includes('tokentoken'), '비밀값 없음'); ok(!txt.includes('admin@ibit.kr'), '메일 주소 없음');
const refs = doc.secretRefs.map((r) => r.ref); ok(refs.some((r) => r === `file:${join(app, 'data', 'config.json')}`) && refs.some((r) => r === `dir:${join(etc, 'tls')}`) && refs.some((r) => r.endsWith('setup-token')), JSON.stringify(refs));
eq(doc.evidence.nginx.serverNames, ['webmail.ibit.kr']); eq(doc.evidence.nginx.proxyPass, ['http://127.0.0.1:8790']); eq(doc.evidence.nginx.httpRedirect, true); eq(doc.evidence.certbot.restartsUnit, true);
const v = validateDiscovery(doc); eq(v.errors, []); eq(authorityOf(doc), 'arimail:jitsi:arimail.service');
});
done();