/** * 1.0.7 시험: 다중 도메인(server/domains.mjs) — 등록부 생성, 호스트·로그인 ID 로 테넌트 고르기, 도메인 추가(자기 DB·설정·초기 관리자)·중지·삭제, * 전체 설정 API(/api/setup/domains, ?domain=), IMAP 로그인의 도메인 분리, Haraka 수신 전달의 도메인 나누기, 안내 화면, Haraka 관리 프로세스의 다중 도메인 설정 생성. * 실행: node test/domains.test.mjs (npm test 와 deploy.sh 가 함께 돌립니다) */ import net from 'node:net'; import http from 'node:http'; import { spawn } from 'node:child_process'; import { mkdtempSync, existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { makeEnv, runner, eq, ok, request, params } from './_helpers.mjs'; import { handle } from '../server/api.mjs'; import { loadConfig, saveConfig, configFile, setupToken } from '../server/config.mjs'; import { openTenants, loadRegistry, registryFile, normalizeEntry, noServicePage, isLocalHost, COPY_KEYS } from '../server/domains.mjs'; import { createImap } from '../server/imap.mjs'; import { updateUser } from '../server/auth.mjs'; import { createSetupServer, setupApi } from '../server/setup.mjs'; const { t, done } = runner(); const HERE = dirname(fileURLToPath(import.meta.url)), ROOT = join(HERE, '..'); const tmp = () => mkdtempSync(join(tmpdir(), 'arimail-dom-')); const api = (env, path, cookie, method, body, headers) => handle(env, request(path, cookie, method, body, headers), params(path)); /** 기본 도메인 env: 파일 DB 위치(DB_FILE)만 있으면 되고 DB 자체는 메모리입니다(main.mjs 처럼 loadConfig 를 먼저 부릅니다). */ async function base(over = {}) { const dir = tmp(); const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), PUBLIC_HOST: 'mail.arihakdang.com', RESEND_API_KEY: 're_primary_key_123456', DNS_PROVIDER: 'route53', DNS_AWS_ACCESS_KEY_ID: 'AKIA', DNS_AWS_SECRET_ACCESS_KEY: 'sekret', ...over }); loadConfig(env); return { env, dir }; } const open = (env) => openTenants(env, { migrationsDir: join(ROOT, 'migrations'), log: { log() {}, error() {} } }); await t('등록부: 첫 시작에 환경 파일의 도메인으로 domains.json 을 만들고, 호스트·IP·localhost·하위 호스트·모르는 호스트를 규칙대로 고른다', async () => { const { env, dir } = await base(); const tenants = open(env); ok(existsSync(registryFile(env)) && registryFile(env) === join(dir, 'domains.json'), '등록부 파일'); const reg = loadRegistry(env); eq(reg.primary, 'arihakdang.com', '기본 도메인'); eq(reg.domains['arihakdang.com'].hosts[0], 'mail.arihakdang.com', '기본 호스트'); eq(env.DOMAIN, 'arihakdang.com', 'env.DOMAIN'); eq(env.MAIL_SERVER_HOST, 'mail.arihakdang.com', '메일 서버 이름'); for (const h of ['mail.arihakdang.com', 'MAIL.arihakdang.com:8790', '127.0.0.1:8790', 'localhost', '[::1]:8790', '', 'arihakdang.com', 'webmail.arihakdang.com']) eq(tenants.resolveHost(h).tenant?.entry.domain, 'arihakdang.com', '기본 도메인으로: ' + h); eq(tenants.resolveHost('mail.other.test').tenant, null, '모르는 호스트'); eq(tenants.resolveHost('notarihakdang.com').tenant, null, '접미사만 비슷한 호스트'); ok(isLocalHost('10.0.0.5') && isLocalHost('localhost') && !isLocalHost('mail.x.com'), 'isLocalHost'); eq(tenants.resolveLogin('admin', '').DOMAIN, 'arihakdang.com', '도메인 없는 로그인 → 기본'); eq(tenants.resolveLogin('admin@arihakdang.com', ''), env, '@기본 도메인'); eq(tenants.resolveLogin('x@unknown.test', '').DOMAIN, 'arihakdang.com', '@등록되지 않은 도메인(www 관리자 이메일 로그인)은 기본 도메인에서 검사'); eq((await tenants.list()).length, 1, '목록 1개'); tenants.close(); }); await t('도메인 추가: 자기 디렉터리에 DB·설정(기본 도메인의 키·DNS 설정 복사)·초기 관리자(admin)가 생기고 계정은 도메인마다 따로이며, 중복·형식·www DB 오류를 거른다', async () => { const { env, dir } = await base(); const tenants = open(env); const r = await tenants.add({ domain: 'Second.test', hosts: 'mail.second.test, webmail.second.test', adminPassword: 'secondpw1', note: '둘째' }); eq(r.entry.domain, 'second.test', '소문자 정규화'); eq(r.entry.hosts.join(','), 'mail.second.test,webmail.second.test', '호스트 목록'); eq(r.initialAdmin?.login, 'admin', '초기 관리자'); eq(r.initialAdmin?.password, 'secondpw1', '지정한 비밀번호'); ok(existsSync(join(dir, 'domains', 'second.test', 'mail.sqlite')) && existsSync(join(dir, 'domains', 'second.test', 'config.json')), '도메인 디렉터리'); const sec = tenants.byDomain('second.test').env; eq(sec.MAILBOX, 'admin@second.test', '메일함'); eq(sec.PUBLIC_HOST, 'mail.second.test', '공개 호스트'); eq(sec.MAIL_SERVER_HOST, 'mail.arihakdang.com', '메일 서버 이름은 기본 도메인'); eq(sec.LOGIN_MODE, 'standalone', '자체 계정'); ok(!sec.WWW_DB && !sec.WWW_DB_FILE, 'www DB 없음'); eq(sec.RESEND_API_KEY, 're_primary_key_123456', 'API 키 복사'); eq(sec.DNS_PROVIDER, 'route53', 'DNS 설정 복사'); eq(sec.DNS_AWS_SECRET_ACCESS_KEY, 'sekret', 'DNS 비밀 복사'); eq(sec.DNS_AWS_HOSTED_ZONE_ID, undefined, '존 id 는 복사 안 함(도메인으로 자동 검색)'); eq(sec.MAIL_INBOUND, 'resend', '수신 기본'); ok(COPY_KEYS.includes('RESEND_API_KEY') && !COPY_KEYS.includes('HARAKA_DKIM_PRIVATE'), '복사 키 목록'); ok(JSON.parse(readFileSync(configFile(sec), 'utf8'))._meta.copiedFrom === 'arihakdang.com', '설정 파일 메타'); // 로그인: 둘째 도메인의 admin 은 둘째 env 에서만, 기본 도메인의 www 관리자는 둘째 env 에서 거부 const login = await api(sec, 'login', '', 'POST', { login: 'admin', password: 'secondpw1' }); eq(login.status, 200, '둘째 도메인 로그인'); const cookie = login.headers.get('set-cookie').split(';')[0]; const session = await (await api(sec, 'session', cookie)).json(); eq(session.mailbox, 'admin@second.test', '세션 메일함'); eq(session.domain, 'second.test', '세션 도메인'); eq(session.initialPassword, true, '초기 비밀번호 경고'); const st = await (await api(sec, 'settings', cookie)).json(); eq(st.imap.host, 'mail.arihakdang.com', '메일 프로그램 서버 이름은 기본 도메인'); eq(st.smtp.host, 'mail.arihakdang.com', 'SMTP 서버 이름'); eq(st.host, 'mail.second.test', '웹 메일 호스트는 자기 것'); eq(st.mailbox, 'admin@second.test', '설정 화면 메일함'); eq((await api(sec, 'login', '', 'POST', { login: 'arihakdang', password: 'admin-secret-12' })).status, 401, '기본 도메인 계정은 둘째에서 거부'); eq((await api(env, 'login', '', 'POST', { login: 'admin', password: 'secondpw1' })).status, 401, '둘째 계정은 기본에서 거부'); eq((await api(env, 'session', cookie)).status, 200, '세션 응답'); eq((await (await api(env, 'session', cookie)).json()).user, null, '둘째 세션 쿠키는 기본 도메인에서 비로그인'); // 호스트·로그인 ID 로 고르기 eq(tenants.resolveHost('webmail.second.test').tenant.entry.domain, 'second.test', '추가 호스트'); eq(tenants.resolveHost('imap.second.test').tenant.entry.domain, 'second.test', '하위 호스트'); eq(tenants.resolveLogin('admin@second.test', '').DOMAIN, 'second.test', '@둘째'); eq(tenants.resolveLogin('admin', 'mail.second.test').DOMAIN, 'second.test', 'SNI 로 둘째'); eq(tenants.resolveLogin('admin', 'mail.arihakdang.com').DOMAIN, 'arihakdang.com', 'SNI 로 기본'); // 오류 for (const [body, code, field] of [[{ domain: 'second.test' }, 'domain_exists'], [{ domain: 'arihakdang.com' }, 'domain_exists'], [{ domain: 'third.test', hosts: 'mail.second.test' }, 'host_taken', 'hosts'], [{ domain: 'bad domain' }, 'validation', 'domain'], [{ domain: 'third.test', adminPassword: 'abc' }, 'validation', 'adminPassword'], [{ domain: 'third.test', auth: { mode: 'www', wwwDbFile: '/nonexistent/x.sqlite' } }, 'www_db_unreadable', 'auth.wwwDbFile'], [{ domain: 'third.test', auth: { mode: 'ldap' } }, 'validation', 'auth.mode']]) { let err = null; try { await tenants.add(body); } catch (e) { err = e; } eq(err?.message, code, JSON.stringify(body)); if (field) eq(err?.field, field, 'field ' + code); } eq((await tenants.list()).length, 2, '목록 2개'); const second = (await tenants.list())[1]; eq(second.accounts, 1, '계정 수'); eq(second.initialPassword, 'secondpw1', '목록의 초기 비밀번호 안내'); // 관리자가 비밀번호를 바꾸면 초기 비밀번호 안내가 사라집니다 await updateUser(sec, (await sec.DB.prepare('SELECT id FROM mail_users').first()).id, { password: 'changedpw1' }); eq((await tenants.list())[1].initialPassword, null, '비밀번호 변경 뒤 안내 없음'); // 같은 도메인을 동시에 추가하면 하나만 성공합니다 const race = await Promise.allSettled([tenants.add({ domain: 'race.test' }), tenants.add({ domain: 'race.test' })]); eq(race.filter((x) => x.status === 'fulfilled').length, 1, '동시 추가 하나만'); eq(race.find((x) => x.status === 'rejected')?.reason?.message, 'domain_exists', '다른 하나는 중복'); // 복사 없이 추가 + 등록부 다시 읽기(재시작 흉내) await tenants.add({ domain: 'third.test', copyFromPrimary: false }); eq(tenants.byDomain('third.test').env.RESEND_API_KEY, undefined, '복사 안 함'); tenants.close(); const env2 = (await base({ DB_FILE: join(dir, 'mail-gateway.sqlite') })).env; const again = open(env2); eq(again.all().map((x) => x.entry.domain).sort().join(','), 'arihakdang.com,race.test,second.test,third.test', '재시작 뒤 등록부'); eq(again.byDomain('second.test').env.RESEND_API_KEY, 're_primary_key_123456', '재시작 뒤 설정 유지'); eq((await api(again.byDomain('second.test').env, 'login', '', 'POST', { login: 'admin@second.test', password: 'changedpw1' })).status, 200, '재시작 뒤 로그인(바꾼 비밀번호)'); again.close(); }); await t('중지·수정·삭제: 중지한 도메인은 disabled 로 알려 로그인·수신을 막고, 기본 도메인은 지울 수 없으며, 삭제해도 데이터 디렉터리는 남는다', async () => { const { env, dir } = await base(); const tenants = open(env); await tenants.add({ domain: 'second.test' }); tenants.update('second.test', { enabled: false }); const r = tenants.resolveHost('mail.second.test'); ok(r.tenant && r.disabled, '중지 표시'); eq(tenants.resolveLogin('admin@second.test', ''), null, '중지 도메인 로그인 불가'); eq(tenants.envs().length, 1, '주기 작업 대상에서 제외'); tenants.update('second.test', { enabled: true, hosts: 'post.second.test', note: '메모' }); eq(tenants.resolveHost('post.second.test').disabled, false, '다시 사용'); eq(tenants.byDomain('second.test').env.PUBLIC_HOST, 'post.second.test', '호스트 변경이 env 에 반영'); eq(tenants.resolveHost('mail.second.test').tenant.entry.domain, 'second.test', '옛 호스트도 하위 호스트 규칙으로'); // 로그인 방식 바꾸기(www): 읽을 수 있는 SQLite 파일이면 허용 const www = join(dir, 'www.sqlite'); writeFileSync(www, ''); tenants.update('second.test', { auth: { mode: 'www', wwwDbFile: www, ownerLogin: 'owner' } }); eq(tenants.byDomain('second.test').env.WWW_DB_FILE, www, 'www DB 반영'); eq(tenants.byDomain('second.test').env.LOGIN_MODE, 'www', 'www 방식'); tenants.update('second.test', { auth: { mode: 'local' } }); ok(!tenants.byDomain('second.test').env.WWW_DB_FILE, '자체 계정으로 복귀'); const p = tenants.update('arihakdang.com', { hosts: 'alias.arihakdang.com', enabled: false, auth: { mode: 'local' } }); eq(p.enabled, true, '기본 도메인은 끌 수 없음'); eq(p.hosts.join(','), 'mail.arihakdang.com,alias.arihakdang.com', '기본 호스트는 항상 포함'); eq(p.auth.mode, 'www', '기본 도메인의 로그인 방식은 환경 파일'); let err = null; try { tenants.remove('arihakdang.com'); } catch (e) { err = e; } eq(err?.message, 'primary_domain', '기본 도메인 삭제 불가'); const removed = tenants.remove('second.test'); eq(tenants.resolveHost('post.second.test').tenant, null, '삭제 뒤 모르는 호스트'); ok(existsSync(join(removed.dir, 'mail.sqlite')), '데이터 디렉터리 유지'); ok(!loadRegistry(env).domains['second.test'], '등록부에서 제거'); tenants.close(); }); await t('전체 설정 API: 전용 포트에서 도메인 목록·추가·수정·삭제와 ?domain= 선택이 동작하고, 추가 도메인 호스트로 열면 안내 화면, 관리자 세션(/setup)에서는 도메인 관리가 없다', async () => { const { env } = await base({ SETUP_PORT: '0' }); const tenants = open(env); env.APP_VERSION = '1.0.7-test'; const setup = createSetupServer(env, { webDir: join(ROOT, 'web'), tenants }); const port = 40000 + Math.floor(Math.random() * 20000); await setup.listen({ port, host: '127.0.0.1' }); const token = setupToken(env); eq(readFileSync(join(dirname(env.DB_FILE), 'setup-token'), 'utf8').trim(), token, '토큰 파일'); const call = (path, method = 'GET', body, host = '127.0.0.1') => fetch(`http://127.0.0.1:${port}${path}`, { method, headers: { 'X-Setup-Token': token, Host: host, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined }); try { let r = await call('/api/setup/domains'); let d = await r.json(); eq(r.status, 200, '목록'); eq(d.primary, 'arihakdang.com', 'primary'); eq(d.domains.length, 1, '1개'); eq(d.serverHost, 'mail.arihakdang.com', '메일 서버 이름'); r = await call('/api/setup/domains', 'POST', { domain: 'second.test', adminPassword: 'secondpw1' }); d = await r.json(); eq(r.status, 201, '추가'); eq(d.initialAdmin.login, 'admin', '초기 관리자'); eq(d.domains.length, 2, '목록 갱신'); r = await call('/api/setup/domains', 'POST', { domain: 'second.test' }); eq(r.status, 409, '중복'); eq((await r.json()).error, 'domain_exists', '중복 코드'); r = await call('/api/setup/domains', 'POST', { domain: 'x y' }); eq(r.status, 422, '형식'); eq((await r.json()).field, 'domain', '필드'); // ?domain= 으로 둘째 도메인의 설정을 읽고 씁니다 r = await call('/api/setup/config?domain=second.test'); d = await r.json(); eq(d.meta.mailbox, 'admin@second.test', '둘째 설정'); eq(d.meta.primary, false, '기본 아님'); eq(d.meta.serverHost, 'mail.arihakdang.com', '서버 이름'); r = await call('/api/setup/config?domain=second.test', 'PUT', { values: { MAIL_FROM_NAME: '둘째' } }); d = await r.json(); eq(d.values.MAIL_FROM_NAME, '둘째', '둘째 저장'); eq(tenants.byDomain('second.test').env.MAIL_FROM_NAME, '둘째', '둘째 env 적용'); ok(env.MAIL_FROM_NAME !== '둘째', '기본 도메인은 그대로'); r = await call('/api/setup/config'); d = await r.json(); eq(d.meta.mailbox, 'admin@arihakdang.com', '기본 설정'); eq(d.meta.primary, true, '기본'); eq(d.meta.domains, 2, '도메인 수'); r = await call('/api/setup/config?domain=nope.test'); eq(r.status, 404, '모르는 도메인'); eq((await r.json()).error, 'domain_not_found', '코드'); r = await call('/api/setup/plan?domain=second.test'); d = await r.json(); eq(d.plan.domain, 'second.test', '둘째 도메인의 자동 설정 계획'); r = await call('/api/setup/domains/second.test', 'PATCH', { enabled: false }); d = await r.json(); eq(r.status, 200, '중지'); eq(d.domains[1].enabled, false, '중지 반영'); r = await call('/api/setup/domains/second.test', 'DELETE'); eq(r.status, 200, '삭제'); eq((await r.json()).domains.length, 1, '삭제 뒤 1개'); r = await call('/api/setup/domains/arihakdang.com', 'DELETE'); eq(r.status, 409, '기본 삭제 불가'); // 추가 도메인의 호스트 이름으로 전용 포트에 오면 안내 화면(기본 도메인 주소로 열라고) await tenants.add({ domain: 'second.test' }); // fetch 는 Host 헤더를 바꿀 수 없으므로 http 모듈로 직접 보냅니다 const rawGet = (host) => new Promise((resolve, reject) => { http.get({ host: '127.0.0.1', port, path: '/', headers: { Host: host } }, (res) => { let body = ''; res.setEncoding('utf8'); res.on('data', (c) => { body += c; }); res.on('end', () => resolve({ status: res.statusCode, body })); }).on('error', reject); }); let g = await rawGet('mail.second.test'); eq(g.status, 404, '다른 호스트'); ok(g.body.includes('기본 도메인 주소로'), '안내 문구'); g = await rawGet('mail.arihakdang.com'); eq(g.status, 200, '기본 호스트'); g = await rawGet('127.0.0.1:' + port); eq(g.status, 200, 'IP'); r = await call('/api/health'); eq((await r.json()).setup, true, '상태 확인'); } finally { setup.close(); tenants.close(); } // 관리자 세션 쪽(api.mjs → setupApi, tenants 없음): domains 는 404 const r2 = await setupApi(env, new Request('https://mail.arihakdang.com/api/setup/domains'), ['domains'], { ip: '127.0.0.1', user: { login: 'x' } }); eq(r2.status, 404, '관리자 세션에서는 도메인 관리 없음'); }); await t('IMAP: 로그인 ID 의 @도메인으로 도메인을 고르고(admin@second.test → 둘째 DB), 도메인 없는 ID 는 기본 도메인, 모르는·중지한 도메인은 거부한다', async () => { const { env } = await base({ POP_AUTH_DELAY_MS: 0 }); const tenants = open(env); await tenants.add({ domain: 'second.test', adminPassword: 'secondpw1' }); await tenants.add({ domain: 'off.test', adminPassword: 'offpw123' }); tenants.update('off.test', { enabled: false }); const imap = createImap(env, { hostname: 'mail.test', resolveEnv: (l, s) => tenants.resolveLogin(l, s) }); const port = 40000 + Math.floor(Math.random() * 20000); await imap.listen({ plainPort: port, host: '127.0.0.1' }); const session = async (login, password) => { const c = imapClient(port); await c.greeting(); const r = await c.cmd(`LOGIN "${login}" "${password}"`); return { c, r }; }; try { let { c, r } = await session('admin@second.test', 'secondpw1'); ok(/OK/.test(r), '둘째 로그인: ' + r.trim()); r = await c.cmd('SELECT INBOX'); ok(/OK/.test(r), '둘째 SELECT'); c.end(); ({ c, r } = await session('admin@second.test', 'admin-secret-12')); ok(/NO/.test(r), '둘째에 기본 비밀번호 거부'); c.end(); ({ c, r } = await session('arihakdang', 'admin-secret-12')); ok(/OK/.test(r), '도메인 없는 ID 는 기본 도메인'); c.end(); ({ c, r } = await session('admin@off.test', 'offpw123')); ok(/NO/.test(r), '중지한 도메인 거부'); c.end(); ({ c, r } = await session('admin@nowhere.test', 'secondpw1')); ok(/NO/.test(r), '모르는 도메인은 기본 도메인에서 검사 → 계정 없음'); c.end(); ({ c, r } = await session('ADMIN@arihakdang.com', 'admin-secret-12')); ok(/OK/.test(r), 'www 관리자 이메일 로그인(기본 도메인)'); c.end(); } finally { imap.close(); tenants.close(); } }); await t('Haraka 수신 전달: 받는 사람 주소의 도메인으로 저장할 DB 를 고르고, 토큰은 기본 도메인 것이며, 수신 방식이 haraka 인 도메인만 받는다', async () => { const { env } = await base(); const tenants = open(env); await tenants.add({ domain: 'second.test' }); await tenants.add({ domain: 'third.test' }); const sec = tenants.byDomain('second.test').env, third = tenants.byDomain('third.test').env; eq((await api(env, 'inbound/haraka/rcpt?addr=admin@second.test', '', 'GET', undefined, { 'X-AriMail-Token': env.HARAKA_TOKEN })).status, 404, '아무 도메인도 haraka 수신이 아니면 404'); saveConfig(sec, { inbound: 'haraka' }); // 둘째만 haraka 수신 const hdr = { 'X-AriMail-Token': env.HARAKA_TOKEN }; eq((await api(env, 'inbound/haraka/rcpt?addr=admin@second.test', '', 'GET', undefined, { 'X-AriMail-Token': sec.HARAKA_TOKEN })).status, 401, '둘째 도메인의 토큰은 거부(토큰은 기본 도메인 것)'); const rc = async (addr) => (await (await api(env, 'inbound/haraka/rcpt?addr=' + addr, '', 'GET', undefined, hdr)).json()).local; eq(await rc('admin@second.test'), true, '둘째 메일함'); eq(await rc('nobody@second.test'), false, '둘째 없는 사용자'); eq(await rc('admin@third.test'), false, '셋째는 haraka 수신 아님'); eq(await rc('admin@arihakdang.com'), false, '기본은 haraka 수신 아님'); const eml = Buffer.from('From: sender@example.test\r\nTo: admin@second.test\r\nSubject: hello second\r\nMessage-ID: \r\n\r\nbody\r\n'); const post = (rcpt, id) => handle(env, new Request('https://x/api/inbound/haraka', { method: 'POST', headers: { ...hdr, 'X-AriMail-Id': id, 'X-AriMail-From': 'sender@example.test', 'X-AriMail-Rcpt': JSON.stringify(rcpt), 'Content-Type': 'message/rfc822' }, body: eml }), { path: ['inbound', 'haraka'], ip: '127.0.0.1' }); let r = await post(['admin@second.test'], 'tx1'); eq(r.status, 200, '저장'); const r1 = await r.json(); ok(r1.imported && r1.mailbox === 'admin@second.test', '둘째 메일함에 저장 — ' + JSON.stringify(r1)); // 두 도메인의 주소가 한 트랜잭션에 함께 오면 각 DB 에 저장하고 합계를 돌려줍니다 saveConfig(third, { inbound: 'haraka' }); r = await post(['admin@second.test', 'admin@third.test'], 'tx2'); const r2 = await r.json(); eq(r2.imported, 2, '두 도메인 합계'); eq(r2.results.length, 2, '도메인별 결과'); eq(Number((await third.DB.prepare('SELECT COUNT(*) n FROM messages').first()).n), 1, '셋째 DB 에 저장'); saveConfig(third, { inbound: 'resend' }); eq(Number((await sec.DB.prepare('SELECT COUNT(*) n FROM messages').first()).n), 2, '둘째 DB 에 저장'); eq(Number((await env.DB.prepare('SELECT COUNT(*) n FROM messages').first()).n), 0, '기본 DB 에는 없음'); eq((await sec.DB.prepare("SELECT status FROM webhook_events WHERE id='haraka:tx1'").first()).status, 'stored', '둘째의 수신 기록'); r = await post(['admin@second.test'], 'tx1'); eq((await r.json()).skipped, 'exists', '재시도는 중복'); eq((await sec.DB.prepare("SELECT status FROM webhook_events WHERE id='haraka:tx1'").first()).status, 'stored', '기록 유지'); tenants.close(); }); await t('안내 화면과 항목 정규화: 모르는 도메인·중지한 도메인 문구, 호스트 기본값 mail.<도메인>, 로그인 방식 검사', async () => { const html = noServicePage({ host: 'mail.nowhere.test', strings: {}, version: '1.0.7' }); ok(html.includes('메일 서비스가 존재하지 않습니다') && html.includes('mail.nowhere.test') && html.includes('관리자에게'), '모르는 도메인 문구'); ok(noServicePage({ disabled: true }).includes('활성화되지 않았습니다'), '중지 문구'); ok(noServicePage({ host: '' }).includes('<x>'), '이스케이프'); const e = normalizeEntry({ domain: ' Example.COM. ' }); eq(e.domain, 'example.com', '도메인'); eq(e.hosts[0], 'mail.example.com', '기본 호스트'); eq(e.mailbox, 'admin@example.com', '기본 메일함'); eq(e.auth.mode, 'local', '기본 로그인'); eq(e.enabled, true, '기본 사용'); const e2 = normalizeEntry({ domain: 'example.com', mailbox: 'Postmaster', hosts: ['Mail.Example.com', 'mail.example.com', 'a.example.com:443'] }); eq(e2.mailbox, 'postmaster@example.com', '메일함 로컬'); eq(e2.hosts.join(','), 'mail.example.com,a.example.com', '중복·포트 제거'); let err = null; try { normalizeEntry({ domain: 'example.com', hosts: 'bad host' }); } catch (x) { err = x; } eq(err?.field, 'hosts', '호스트 검사'); err = null; try { normalizeEntry({ domain: 'example.com', auth: { mode: 'www' } }); } catch (x) { err = x; } eq(err?.field, 'auth.wwwDbFile', 'www 는 DB 파일 필수'); }); await t('Haraka 관리 프로세스: 등록부의 켜진 도메인 중 수신 haraka 인 도메인을 domains= 에 넣고, 발신 haraka + DKIM 키가 있는 도메인마다 dkim/<도메인>/ 키 파일을 만든다', async () => { const dir = tmp(), data = join(dir, 'data'), haraka = join(dir, 'haraka'); mkdirSync(data, { recursive: true }); mkdirSync(join(haraka, 'node_modules', 'Haraka', 'bin'), { recursive: true }); mkdirSync(join(haraka, 'node_modules', 'Haraka', 'config'), { recursive: true }); // 가짜 Haraka: 실행 파일은 그냥 대기하는 스크립트(설정 생성만 봅니다) writeFileSync(join(haraka, 'node_modules', 'Haraka', 'bin', 'haraka'), 'setInterval(() => {}, 1000);\n'); writeFileSync(join(haraka, 'node_modules', 'Haraka', 'package.json'), '{"version":"0.0.0-test"}'); writeFileSync(join(data, 'config.json'), JSON.stringify({ inbound: 'haraka', outbound: 'haraka', HARAKA_TOKEN: 'tok', HARAKA_DKIM_PRIVATE: 'KEY-PRIMARY', HARAKA_DKIM_SELECTOR: 'p1' })); writeFileSync(join(data, 'domains.json'), JSON.stringify({ primary: 'a.test', domains: { 'a.test': { domain: 'a.test', enabled: true, hosts: ['mail.a.test'] }, 'b.test': { domain: 'b.test', enabled: true, hosts: ['mail.b.test'] }, 'c.test': { domain: 'c.test', enabled: false, hosts: ['mail.c.test'] }, 'd.test': { domain: 'd.test', enabled: true, hosts: ['mail.d.test'] } } })); mkdirSync(join(data, 'domains', 'b.test'), { recursive: true }); writeFileSync(join(data, 'domains', 'b.test', 'config.json'), JSON.stringify({ inbound: 'haraka', outbound: 'haraka', HARAKA_DKIM_PRIVATE: 'KEY-B', HARAKA_DKIM_SELECTOR: 'sel-b' })); mkdirSync(join(data, 'domains', 'c.test'), { recursive: true }); writeFileSync(join(data, 'domains', 'c.test', 'config.json'), JSON.stringify({ inbound: 'haraka', outbound: 'haraka', HARAKA_DKIM_PRIVATE: 'KEY-C' })); mkdirSync(join(data, 'domains', 'd.test'), { recursive: true }); writeFileSync(join(data, 'domains', 'd.test', 'config.json'), JSON.stringify({ inbound: 'resend', outbound: 'haraka' })); // 발신만, 키 없음 // 예전에 서명하던 도메인의 디렉터리(지워져야 함) mkdirSync(join(haraka, 'config', 'dkim', 'old.test'), { recursive: true }); writeFileSync(join(haraka, 'config', 'dkim', 'old.test', 'private'), 'OLD'); writeFileSync(join(haraka, 'config', 'dkim.private.key'), 'OLD-SINGLE'); const child = spawn(process.execPath, [join(ROOT, 'haraka', 'run.mjs')], { env: { ...process.env, DB_FILE: join(data, 'mail-gateway.sqlite'), HARAKA_DIR: haraka, HARAKA_CHECK_MS: '200', MAILBOX: 'admin@a.test', PUBLIC_HOST: 'mail.a.test', PORT: '18990' }, stdio: ['ignore', 'pipe', 'pipe'] }); let out = ''; child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { out += c; }); try { const until = Date.now() + 8000; while (Date.now() < until && !existsSync(join(haraka, 'config', 'dkim', 'b.test', 'private'))) await new Promise((r) => setTimeout(r, 100)); const ini = readFileSync(join(haraka, 'config', 'arimail.ini'), 'utf8'); ok(ini.includes('domains=a.test,b.test\n'), 'domains= 에 켜진 haraka 수신 도메인만: ' + ini); eq(readFileSync(join(haraka, 'config', 'dkim', 'b.test', 'selector'), 'utf8'), 'sel-b\n', 'b 셀렉터'); eq(readFileSync(join(haraka, 'config', 'dkim', 'b.test', 'private'), 'utf8'), 'KEY-B\n', 'b 키'); eq(readFileSync(join(haraka, 'config', 'dkim', 'a.test', 'selector'), 'utf8'), 'p1\n', '기본 도메인 셀렉터'); ok(!existsSync(join(haraka, 'config', 'dkim', 'c.test')) && !existsSync(join(haraka, 'config', 'dkim', 'd.test')) && !existsSync(join(haraka, 'config', 'dkim', 'old.test')), '중지·키 없음·옛 도메인 디렉터리 없음'); ok(readFileSync(join(haraka, 'config', 'plugins'), 'utf8').includes('dkim'), 'dkim 플러그인 켜짐'); ok(!readFileSync(join(haraka, 'config', 'dkim.ini'), 'utf8').includes('domain=') && !existsSync(join(haraka, 'config', 'dkim.private.key')), '단일 도메인 폴백 없음(키 없는 도메인은 서명하지 않음)'); const status = JSON.parse(readFileSync(join(data, 'haraka-status.json'), 'utf8')); eq(status.domains.join(','), 'a.test,b.test', '상태 파일 domains'); eq(status.signers.join(','), 'a.test,b.test', '상태 파일 signers'); eq(status.dkim, true, 'dkim 표시'); } catch (e) { e.message += '\n--- run.mjs 출력:\n' + out; throw e; } finally { child.kill('SIGTERM'); await new Promise((r) => child.on('exit', r)); rmSync(dir, { recursive: true, force: true }); } }); /** IMAP 시험 클라이언트(test/imap.test.mjs 와 같은 최소 구현). */ function imapClient(port) { const socket = net.connect(port, '127.0.0.1'); let buffer = ''; const waiters = []; socket.setEncoding('latin1'); socket.on('data', (d) => { buffer += d; flush(); }); function flush() { while (waiters.length) { const w = waiters[0]; const re = w.tag ? new RegExp(`(^|\\r\\n)${w.tag} (OK|NO|BAD)[^\\r]*\\r\\n`) : /^\+ [^\r]*\r\n|^\* [^\r]*\r\n/; const m = buffer.match(re); if (!m) return; const end = m.index + m[0].length; waiters.shift(); w.resolve(buffer.slice(0, end)); buffer = buffer.slice(end); } } let n = 0; return { greeting: () => new Promise((resolve) => { waiters.push({ resolve }); flush(); }), cmd: (line) => { const tag = 'A' + (++n); socket.write(`${tag} ${line}\r\n`); return new Promise((resolve) => { waiters.push({ tag, resolve }); flush(); }); }, end: () => socket.end() }; } done();