Files
AriWeb-Suite/modules/arimail/test/webmaker.test.mjs
T

291 lines
33 KiB
JavaScript

/**
* 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 <dir>)의 출력도 확인합니다.
* - 경로 접두사: "호스트/경로" 등록, 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: '<p>hi <img src="/mail/api/mail/abc/attachments/def?inline=1"></p>' })).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: '<p><img src="/mail/api/mail/abc/attachments/def?inline=1"> two</p>' })).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: '<p><img src="/mail/api/mail/abc/attachments/def?inline=1"> three</p>' })).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();