Files

65 lines
12 KiB
JavaScript

/**
* test/e2e/fake-arimail.mjs — AriMail 의 포털 연동 면만 흉내 내는 HTTP 서버(설정 API·관리자 API·사용량 API·건강).
*
* portal/mail/arimail.mjs 어댑터가 부르는 끝점과 응답 모양을 따른다(test/mail.test.mjs 의 가짜 서버를 확장).
* - 설정 API(X-Setup-Token): /api/setup/domains(GET/POST), /api/setup/domains/:d(PATCH/DELETE), /api/setup/config(GET/PUT, ?domain=), /api/setup/plan, /api/setup/plan/run, /api/setup/dns/probe, /api/setup/check
* - 관리자 API(X-Mail-Scope: admin + mg_admin 쿠키): /api/login, /api/admin/users(GET/POST), /api/admin/users/:id(PATCH/DELETE), /api/admin/config(GET/PATCH), /api/admin/status, /api/admin/rates
* - 사용량 API(Bearer 키): /api/usage, /api/usage/:domain — 도메인별 보장량(LIMIT_*)과 저장·통수에서 경고 구간·제한 사유를 계산
* - SMTP/IMAP/POP3 는 없다(메일 왕복은 로컬에서 검증할 수 없음 — 보고서에 그렇게 적는다). 하네스는 state.usage 로 사용량을 조작한다.
*/
import http from 'node:http';
export async function startFakeArimail(port, { setupToken, usageKey }) {
const state = { domains: new Map(), configs: new Map(), users: new Map(), services: new Map(), usage: new Map(), sessions: new Map(), calls: [] };
const usersOf = (d) => { if (!state.users.has(d)) state.users.set(d, []); return state.users.get(d); };
const level = (x, l) => (!l ? 0 : x / l >= 1 ? 100 : x / l >= 0.95 ? 95 : x / l >= 0.9 ? 90 : x / l >= 0.8 ? 80 : x / l >= 0.7 ? 70 : 0);
const report = (d) => { const u = state.usage.get(d) || {}; const cfg = state.configs.get(d) || {}; const users = usersOf(d); const lim = { accounts: Number(cfg.LIMIT_ACCOUNTS || 0), totalBytes: Number(cfg.LIMIT_TOTAL_BYTES || 0), monthlyMessages: Number(cfg.LIMIT_MONTHLY_MESSAGES || 0) }; const storage = u.storageBytes || 0; const month = (u.sent || 0) + (u.received || 0); const levels = { storage: level(storage, lim.totalBytes), monthly: level(month, lim.monthlyMessages), accounts: level(users.length, lim.accounts) }; const reasons = []; if (levels.storage === 100) reasons.push('storage_full'); if (levels.monthly === 100) reasons.push('monthly_limit'); if (levels.accounts === 100) reasons.push('accounts_full'); const e = state.domains.get(d) || {};
return { domain: d, enabled: true, hosts: e.hosts || [`mail.${d}`], authMode: 'local', inbound: 'haraka', outbound: 'haraka', timezone: 'Asia/Seoul', ariweb: e.ariweb || null, operating: e.operating || 'active', accounts: { local: users.length, external: 0, total: users.length, limit: lim.accounts, percent: lim.accounts ? Math.round(users.length / lim.accounts * 100) : 0 }, storage: { bytes: storage, limit: lim.totalBytes, percent: lim.totalBytes ? Math.round(storage / lim.totalBytes * 100) : 0, largeFiles: { count: 0, bytes: 0, sent: 0 } }, messages: { total: u.messages || 0, inbound: u.received || 0, outbound: u.sent || 0, drafts: 0, failed: 0 }, traffic: { day: { sent: 0, received: 0, total: 0 }, week: { sent: 0, received: 0, total: 0 }, month: { sent: u.sent || 0, received: u.received || 0, total: month, sentBytes: 0, receivedBytes: 0 }, monthlyLimit: lim.monthlyMessages, monthlyPercent: lim.monthlyMessages ? Math.round(month / lim.monthlyMessages * 100) : 0 }, warnLevel: Math.max(levels.storage, levels.monthly, levels.accounts), warnLevels: levels, limitedReasons: reasons, monthReset: u.monthReset || null, countingRule: 'per_final_recipient', accountList: users.map((x) => ({ login: x.login, name: x.name, mailbox: `${x.login}@${d}`, role: x.role || 'user', source: 'local', status: x.status || 'active', messages: 0, bytes: x.bytes || 0, limit: x.quotaBytes || 0, percent: x.quotaBytes ? Math.round((x.bytes || 0) / x.quotaBytes * 100) : 0, lastLoginAt: null, sent: 0, received: 0 })), limits: { system: {}, admin: {} } }; };
const srv = http.createServer((req, res) => {
let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => {
const url = new URL(req.url, 'http://x'); const p = url.pathname; const j = (code, o, h = {}) => { res.writeHead(code, { 'Content-Type': 'application/json', ...h }); res.end(JSON.stringify(o)); };
let b = {}; try { b = body ? JSON.parse(body) : {}; } catch (_) {}
const host = String(req.headers['x-forwarded-host'] || req.headers.host || ''); const domainOfHost = [...state.domains.values()].find((d) => (d.hosts || []).some((h) => String(h).split('/')[0] === host))?.domain || null;
state.calls.push({ m: req.method, p, host });
if (p === '/api/health') return j(200, { ok: true, version: '1.0.11-e2e-fake', domains: state.domains.size });
// ---- 설정 API
if (p.startsWith('/api/setup/')) {
if (req.headers['x-setup-token'] !== setupToken) return j(401, { ok: false, error: 'setup_token_invalid' });
const dq = url.searchParams.get('domain');
if (p === '/api/setup/domains' && req.method === 'GET') return j(200, { ok: true, domains: [...state.domains.values()] });
if (p === '/api/setup/domains' && req.method === 'POST') { const d = String(b.domain || '').toLowerCase(); if (!d) return j(422, { ok: false, error: 'domain_required' }); if (state.domains.has(d)) return j(409, { ok: false, error: 'domain_exists' }); const entry = { domain: d, enabled: true, hosts: b.hosts || [`mail.${d}`], mailbox: `${b.mailbox || 'admin'}@${d}`, auth: { mode: 'local' }, ariweb: b.ariweb || null, operating: b.operating || 'active', note: b.note || '' }; state.domains.set(d, entry); const pw = b.adminPassword || 'adminpasswd'; usersOf(d).push({ id: 'u_admin_' + d, login: b.mailbox || 'admin', name: '관리자', role: 'admin', password: pw, status: 'active' }); state.configs.set(d, {}); return j(201, { ok: true, domain: entry, initialAdmin: { login: b.mailbox || 'admin', password: pw } }); }
if (p.startsWith('/api/setup/domains/')) { const d = decodeURIComponent(p.split('/')[4] || ''); const e = state.domains.get(d); if (!e) return j(404, { ok: false, error: 'domain_not_found' }); if (req.method === 'PATCH') { Object.assign(e, b); return j(200, { ok: true, domain: e }); } if (req.method === 'DELETE') { state.domains.delete(d); state.users.delete(d); state.configs.delete(d); return j(200, { ok: true, removed: d }); } }
if (p === '/api/setup/config' && req.method === 'GET') { const d = dq || [...state.domains.keys()][0] || ''; const cfg = state.configs.get(d) || {}; return j(200, { ok: true, domain: d, values: Object.fromEntries(Object.entries(cfg).map(([k, v]) => [k, /SECRET|KEY|PASS/.test(k) ? '••••' : v])), meta: { host: `mail.${d}`, domain: d, dns: { records: [{ type: 'MX', name: d, value: `10 mail.${d}` }, { type: 'TXT', name: d, value: 'v=spf1 a mx -all' }, { type: 'TXT', name: `arimail._domainkey.${d}`, value: 'v=DKIM1; k=rsa; p=FAKEPUBLICKEY' }, { type: 'TXT', name: `_dmarc.${d}`, value: 'v=DMARC1; p=quarantine' }] }, inbound: 'haraka', outbound: 'haraka' } }); }
if (p === '/api/setup/config' && req.method === 'PUT') { const d = dq || ''; state.configs.set(d, { ...(state.configs.get(d) || {}), ...(b.values || {}) }); return j(200, { ok: true, saved: Object.keys(b.values || {}) }); }
if (p === '/api/setup/plan' && req.method === 'GET') return j(200, { ok: true, plan: { domain: dq, steps: [], records: [] } });
if (p === '/api/setup/plan/run') return j(200, { ok: true, log: [] });
if (p === '/api/setup/dns/probe') return j(200, { ok: true, probe: { mx: [], spf: null, dkim: null, dmarc: null } });
if (p === '/api/setup/check') return j(200, { ok: true, outboundStatus: { mode: 'haraka', provider: 'ok', checkedAt: new Date().toISOString() } });
return j(404, { ok: false, error: 'not_found' });
}
// ---- 사용량 API
if (p.startsWith('/api/usage')) { if ((req.headers.authorization || '') !== 'Bearer ' + usageKey) return j(401, { ok: false, error: 'unauthenticated' }); const d = decodeURIComponent(p.split('/')[3] || ''); if (d) { if (!state.domains.has(d)) return j(404, { ok: false, error: 'domain_not_found' }); return j(200, { ok: true, version: '1.0.11-e2e-fake', generatedAt: new Date().toISOString(), ...report(d) }); } return j(200, { ok: true, version: '1.0.11-e2e-fake', generatedAt: new Date().toISOString(), primary: [...state.domains.keys()][0] || null, totals: { domains: state.domains.size }, domains: [...state.domains.keys()].map(report) }); }
// ---- 로그인·관리자 API(도메인은 Host 로)
const d = domainOfHost; const admin = req.headers['x-mail-scope'] === 'admin';
if (p === '/api/login' && req.method === 'POST') { if (!d) return j(404, { ok: false, error: 'no_service_for_host' }); const u = usersOf(d).find((x) => x.login === b.login && x.password === b.password); if (!u) return j(401, { ok: false, error: 'invalid_credentials' }); if (admin && u.role !== 'admin') return j(403, { ok: false, error: 'admin_only' }); const tok = 'tok_' + Math.random().toString(36).slice(2); state.sessions.set(tok, { d, login: u.login, admin }); return j(200, { ok: true, user: { login: u.login, role: u.role } }, { 'Set-Cookie': `${admin ? 'mg_admin' : 'mg_session'}=${tok}; Path=/; HttpOnly` }); }
const cookie = String(req.headers.cookie || ''); const tok = (cookie.match(/mg_admin=([^;]+)/) || [])[1]; const sess = tok && state.sessions.get(tok);
if (p.startsWith('/api/admin/')) {
if (!admin || !sess || !sess.admin || sess.d !== d) return j(403, { ok: false, error: 'admin_login_required' });
const cfg = state.configs.get(d) || {}; const list = usersOf(d);
if (p === '/api/admin/users' && req.method === 'GET') return j(200, { ok: true, users: list.map((u) => ({ id: u.id, login: u.login, name: u.name, mailbox: `${u.login}@${d}`, role: u.role || 'user', status: u.status || 'active', quotaBytes: u.quotaBytes || 0, bytes: u.bytes || 0, services: u.services || { pop: true, imap: true } })), domain: d, accounts: { used: list.length, limit: Number(cfg.LIMIT_ACCOUNTS || 0) }, policy: { minLength: 6 }, services: state.services.get(d) || { pop: true, imap: true } });
if (p === '/api/admin/users' && req.method === 'POST') { if (list.some((u) => u.login === b.login)) return j(409, { ok: false, error: 'login_taken' }); if (Number(cfg.LIMIT_ACCOUNTS || 0) && list.length >= Number(cfg.LIMIT_ACCOUNTS)) return j(409, { ok: false, error: 'account_limit_reached' }); if (String(b.password || '').length < 6) return j(422, { ok: false, error: 'weak_password' }); const id = 'u_' + Math.random().toString(36).slice(2, 10); list.push({ id, login: b.login, name: b.name || '', role: b.role || 'user', password: b.password, status: b.status || 'active', quotaBytes: b.quotaBytes || 0, services: b.services }); return j(200, { ok: true, id }); }
if (p.startsWith('/api/admin/users/')) { const id = decodeURIComponent(p.split('/')[4]); const u = list.find((x) => x.id === id); if (!u) return j(404, { ok: false, error: 'not_found' }); if (req.method === 'PATCH') { Object.assign(u, b); return j(200, { ok: true }); } if (req.method === 'DELETE') { list.splice(list.indexOf(u), 1); return j(200, { ok: true }); } }
if (p === '/api/admin/config' && req.method === 'GET') return j(200, { ok: true, services: state.services.get(d) || { pop: true, imap: true }, largeFiles: { enabled: false, maxBytes: 0, maxDays: 0, maxDownloads: 0 }, systemLargeFiles: { bytes: 1024 ** 3, days: 30, downloads: 20 } });
if (p === '/api/admin/config' && req.method === 'PATCH') { if (b.services) state.services.set(d, { ...(state.services.get(d) || { pop: true, imap: true }), ...b.services }); return j(200, { ok: true, services: state.services.get(d) || { pop: true, imap: true } }); }
if (p === '/api/admin/status') return j(200, { ok: true, host: `mail.${d}`, tls: true, ports: { pop3s: 995, pop3: 110, imaps: 993, imap: 143, smtps: 465, submission: 587 }, services: state.services.get(d) || { pop: true, imap: true }, mail: { inbound: 'haraka', outbound: 'haraka', status: { provider: 'ok' } } });
if (p === '/api/admin/rates') return j(200, { ok: true, rates: { deliveryRate: 5, submissionRate: 10, buckets: [], submissions: [] }, queue: [], abuse: [] });
return j(404, { ok: false, error: 'not_found' });
}
if (p === '/api/mail') return j(200, { ok: true, messages: [] });
j(404, { ok: false, error: 'not_found' });
});
});
await new Promise((r) => srv.listen(port, '127.0.0.1', r));
return { url: `http://127.0.0.1:${port}`, state, close: () => new Promise((r) => srv.close(r)) };
}