/** * 1.0.8 시험: webmaker 인증 모드(server/webmaker.mjs · server/webmaker-snapshot.mjs)와 경로 접두사(/mail). * - 웹메이커 데이터 디렉터리(users.json · sites/ · modules/ · domains.json)를 흉내 내 만들고 스냅샷·도메인 ↔ 사이트 대응·계정 우선순위를 확인합니다. * - 웹메이커 계정으로 웹 로그인(관리자/일반)·IMAP 로그인·비밀번호 틀림·승인 취소·다른 도메인 주소 거부, 관리자 페이지의 읽기 전용 행, 비밀번호 변경 금지, * 로컬 계정과의 로그인 ID 충돌, 수신 주소 목록, 웹메이커 쪽 파일 변경(비밀번호 변경·계정 삭제)의 즉시 반영, 파일을 읽지 못할 때의 동작을 봅니다. * - 기본 도메인을 환경 변수(LOGIN_MODE=webmaker)로 webmaker 모드로 띄우는 경우와, 명령줄 도우미(webmaker-snapshot.mjs )의 출력도 확인합니다. * - 경로 접두사: "호스트/경로" 등록, resolveHost 의 prefix, API 의 인라인 이미지 주소·초안 저장 시 접두사 제거. * 실행: node test/webmaker.test.mjs (npm test 와 deploy.sh 가 함께 돌립니다) */ import net from 'node:net'; import { webmakerSocketPath } from '../server/webmaker.mjs'; import { execFileSync } from 'node:child_process'; import { scryptSync, randomBytes } from 'node:crypto'; import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, chmodSync } 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 } from '../server/config.mjs'; import { openTenants, normalizeEntry, normalizePrefix, splitHostEntry, loadRegistry } from '../server/domains.mjs'; import { readSnapshotSync } from '../server/webmaker-snapshot.mjs'; import { accountsFor, siteFor, verifyScrypt, clearSnapshotCache } from '../server/webmaker.mjs'; import { verifyUser, knownMailboxes, mailboxUser, ensureInitialAdmin, createUser, authMode, principalById } from '../server/auth.mjs'; import { createImap } from '../server/imap.mjs'; const { t, done } = runner(); const HERE = dirname(fileURLToPath(import.meta.url)), ROOT = join(HERE, '..'); const tmp = (p = 'arimail-wm-') => mkdtempSync(join(tmpdir(), p)); const api = (env, path, cookie, method, body, headers) => handle(env, request(path, cookie, method, body, headers), params(path)); const hash = (pw, salt) => scryptSync(pw, salt, 64).toString('hex'); const SITE_ID = 'a'.repeat(32), SITE2 = 'b'.repeat(32); /** 웹메이커 데이터 디렉터리 흉내: 회원 admin(관리자)·owner1(소유자)·guest(일반), 사이트 site.test(소유자 owner1, 사용자 user1·pending1·admin(회원)), 사이트 둘째(도메인 없음). */ function makeWebmaker(dir, { memberAdmin = false } = {}) { const u = (id, pw, role) => { const salt = randomBytes(16).toString('hex'); return { id, name: id + ' 이름', salt, passHash: hash(pw, salt), role, accountType: 'domain', createdAt: '2026-10-14T00:00:00Z' }; }; writeFileSync(join(dir, 'users.json'), JSON.stringify({ admin: u('admin', 'wm-admin-pw', 'admin'), owner1: u('owner1', 'owner-pw-1', 'user'), guest: u('guest', 'guest-pw-1', 'user') }, null, 2), { mode: 0o600 }); mkdirSync(join(dir, 'sites'), { recursive: true }); mkdirSync(join(dir, 'modules'), { recursive: true }); writeFileSync(join(dir, 'sites', SITE_ID + '.json'), JSON.stringify({ id: SITE_ID, ownerId: 'owner1', title: '시험 사이트', domains: ['site.test', 'www.site.test'], webmail: { enabled: true, domain: 'site.test' } })); writeFileSync(join(dir, 'sites', SITE2 + '.json'), JSON.stringify({ id: SITE2, ownerId: 'guest', title: '둘째', domains: [] })); const m = (id, loginId, pw, role, approved = true) => { const salt = randomBytes(16).toString('hex'); return { id, name: loginId + ' 님', email: '', loginId, salt, hash: hash(pw, salt), role, approved, createdAt: '2026-10-14T00:00:00Z' }; }; const members = [m('m1', 'user1', 'user1-pw-1', 'admin'), m('m2', 'pending1', 'pending-pw', 'admin', false), { id: 'm3', name: '방문자', email: 'visitor@example.test', salt: 'x', hash: 'y', role: 'member', approved: true }, m('m4', 'Bad Login!', 'bad-pw-1', 'admin')]; if (memberAdmin) members.push(m('m5', 'admin', 'member-admin-pw', 'admin')); writeFileSync(join(dir, 'modules', SITE_ID + '.json'), JSON.stringify({ enabled: { members: true }, settings: {}, members, posts: [] })); writeFileSync(join(dir, 'domains.json'), JSON.stringify({ domains: [{ id: 'd1', userId: 'owner1', name: 'sub.platform.test', type: 'subdomain', status: 'active', siteId: SITE_ID }], contacts: {} })); return dir; } async function base(over = {}) { const dir = tmp(); const env = await makeEnv({ DB_FILE: join(dir, 'mail-gateway.sqlite'), PUBLIC_HOST: 'mail.arihakdang.com', ...over }); loadConfig(env); return { env, dir }; } const open = (env) => openTenants(env, { migrationsDir: join(ROOT, 'migrations'), log: { log() {}, error() {} } }); const cookieOf = (res) => res.headers.get('set-cookie').split(';')[0]; /** 테넌트 추가 + 웹메이커 모드. 시험마다 새 캐시로 시작합니다. */ async function tenantWithWebmaker(wmDir, extra = {}) { clearSnapshotCache(); const { env } = await base(); const tenants = open(env); const r = await tenants.add({ domain: 'site.test', hosts: 'site.test/mail', auth: { mode: 'webmaker', webmakerDir: wmDir, ...extra } }); return { env, tenants, site: tenants.byDomain('site.test').env, entry: r.entry }; } await t('스냅샷: 웹메이커 파일을 읽어 회원·사이트·회원(로그인 ID 있는)·도메인 연결을 모으고, 명령줄 도우미도 같은 JSON 을 낸다', async () => { const dir = makeWebmaker(tmp()); const snap = readSnapshotSync(dir); eq(Object.keys(snap.users).sort().join(','), 'admin,guest,owner1', '회원'); eq(snap.users.admin.role, 'admin', '관리자 역할'); ok(snap.users.admin.passHash.length === 128, 'scrypt 해시'); eq(snap.sites[SITE_ID].domains.join(','), 'site.test,site.test', 'www. 는 뗌'); eq(snap.sites[SITE_ID].webmailDomain, 'site.test', '메일 도메인'); eq(snap.members[SITE_ID].length, 4, 'loginId/email 있는 회원만(해시 없는 방문자 제외 아님 — salt/hash 있어야)'); eq(snap.members[SITE_ID].find((m) => m.id === 'm2').approved, false, '승인 안 됨'); eq(snap.domainSites['sub.platform.test'], SITE_ID, '서브 도메인 → 사이트'); const cli = JSON.parse(execFileSync(process.execPath, [join(ROOT, 'server', 'webmaker-snapshot.mjs'), dir], { encoding: 'utf8' })); eq(JSON.stringify({ ...cli, readAt: 0 }), JSON.stringify({ ...snap, readAt: 0 }), '도우미 출력 = 모듈 결과'); let code = 0; try { execFileSync(process.execPath, [join(ROOT, 'server', 'webmaker-snapshot.mjs'), join(dir, 'nope')], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { code = e.status; } eq(code, 0, '없는 디렉터리도 빈 스냅샷(오류 아님)'); // 파일이 없으면 각 항목이 비어 있을 뿐 실패하지 않습니다(웹메이커가 아직 아무것도 안 만든 서버). ok(await verifyScrypt('wm-admin-pw', snap.users.admin.salt, snap.users.admin.passHash) && !(await verifyScrypt('wrong', snap.users.admin.salt, snap.users.admin.passHash)) && !(await verifyScrypt('x', '', '')), 'scrypt 검사'); }); await t('도메인 ↔ 사이트 대응과 계정 우선순위: 사이트 회원(loginId) → 소유자 → 웹메이커 관리자, 사이트가 없으면 모든 회원', async () => { const dir = makeWebmaker(tmp(), { memberAdmin: true }); const snap = readSnapshotSync(dir); eq(siteFor(snap, 'site.test')?.id, SITE_ID, '도메인 목록으로'); eq(siteFor(snap, 'sub.platform.test')?.id, SITE_ID, '서브 도메인 등록으로'); eq(siteFor(snap, 'x.test', SITE2)?.id, SITE2, 'siteId 지정'); eq(siteFor(snap, 'none.test'), null, '없음'); const acc = accountsFor({ MAILBOX: 'admin@site.test' }, snap); const by = Object.fromEntries(acc.map((a) => [a.login, a])); eq(Object.keys(by).sort().join(','), 'admin,owner1,pending1,user1', '계정 목록(승인 안 된 회원은 목록에 있되 로그인 불가, 잘못된 로그인 ID 는 제외)'); eq(by.admin.kind, 'member', '사이트 회원 admin 이 플랫폼 관리자 admin 보다 우선'); eq(by.admin.role, 'user', '사이트 회원은 일반 역할'); eq(by.owner1.kind, 'owner', '소유자'); eq(by.owner1.role, 'user', '소유자는 일반(웹메이커 관리자만 관리자)'); eq(by.user1.mailbox, 'user1@site.test', '메일함'); ok(!acc.some((a) => a.login === 'guest'), '사이트 도메인에는 일반 회원(guest) 없음'); const acc2 = accountsFor({ MAILBOX: 'admin@platform.test' }, snap); eq(acc2.map((a) => a.login).sort().join(','), 'admin,guest,owner1', '사이트 없는 도메인: 모든 회원'); eq(acc2.find((a) => a.login === 'admin').role, 'admin', '플랫폼 관리자'); eq(acc2.find((a) => a.login === 'guest').role, 'user', '일반 회원'); const acc3 = accountsFor({ MAILBOX: 'admin@site.test' }, readSnapshotSync(makeWebmaker(tmp()))); eq(acc3.find((a) => a.login === 'admin').kind, 'admin', '회원 admin 이 없으면 플랫폼 관리자가 admin@site.test'); eq(acc3.find((a) => a.login === 'pending1').approved, false, '승인 안 된 회원'); }); await t('웹 로그인: 웹메이커 관리자(admin)·소유자·사이트 사용자가 같은 비밀번호로 들어오고, 틀린 비밀번호·승인 취소·다른 도메인 주소는 거부, 로컬 계정도 함께 쓴다', async () => { const dir = makeWebmaker(tmp()); const { env, tenants, site } = await tenantWithWebmaker(dir); eq(authMode(site), 'webmaker', '모드'); eq(site.LOGIN_MODE, 'webmaker', 'env.LOGIN_MODE'); eq(site.WEBMAKER_DIR, dir, 'env.WEBMAKER_DIR'); eq(site.PUBLIC_HOST, 'site.test', '호스트'); eq(site.PUBLIC_PATH, '/mail', '경로 접두사'); ok(!(await ensureInitialAdmin(site)), 'webmaker 모드는 초기 관리자(adminpasswd)를 만들지 않음'); eq(Number((await site.DB.prepare('SELECT COUNT(*) n FROM mail_users').first()).n), 0, '로컬 계정 0'); const login = async (l, p, scope) => api(site, 'login', '', 'POST', { login: l, password: p }, scope ? { 'X-Mail-Scope': scope } : {}); let r = await login('admin', 'wm-admin-pw'); eq(r.status, 200, '관리자 로그인'); const adminCookie = cookieOf(r); let s = await (await api(site, 'session', adminCookie)).json(); eq(s.user.source, 'webmaker', '출처'); eq(s.user.role, 'admin', '역할'); eq(s.user.mailbox, 'admin@site.test', '메일함'); eq(s.user.id, 'wm:u:admin', '주체 id'); eq(s.authMode, 'webmaker', 'authMode'); eq(s.initialPassword, false, '초기 비밀번호 경고 없음'); r = await login('user1@site.test', 'user1-pw-1'); eq(r.status, 200, '사이트 사용자(주소로) 로그인'); const userCookie = cookieOf(r); s = await (await api(site, 'session', userCookie)).json(); eq(s.user.role, 'user', '일반'); eq(s.user.id, `wm:m:${SITE_ID}:m1`, '회원 주체 id'); eq(s.user.name, 'user1 님', '이름'); eq((await login('owner1', 'owner-pw-1')).status, 200, '소유자 로그인'); eq((await login('owner1', 'owner-pw-1', 'admin')).status, 403, '소유자는 관리자 페이지 불가(최소 권한)'); eq((await login('user1', 'user1-pw-1', 'admin')).status, 403, '사이트 사용자는 관리자 페이지 불가'); eq((await login('user1', 'wrong-pw-1')).status, 401, '틀린 비밀번호'); eq((await login('pending1', 'pending-pw')).status, 401, '승인 안 된 회원'); eq((await login('user1@other.test', 'user1-pw-1')).status, 401, '다른 도메인 주소'); eq((await login('guest', 'guest-pw-1')).status, 401, '사이트 도메인에 없는 회원'); eq((await login('bad login!', 'bad-pw-1')).status, 401, '메일 주소로 쓸 수 없는 로그인 ID 는 제외'); // 로컬 계정: 웹메이커와 겹치는 ID 는 거부, 다른 ID 는 만들어 함께 로그인 let err = null; try { await createUser(site, { login: 'admin', password: 'local-pw-12' }, 'test'); } catch (e) { err = e.message; } eq(err, 'login_taken', '웹메이커 계정과 겹치는 로컬 계정 거부'); await createUser(site, { login: 'extra', password: 'extra-pw-12', name: '추가' }, 'test'); eq((await login('extra', 'extra-pw-12')).status, 200, '로컬 계정 로그인'); eq((await verifyUser(site, 'extra', 'extra-pw-12'))?.source, 'local', '로컬 출처'); // 수신 주소·메일함 주인 const known = await knownMailboxes(site); ok(['admin@site.test', 'owner1@site.test', 'user1@site.test', 'pending1@site.test', 'extra@site.test'].every((m) => known.includes(m)), '수신 주소: ' + known.join(',')); eq((await mailboxUser(site, 'user1@site.test'))?.id, `wm:m:${SITE_ID}:m1`, '메일함 주인(자동 처리용)'); eq((await mailboxUser(site, 'extra@site.test'))?.source, 'local', '로컬 메일함 주인'); // 설정 화면·비밀번호 변경 const st = await (await api(site, 'settings', userCookie)).json(); eq(st.canChangePassword, false, '비밀번호 변경 불가'); eq(st.managedBy, 'webmaker', '관리 주체'); eq(st.smtp.host, 'mail.arihakdang.com', '메일 서버 이름은 기본 도메인'); r = await api(site, 'password', userCookie, 'PATCH', { currentPassword: 'user1-pw-1', password: 'new-pw-123456' }); eq(r.status, 403, '비밀번호 변경 거부'); eq((await r.json()).error, 'webmaker_managed', '오류 코드'); // 기본 도메인에는 웹메이커 계정이 없다(도메인마다 따로) eq((await api(env, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 401, '기본 도메인(www 모드)에서는 웹메이커 계정 없음'); eq((await tenants.list()).find((d) => d.domain === 'site.test').external.count, 4, '목록의 웹메이커 계정 수(승인 안 된 회원 포함)'); tenants.close(); }); await t('관리자 페이지: 웹메이커 계정은 읽기 전용 행(external)으로 보이고 POP/IMAP 허용·메일함 크기만 바꿀 수 있으며 삭제·이름 변경은 거부된다', async () => { const dir = makeWebmaker(tmp()); const { tenants, site } = await tenantWithWebmaker(dir); const r = await api(site, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' }, { 'X-Mail-Scope': 'admin' }); eq(r.status, 200, '관리자 로그인'); const ck = cookieOf(r); const H = { 'X-Mail-Scope': 'admin' }; let d = await (await api(site, 'admin/users', ck, 'GET', undefined, H)).json(); eq(d.authMode, 'webmaker', 'authMode'); eq(d.me, null, 'me 행 없음(웹메이커 관리자는 external 에)'); eq(d.users.length, 0, '로컬 계정 없음'); eq(d.external.map((u) => u.login).sort().join(','), 'admin,owner1,pending1,user1', '외부 계정 행'); eq(d.externalSource.kind, 'webmaker', '출처'); eq(d.externalSource.ok, true, '읽기 성공'); eq(d.externalSource.site.id, SITE_ID, '사이트'); eq(d.external.find((u) => u.login === 'owner1').role, 'user', '소유자 역할'); eq(d.external.find((u) => u.login === 'admin').role, 'admin', '관리자 역할'); const u1 = d.external.find((u) => u.login === 'user1'); eq(u1.status, 'active', '상태'); eq(u1.kind, 'member', '종류'); eq(u1.services.pop, true, '기본 POP 허용'); eq(u1.quotaBytes, 0, '기본 크기'); eq(d.external.find((u) => u.login === 'pending1').status, 'suspended', '승인 안 된 회원은 정지로 표시'); // 허용·크기 저장 // 경로의 ':' 는 main.mjs 가 decodeURIComponent 로 풀어 주므로 여기서는 id 를 그대로 씁니다(시험 도우미 params 는 디코딩하지 않음). let p = await api(site, 'admin/users/' + u1.id, ck, 'PATCH', { services: { pop: false, imap: true }, quotaBytes: 5 * 1048576, name: '바꿈', password: 'nope' }, H); eq(p.status, 200, 'PATCH: ' + await p.text()); d = await (await api(site, 'admin/users', ck, 'GET', undefined, H)).json(); const u2 = d.external.find((u) => u.login === 'user1'); eq(u2.services.pop, false, 'POP 끔'); eq(u2.quotaBytes, 5 * 1048576, '크기 저장'); eq(u2.limit, 5 * 1048576, '적용 크기(mailboxQuota 가 wm: id 를 찾음)'); eq(u2.name, 'user1 님', '이름은 그대로(웹메이커가 주인)'); eq((await api(site, 'admin/users/' + u1.id, ck, 'DELETE', undefined, H)).status, 403, '삭제 거부'); eq((await api(site, 'admin/users/wm:u:nobody', ck, 'PATCH', { services: { pop: true } }, H)).status, 404, '없는 외부 계정'); // POP 을 끈 계정의 설정은 메일 프로그램 로그인 뒤 검사에 쓰입니다(serviceAllowed) — prefs 가 wm: id 로 저장됐는지 eq(JSON.parse((await site.DB.prepare("SELECT value FROM settings WHERE key=?").bind('prefs:' + u1.id).first()).value).pop, false, 'prefs:wm:… 저장'); tenants.close(); }); await t('IMAP 로그인: 웹메이커 계정이 @도메인으로 자기 테넌트를 고르고 같은 비밀번호를 쓴다(웹·POP·SMTP 와 같은 verifyUser)', async () => { const dir = makeWebmaker(tmp()); const { env, tenants } = await tenantWithWebmaker(dir); 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('user1@site.test', 'user1-pw-1'); ok(/OK/.test(r), '사이트 사용자 IMAP 로그인: ' + r.trim()); r = await c.cmd('SELECT INBOX'); ok(/OK/.test(r), 'SELECT'); c.end(); ({ c, r } = await session('admin@site.test', 'wm-admin-pw')); ok(/OK/.test(r), '웹메이커 관리자 IMAP 로그인'); c.end(); ({ c, r } = await session('admin@site.test', 'admin-secret-12')); ok(/NO/.test(r), '기본 도메인 비밀번호는 거부'); c.end(); ({ c, r } = await session('admin', 'wm-admin-pw')); ok(/NO/.test(r), '도메인 없는 ID 는 기본 도메인 → 거부'); c.end(); } finally { imap.close(); tenants.close(); } }); await t('웹메이커 쪽 변경의 즉시 반영: 비밀번호를 바꾸면 새 비밀번호로, 회원을 지우면 세션이 끝나며, 파일을 읽지 못하면 마지막 스냅샷으로 버틴다', async () => { const dir = makeWebmaker(tmp()); const { tenants, site } = await tenantWithWebmaker(dir); const r = await api(site, 'login', '', 'POST', { login: 'user1', password: 'user1-pw-1' }); eq(r.status, 200, '로그인'); const ck = cookieOf(r); // 비밀번호 변경(웹메이커가 파일을 다시 씀) → 캐시가 새것이어도 실패 시 한 번 다시 읽으므로 바로 통과 const mod = JSON.parse(readFileSync(join(dir, 'modules', SITE_ID + '.json'), 'utf8')); const m1 = mod.members.find((m) => m.id === 'm1'); m1.salt = randomBytes(16).toString('hex'); m1.hash = hash('changed-pw-2', m1.salt); writeFileSync(join(dir, 'modules', SITE_ID + '.json'), JSON.stringify(mod)); eq((await api(site, 'login', '', 'POST', { login: 'user1', password: 'changed-pw-2' })).status, 200, '새 비밀번호'); eq((await api(site, 'login', '', 'POST', { login: 'user1', password: 'user1-pw-1' })).status, 401, '옛 비밀번호 거부'); ok((await (await api(site, 'session', ck)).json()).user, '세션은 유지(비밀번호 변경은 웹메이커 쪽 세대 관리)'); // 회원 삭제 → 주체 조회 실패 → 세션 종료 mod.members = mod.members.filter((m) => m.id !== 'm1'); writeFileSync(join(dir, 'modules', SITE_ID + '.json'), JSON.stringify(mod)); clearSnapshotCache(); eq((await (await api(site, 'session', ck)).json()).user, null, '지운 회원의 세션 무효'); eq(await principalById(site, `wm:m:${SITE_ID}:m1`), null, 'principalById null'); // 파일을 못 읽게 되면(쓰는 중의 반쪽 JSON 등): 마지막 스냅샷으로 계속(로그인·세션 유지), 새 프로세스라면(캐시 없음) 로그인 실패 + 관리자 목록에 오류 표시 const ar = await api(site, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' }); eq(ar.status, 200, '정상 로그인'); const adminCk = cookieOf(ar); const usersFile = join(dir, 'users.json'), goodUsers = readFileSync(usersFile, 'utf8'); writeFileSync(usersFile, '{broken json'); await new Promise((r) => setTimeout(r, 50)); eq((await api(site, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 200, '깨진 users.json 이어도 마지막 스냅샷으로 로그인(캐시 3초 안)'); ok((await (await api(site, 'session', adminCk)).json()).user, '세션 유지'); clearSnapshotCache(); eq((await api(site, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 401, '스냅샷이 전혀 없으면(새 프로세스) 로그인 실패'); eq((await (await api(site, 'session', adminCk)).json()).user, null, '그 요청은 비로그인으로 처리되지만…'); writeFileSync(usersFile, goodUsers); clearSnapshotCache(); ok((await (await api(site, 'session', adminCk)).json()).user, '…세션은 지워지지 않아 파일이 돌아오면 다시 로그인 상태(일시 장애로 취급)'); writeFileSync(usersFile, '{broken json'); clearSnapshotCache(); // 디렉터리 자체를 못 읽는 경우(sudo 도우미 없이 권한 없음)는 WEBMAKER_USER 로 도우미를 부르는데, 시험 환경에는 sudo 규칙이 없으므로 오류 → 캐시 없으면 로그인 실패·목록 오류 const { tenants: t2, site: s2 } = await tenantWithWebmaker(dir, { webmakerUser: 'nobody-arimail-test' }); eq((await api(s2, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 401, '도우미 실패 → 로그인 불가(죽지 않음)'); const lr = await api(s2, 'login', '', 'POST', { login: 'extra', password: 'x' }); eq(lr.status, 401, '로컬 조회로 넘어감'); const list = await t2.list(); ok(list.find((d) => d.domain === 'site.test').external.error, '목록에 읽기 오류 표시: ' + JSON.stringify(list[1].external)); t2.close(); tenants.close(); }); await t('소켓 도우미: 읽을 계정이 있고 소켓 파일이 있으면 그 소켓으로 스냅샷 JSON 을 받는다(systemd Accept=yes 흉내), 끊기면 오류', async () => { const dir = makeWebmaker(tmp()); const sockPath = join(tmp('arimail-sock-'), 's.sock'); eq(webmakerSocketPath('/data/webmaker'), '/run/arimail-webmaker/data-webmaker.sock', '소켓 경로 규칙(webmaker-access.sh 와 같음)'); // 접속마다 스냅샷을 써 주고 끊는 서버(도우미 서비스와 같은 동작) const server = net.createServer((c) => { c.end(JSON.stringify(readSnapshotSync(dir))); }); await new Promise((r) => server.listen(sockPath, r)); clearSnapshotCache(); const { env } = await base(); const tenants = open(env); await tenants.add({ domain: 'site.test', auth: { mode: 'webmaker', webmakerDir: dir, webmakerUser: 'master' } }); const site = tenants.byDomain('site.test').env; site.WEBMAKER_SOCKET = sockPath; eq((await api(site, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 200, '소켓으로 읽은 계정으로 로그인'); eq((await tenants.list()).find((d) => d.domain === 'site.test').external.count, 4, '목록도 소켓으로'); server.close(); clearSnapshotCache(); eq((await api(site, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 401, '서버가 없으면(캐시 없음) 로그인 실패'); tenants.close(); }); await t('등록부 검증: webmaker 는 디렉터리 필수(읽을 수 없으면 webmaker_dir_unreadable, 읽을 계정을 적으면 검사 생략), 사이트 id·계정 이름 형식, 수정으로 방식 바꾸기', async () => { const dir = makeWebmaker(tmp()); const bad = (body, field) => { let e = null; try { normalizeEntry(body); } catch (x) { e = x; } ok(e && (e.field === field), `${field}: ${e && e.message}/${e && e.field}`); return e; }; bad({ domain: 'x.test', auth: { mode: 'webmaker' } }, 'auth.webmakerDir'); bad({ domain: 'x.test', auth: { mode: 'webmaker', webmakerDir: 'relative/dir' } }, 'auth.webmakerDir'); eq(bad({ domain: 'x.test', auth: { mode: 'webmaker', webmakerDir: join(dir, 'nope') } }, 'auth.webmakerDir').message, 'webmaker_dir_unreadable', '없는 디렉터리'); bad({ domain: 'x.test', auth: { mode: 'webmaker', webmakerDir: dir, siteId: 'short' } }, 'auth.siteId'); bad({ domain: 'x.test', auth: { mode: 'webmaker', webmakerDir: dir, webmakerUser: 'Bad User' } }, 'auth.webmakerUser'); const e1 = normalizeEntry({ domain: 'x.test', auth: { mode: 'webmaker', webmakerDir: join(dir, 'nope'), webmakerUser: 'master' } }); eq(e1.auth.webmakerUser, 'master', '읽을 계정을 적으면 권한 검사 생략'); eq(e1.auth.wwwDbFile, '', 'www 값 없음'); const e2 = normalizeEntry({ domain: 'x.test', auth: { mode: 'webmaker', webmakerDir: dir + '/', siteId: SITE_ID.toUpperCase() } }); eq(e2.auth.webmakerDir, dir, '끝 슬래시 제거'); eq(e2.auth.siteId, SITE_ID, '소문자'); // 수정으로 local → webmaker → local clearSnapshotCache(); const { env } = await base(); const tenants = open(env); await tenants.add({ domain: 'y.test', adminPassword: 'ypw-12345' }); const y = tenants.byDomain('y.test').env; eq(y.LOGIN_MODE, 'standalone', '처음 local'); tenants.update('y.test', { auth: { mode: 'webmaker', webmakerDir: dir } }); eq(y.LOGIN_MODE, 'webmaker', '바꾼 뒤'); eq(y.WEBMAKER_DIR, dir, '디렉터리'); eq((await api(y, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 200, '웹메이커 admin 로그인(사이트 없는 도메인 → 모든 회원)'); eq((await api(y, 'login', '', 'POST', { login: 'admin', password: 'ypw-12345' })).status, 200, '초기 로컬 admin 도 여전히 로그인(웹메이커 → 로컬 순)'); tenants.update('y.test', { auth: { mode: 'local' } }); eq(y.LOGIN_MODE, 'standalone', '되돌림'); eq(y.WEBMAKER_DIR, undefined, '디렉터리 지움'); eq((await api(y, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 401, '웹메이커 계정 더는 없음'); const reg = loadRegistry(env); eq(reg.domains['y.test'].auth.mode, 'local', '등록부 저장'); tenants.close(); }); await t('기본 도메인을 환경 파일로 webmaker 모드로: LOGIN_MODE=webmaker + WEBMAKER_DIR → 등록부 auth 와 로그인이 웹메이커 계정', async () => { const dir = makeWebmaker(tmp()); clearSnapshotCache(); const { env } = await base({ WWW_DB: undefined, MAILBOX: 'admin@site.test', PUBLIC_HOST: 'site.test', PUBLIC_PATH: 'mail', LOGIN_MODE: 'webmaker', WEBMAKER_DIR: dir }); const tenants = open(env); const reg = loadRegistry(env); eq(reg.domains['site.test'].auth.mode, 'webmaker', '등록부 방식'); eq(reg.domains['site.test'].auth.webmakerDir, dir, '디렉터리'); eq(reg.domains['site.test'].hosts[0], 'site.test/mail', '기본 호스트에 경로'); eq(env.PUBLIC_PATH, '/mail', 'PUBLIC_PATH 정규화'); eq(tenants.resolveHost('site.test').prefix, '/mail', 'resolveHost prefix'); eq(tenants.resolveHost('site.test').tenant.env, env, '기본 도메인'); ok(!(await ensureInitialAdmin(env)), '초기 관리자 없음'); eq((await api(env, 'login', '', 'POST', { login: 'admin', password: 'wm-admin-pw' })).status, 200, '웹메이커 admin'); eq((await api(env, 'login', '', 'POST', { login: 'user1@site.test', password: 'user1-pw-1' })).status, 200, '사이트 사용자'); // 기본 도메인 수정: 첫 호스트(경로 포함)는 환경 파일이 고정 tenants.update('site.test', { hosts: 'other.test' }); eq(tenants.registry.domains['site.test'].hosts.join(','), 'site.test/mail,other.test', '첫 호스트 고정'); tenants.close(); }); await t('경로 접두사: 호스트/경로 등록·정규화, 같은 호스트의 다른 경로 중복 거부, API 의 인라인 이미지 주소와 초안 저장 시 접두사 제거', async () => { eq(normalizePrefix('/mail'), '/mail', '기본'); eq(normalizePrefix('mail/'), '/mail', '슬래시 보정'); eq(normalizePrefix('/a/b/'), '/a/b', '여러 단계'); eq(normalizePrefix('/'), '', '루트'); eq(normalizePrefix(''), '', '빈 값'); eq(normalizePrefix('/../x'), null, '상위 경로'); eq(normalizePrefix('/sp ace'), null, '허용 글자 밖'); eq(JSON.stringify(splitHostEntry('Site.Test/Mail')), JSON.stringify({ host: 'site.test', prefix: '/mail' }), '나누기(전체 소문자)'); eq(splitHostEntry('mail.x.com').prefix, '', '경로 없음'); const e = normalizeEntry({ domain: 'p.test', hosts: 'p.test/mail, mail.p.test' }); eq(e.hosts.join(','), 'p.test/mail,mail.p.test', '호스트 항목'); let err = null; try { normalizeEntry({ domain: 'p.test', hosts: 'p.test/mail, p.test' }); } catch (x) { err = x; } eq(err?.field, 'hosts', '같은 호스트의 다른 경로 거부'); err = null; try { normalizeEntry({ domain: 'p.test', hosts: 'p.test/../x' }); } catch (x) { err = x; } eq(err?.field, 'hosts', '잘못된 경로'); const { env } = await base(); const tenants = open(env); await tenants.add({ domain: 'p.test', hosts: 'p.test/mail', adminPassword: 'ppw-12345' }); eq(tenants.resolveHost('p.test').prefix, '/mail', 'prefix'); eq(tenants.resolveHost('P.TEST:443').tenant.entry.domain, 'p.test', '호스트 매칭'); eq(tenants.resolveHost('mail.arihakdang.com').prefix, '', '기본 도메인은 접두사 없음'); let err2 = null; try { await tenants.add({ domain: 'q.test', hosts: 'p.test/other', adminPassword: 'qpw-12345' }); } catch (x) { err2 = x; } eq(err2?.message, 'host_taken', '다른 도메인이 같은 호스트(경로만 다름) 사용 불가'); const p = tenants.byDomain('p.test').env; eq(p.PUBLIC_HOST, 'p.test', 'PUBLIC_HOST 는 이름만'); eq(p.PUBLIC_PATH, '/mail', 'PUBLIC_PATH'); // API: prefix 를 넘기면 인라인 이미지 주소가 /mail/api/… 로 나오고, 저장할 때는 /api/… 로 되돌린다 const r = await api(p, 'login', '', 'POST', { login: 'admin', password: 'ppw-12345' }); const ck = cookieOf(r); const withPrefix = (path, method, body) => handle(p, request(path, ck, method, body), { ...params(path), prefix: '/mail' }); const draft = await (await withPrefix('mail', 'POST', { to: 'a@b.test', subject: 'x', html: '

hi

' })).json(); const stored = await p.DB.prepare('SELECT html FROM messages WHERE id=?').bind(draft.id).first(); ok(stored.html.includes('src="/api/mail/abc/attachments/def?inline=1"') && !stored.html.includes('/mail/api/'), '저장된 본문은 접두사 없음: ' + stored.html); const png = new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }); const fd = new FormData(); fd.append('files', png, 'dot.png'); const up = await (await withPrefix(`mail/${draft.id}/attachments?inline=1`, 'POST', fd)).json(); ok(up.urls[0].startsWith('/mail/api/mail/'), '올린 인라인 이미지 주소에 접두사: ' + up.urls[0]); const msg = await (await withPrefix(`mail/${draft.id}`, 'GET')).json(); ok(msg.message.previewHtml.includes('src="/mail/api/mail/abc/'), '미리 보기 본문의 저장된 인라인 이미지 주소에도 접두사: ' + msg.message.previewHtml.slice(0, 120)); const plain = await (await api(p, `mail/${draft.id}`, ck)).json(); ok(!plain.message.previewHtml.includes('/mail/api/') && plain.message.previewHtml.includes('src="/api/mail/abc/'), '접두사 없는 요청은 접두사 없음'); // PATCH(초안 수정)도 접두사를 떼고 저장하며, 요청에 접두사가 없어도 도메인의 PUBLIC_PATH(/mail)는 뗀다 eq((await withPrefix(`mail/${draft.id}`, 'PATCH', { html: '

two

' })).status, 200, 'PATCH'); ok((await p.DB.prepare('SELECT html FROM messages WHERE id=?').bind(draft.id).first()).html.includes('src="/api/mail/abc/'), 'PATCH 저장도 접두사 없음'); eq((await api(p, `mail/${draft.id}`, ck, 'PATCH', { html: '

three

' })).status, 200, 'PATCH(접두사 헤더 없음)'); ok((await p.DB.prepare('SELECT html FROM messages WHERE id=?').bind(draft.id).first()).html.includes('src="/api/mail/abc/'), 'PUBLIC_PATH 로도 뗌'); // 로그인 응답의 쿠키 Path 는 접두사 ok(/Path=\/mail(;|$)/.test(String((await withPrefix('login', 'POST', { login: 'admin', password: 'ppw-12345' })).headers.get('set-cookie'))), '쿠키 Path=/mail'); tenants.close(); }); /** 시험용 IMAP 클라이언트(domains.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();