Ariweb Suite 0.1.1 기반: 공통 라이브러리(db·http·세션·작업 엔진·할당/예약·알림·감사·보관소), 포털 뼈대, Webmaker/AriMail 도입 소스와 manifest

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:02:58 +00:00
co-authored by Claude Fable 5.1
parent 9ca4cee341
commit c7aea8d419
295 changed files with 90146 additions and 0 deletions
+280
View File
@@ -0,0 +1,280 @@
'use strict';
// v1.6.38 (요구 2) 메일 서버(Arimail) 등록부 — 다른 서버에 설치된 Arimail 여러 대를 등록하고 사이트의 메일 도메인에 맞는 서버를 고르는지.
// 가짜 Arimail 두 대(node http)를 띄워 로그인·계정 목록·계정 생성이 "맞는 서버" 로 가는지 확인한다. 비밀번호가 등록부 파일·화면용 응답에 남지 않는 것도 본다.
const test = require('node:test'); const assert = require('node:assert/strict');
const fs = require('fs'); const path = require('path'); const os = require('os'); const http = require('http');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-mailsrv-'));
process.env.DATA_ROOT = tmp; process.env.DATA_DIR = path.join(tmp, 'sites'); process.env.SITES_ROOT = path.join(tmp, 'www');
fs.mkdirSync(tmp, { recursive: true }); fs.writeFileSync(path.join(tmp, 'session.secret'), 'a'.repeat(64));
/**
* 가짜 Arimail: domain 하나, 관리자 계정 둘(wm-admin = 연동 계정, root = 초기 관리자). 받은 요청을 기록한다.
* v1.6.39: PATCH(비밀번호·역할·상태 변경), 로그인 잠금 흉내(lockAll → 429 rate_limited), 약한 비밀번호 거절(rejectWeak) 을 지원해
* 비밀번호 동기화·초기화·되돌리기·잠금 처리를 검사할 수 있게 한다. 로그인은 users 의 admin 역할 계정 비밀번호로 검사한다(비밀번호를 바꾸면 새 값으로만 로그인).
*/
function fakeArimail(domain, adminPw) {
const users = [{ id: 'adm', login: 'wm-admin', name: 'Webmaker', mailbox: `wm-admin@${domain}`, role: 'admin', status: 'active', password: adminPw }, { id: 'root', login: 'root', name: 'Root', mailbox: `root@${domain}`, role: 'admin', status: 'active', password: 'RootPw!1' }];
const log = []; const ctl = { lockAll: false, rejectWeak: false, usageKey: null /* v1.7.3: 사용량 API 키(없으면 API 꺼짐 404) */, usageCalls: 0 };
// v1.7.3: 가짜 사용량 보고서 — 문서(AriMail docs/USAGE-API.md)의 모양을 따른다
const usageReport = (full) => ({ domain, primary: true, enabled: true, hosts: [`webmail.${domain}`], authMode: 'webmaker', timezone: 'Asia/Seoul', generatedAt: new Date().toISOString(),
accounts: { local: 2, external: 1, total: 3, limit: 0, percent: null }, storage: { bytes: 123456789, limit: 0, percent: null, largeFiles: { count: 1, bytes: 1000, sent: 1 } }, messages: { total: 420, inbound: 300, outbound: 110, drafts: 5, failed: 5 },
traffic: { day: { sent: 3, received: 9, total: 12, sentBytes: 3000, receivedBytes: 9000 }, week: { sent: 10, received: 40, total: 50, sentBytes: 1, receivedBytes: 2 }, month: { sent: 31, received: 88, total: 119, sentBytes: 310000, receivedBytes: 880000 }, monthlyLimit: 0, monthlyPercent: null },
...(full ? { period: { days: 30 }, daily: [{ date: '2026-10-23', sent: 1, received: 2, sentBytes: 10, receivedBytes: 20 }, { date: '2026-10-24', sent: 2, received: 7, sentBytes: 20, receivedBytes: 70 }],
accountList: [{ login: 'wm-admin', name: 'Webmaker', mailbox: `wm-admin@${domain}`, role: 'admin', source: 'local', status: 'active', messages: 10, bytes: 1000, limit: 0, percent: null, lastLoginAt: null, sent: 1, received: 2, sentBytes: 1, receivedBytes: 2 },
{ login: 'kim', name: 'Kim', mailbox: `kim@${domain}`, role: 'user', source: 'webmaker', status: 'active', messages: 200, bytes: 100000000, limit: 0, percent: null, lastLoginAt: null, sent: 20, received: 60, sentBytes: 2, receivedBytes: 3 },
{ login: 'other', name: 'Other', mailbox: `other@${domain}`, role: 'user', source: 'webmaker', status: 'active', messages: 210, bytes: 23455789, limit: 0, percent: null, lastLoginAt: null, sent: 10, received: 26, sentBytes: 3, receivedBytes: 4 }] } : {}) });
const srv = http.createServer((req, res) => {
let body = ''; req.on('data', (c) => { body += c; }); req.on('end', () => {
const j = body ? JSON.parse(body) : {}; log.push({ m: req.method, u: req.url, cookie: req.headers.cookie || '' });
const send = (code, o, h) => { res.writeHead(code, { 'Content-Type': 'application/json', ...(h || {}) }); res.end(JSON.stringify(o)); };
if (req.url === '/api/health') return send(200, { ok: true, version: ctl.version || '1.0.9-test' });
// v1.7.3: 사용량 API — Bearer 키, 키가 없으면 404 usage_api_disabled, 틀리면 401 unauthenticated (세션 쿠키와 무관)
if (/^\/api\/usage(\/|\?|$)/.test(req.url) && req.method === 'GET') {
ctl.usageCalls++; if (!ctl.usageKey) return send(404, { ok: false, error: 'usage_api_disabled' });
const given = (req.headers.authorization || '').replace(/^Bearer\s+/i, ''); if (given !== ctl.usageKey) return send(401, { ok: false, error: 'unauthenticated' });
const m2 = req.url.match(/^\/api\/usage\/([^?]+)/); if (m2) { if (decodeURIComponent(m2[1]) !== domain) return send(404, { ok: false, error: 'domain_not_found' }); return send(200, { ok: true, version: '1.0.9-test', ...usageReport(true) }); }
const r0 = usageReport(false); return send(200, { ok: true, version: '1.0.9-test', generatedAt: r0.generatedAt, primary: domain, totals: { domains: 1, accounts: 3, storageBytes: r0.storage.bytes, largeFileBytes: 1000, messages: 420, sentMonth: 31, receivedMonth: 88 }, domains: [r0] });
}
if (req.url === '/api/login') {
if (ctl.lockAll) return send(429, { ok: false, error: 'rate_limited' });
const u = users.find((x) => x.login === j.login && x.password === j.password && x.role === 'admin');
if (u) return send(200, { ok: true }, { 'Set-Cookie': `mg_admin=tok-${domain}; Path=/; HttpOnly; Secure` }); return send(401, { ok: false, error: 'invalid_credentials' });
}
// 잠금 흉내: 세션도 끊긴 것으로 보아(실제로는 세션 만료 뒤 재로그인 시점) 모든 관리자 호출을 401 로 돌려 재로그인(→ 429)을 유도한다
if (ctl.lockAll || !/^mg_admin=tok-/.test(req.headers.cookie || '')) return send(401, { ok: false, error: 'admin_login_required' });
const pubU = (u) => ({ id: u.id, login: u.login, name: u.name, mailbox: u.mailbox, role: u.role, status: u.status });
// v1.7.3: 사용량 API 키 발급(관리자 세션) — generate 는 새 키를 한 번만 돌려주고, revoke 는 끈다
if (req.url === '/api/setup/usage-key' && req.method === 'POST') { if (j.action === 'revoke') { ctl.usageKey = null; return send(200, { ok: true, enabled: false }); } if (j.action !== 'generate') return send(400, { ok: false, error: 'bad_request' }); ctl.usageKey = 'au_' + domain.replace(/\W/g, '') + '_' + Math.random().toString(36).slice(2, 10).padEnd(8, 'x') + 'KEYKEYKEY'; return send(200, { ok: true, enabled: true, key: ctl.usageKey, endpoint: `https://webmail.${domain}/api/usage` }); }
if (req.url === '/api/admin/users' && req.method === 'GET') return send(200, { users: users.map(pubU), domain, accounts: { used: users.length, limit: 0 } });
if (req.url === '/api/admin/users' && req.method === 'POST') { if (users.some((u) => u.login === j.login)) return send(409, { ok: false, error: 'login_taken' }); if (ctl.rejectWeak && String(j.password).length < 10) return send(400, { ok: false, error: 'weak_password' }); const u = { id: `u${users.length + 1}`, login: j.login, name: j.name, mailbox: `${j.login}@${domain}`, role: j.role || 'user', status: 'active', password: j.password }; users.push(u); return send(201, { id: u.id }); }
const m = req.url.match(/^\/api\/admin\/users\/(.+)$/);
if (m && req.method === 'PATCH') { const u = users.find((x) => x.id === m[1]); if (!u) return send(404, { ok: false, error: 'not_found' }); if (j.password !== undefined) { if (ctl.rejectWeak && String(j.password).length < 10) return send(400, { ok: false, error: 'weak_password' }); u.password = j.password; } if (j.role) u.role = j.role; if (j.status) u.status = j.status; if (j.name) u.name = j.name; return send(200, { ok: true }); }
if (m && req.method === 'DELETE') { const i = users.findIndex((u) => u.id === m[1]); if (i < 0) return send(404, { ok: false, error: 'not_found' }); users.splice(i, 1); return send(200, { ok: true }); }
send(404, { ok: false, error: 'not_found' });
});
});
return new Promise((ok) => srv.listen(0, '127.0.0.1', () => ok({ srv, users, log, ctl, url: `http://127.0.0.1:${srv.address().port}` })));
}
let A, B;
test.before(async () => { A = await fakeArimail('alpha.test', 'PwAlpha!1'); B = await fakeArimail('beta.test', 'PwBeta!2'); });
test.after(() => { try { A.srv.close(); B.srv.close(); } catch (_) {} });
test('등록부: 추가·검증·비밀번호 분리 보관·기본 서버', () => {
const ms = require('../lib/mailservers');
assert.equal(ms.list().length, 0, '처음에는 서버 없음 (설정 파일 mail.* 비어 있음)');
assert.throws(() => ms.upsert({ nickname: 'x', url: '', domain: 'alpha.test', password: 'p' }), /Arimail 주소를 입력하세요/);
assert.throws(() => ms.upsert({ nickname: 'x', url: A.url, domain: '', password: 'p' }), /메일 도메인을 입력하세요/);
assert.throws(() => ms.upsert({ nickname: 'x', url: A.url, domain: 'alpha.test', ip: 'not-an-ip', password: 'p' }), /서버 IP 형식/);
assert.throws(() => ms.upsert({ nickname: 'x', url: A.url, domain: 'alpha.test' }), /관리자 비밀번호를 입력하세요/, '새 서버는 비밀번호 필수');
const a = ms.upsert({ nickname: 'Alpha 메일', ip: '115.68.194.192', url: A.url, domain: 'alpha.test', adminLogin: 'wm-admin', password: 'PwAlpha!1' }, 'tester');
assert.equal(a.id, 'alpha'); assert.equal(a.hasPassword, true); assert.equal(a.isDefault, true, '첫 서버는 기본'); assert.equal(a.adminPassword, undefined, '화면용 응답에 비밀번호 없음');
assert.match(a.label, /Alpha 메일 — 115\.68\.194\.192 \(alpha\.test\)/);
const b = ms.upsert({ nickname: 'Beta 메일', url: B.url, domain: 'beta.test', adminLogin: 'wm-admin', password: 'PwBeta!2' }, 'tester');
assert.equal(b.isDefault, false);
const raw = fs.readFileSync(ms.FILE, 'utf8'); assert.ok(!raw.includes('PwAlpha') && !raw.includes('PwBeta'), '등록부 파일에 비밀번호 원문 없음');
const vault = require('../lib/vault'); assert.equal(vault.secretOf('arimail-alpha'), 'PwAlpha!1', '비밀번호는 보관소 arimail-<id>'); assert.equal(vault.get('arimail-alpha').kind, 'arimail');
assert.equal(require('../lib/config').get('mail.provider'), 'arimail', '서버를 등록하면 연동 모듈이 켜진다');
assert.throws(() => ms.upsert({ nickname: '중복', url: A.url, domain: 'alpha.test', password: 'p' }), /이미 등록되어 있습니다/);
// 수정: 비밀번호 비우면 유지
const a2 = ms.upsert({ id: 'alpha', nickname: 'Alpha 메일 2', ip: '115.68.194.192', url: A.url, domain: 'alpha.test', adminLogin: 'wm-admin' }, 'tester');
assert.equal(a2.nickname, 'Alpha 메일 2'); assert.equal(vault.secretOf('arimail-alpha'), 'PwAlpha!1');
assert.equal(ms.forDomain('beta.test').id, 'beta'); assert.equal(ms.forDomain('nobody.test'), null);
assert.equal(ms.pick({ webmail: { domain: 'beta.test' } }).id, 'beta', '메일 도메인이 같은 서버');
assert.equal(ms.pick({ webmail: { domain: 'nobody.test' } }).id, 'alpha', '맞는 서버가 없으면 기본 서버');
assert.equal(ms.pick({ webmail: { domain: 'beta.test', mailServer: 'alpha' } }).id, 'alpha', '사이트가 고른 서버가 우선');
ms.setDefault('beta'); assert.equal(ms.defaultServer().id, 'beta');
assert.throws(() => ms.remove('primary'), /기본 서버는 여기서 지울 수 없습니다/);
});
test('연동 모듈: 서버마다 로그인·계정 생성·상태가 맞는 서버로 간다', async () => {
const ms = require('../lib/mailservers'); const arimail = require('../lib/connectors/arimail');
const sa = arimail.forServer(ms.get('alpha')); const sb = arimail.forServer(ms.get('beta'));
const ra = await sa.createUser({ login: 'kim', name: 'Kim', password: 'MailPw!123' }); assert.equal(ra.mailbox, 'kim@alpha.test');
const rb = await sb.createUser({ login: 'kim', name: 'Kim', password: 'MailPw!123' }); assert.equal(rb.mailbox, 'kim@beta.test', '같은 아이디라도 서버가 다르면 각자 생김');
assert.equal(A.users.filter((u) => u.role === 'user').length, 1); assert.equal(B.users.filter((u) => u.role === 'user').length, 1);
assert.ok(A.log.some((x) => x.u === '/api/login')); assert.ok(A.log.filter((x) => x.u === '/api/login').length === 1, '세션 쿠키 재사용 (로그인 1회)');
const st = await ms.test('beta'); assert.equal(st.ok, true); assert.match(st.detail, /Beta 메일/); assert.equal(ms.get('beta').lastTest.ok, true);
assert.ok(!JSON.stringify(st).includes('PwBeta'), '상태 응답에 비밀번호 없음');
// 틀린 비밀번호 → 로그인 실패 문구 (비밀번호 노출 없음)
ms.upsert({ id: 'beta', nickname: 'Beta 메일', url: B.url, domain: 'beta.test', adminLogin: 'wm-admin', password: 'Wrong!' }, 'tester');
const bad = await ms.test('beta'); assert.equal(bad.ok, false); assert.match(bad.detail, /관리자 로그인 실패/); assert.ok(!bad.detail.includes('Wrong!'));
ms.upsert({ id: 'beta', nickname: 'Beta 메일', url: B.url, domain: 'beta.test', adminLogin: 'wm-admin', password: 'PwBeta!2' }, 'tester');
// 모듈 최상위(기본 서버에 묶임) 도 동작
assert.equal(arimail.settings().domain, 'beta.test'); assert.equal(arimail.mailboxFor('x'), 'x@beta.test');
});
test('mailsys: 사이트의 메일 도메인에 맞는 서버로 계정을 만들고, 도메인이 안 맞으면 이유를 말한다', async () => {
const mailsys = require('../lib/mailsys'); const ms = require('../lib/mailservers');
const siteA = { id: 'a'.repeat(32), webmail: { enabled: true, domain: 'alpha.test' } }; const siteB = { id: 'b'.repeat(32), webmail: { enabled: true, domain: 'beta.test' } }; const siteX = { id: 'c'.repeat(32), webmail: { enabled: true, domain: 'nobody.test' } };
// health 캐시가 채워질 때까지 잠깐 (비동기 60초 캐시 — 첫 호출은 pending)
mailsys.backendFor(siteA); mailsys.backendFor(siteB); mailsys.backendFor(siteX); await new Promise((r) => setTimeout(r, 300));
const ba = mailsys.backendFor(siteA); assert.equal(ba.arimail.id, 'alpha'); assert.equal(ba.arimail.nickname, 'Alpha 메일 2'); assert.equal(ba.arimail.ip, '115.68.194.192'); assert.equal(ba.canReceive, true);
const bb = mailsys.backendFor(siteB); assert.equal(bb.arimail.id, 'beta'); assert.equal(bb.canReceive, true);
const bx = mailsys.backendFor(siteX); assert.equal(bx.canReceive, false); assert.match(bx.reason, /nobody\.test 도메인을 다루는 Arimail 을 메일 서버 연동에 등록/);
// MX 기본값: 사이트·전역 값이 없으면 고른 서버의 호스트
assert.equal(mailsys.siteConfig(siteB).mxHost, '127.0.0.1');
const r = await ba.createMailbox({ siteId: siteA.id, memberId: 'm1', login: 'lee', name: 'Lee', password: 'MailPw!123' });
assert.equal(r.address, 'lee@alpha.test'); assert.ok(A.users.some((u) => u.login === 'lee')); assert.ok(!B.users.some((u) => u.login === 'lee'));
const st = mailsys.status(siteA); assert.equal(st.mailServer.id, 'alpha'); assert.equal(st.mailServer.nickname, 'Alpha 메일 2');
// 사이트가 서버를 직접 고르면 그 서버 (도메인 불일치 → 이유)
const siteB2 = { id: 'd'.repeat(32), webmail: { enabled: true, domain: 'beta.test', mailServer: 'alpha' } };
const bb2 = mailsys.backendFor(siteB2); assert.equal(bb2.arimail.id, 'alpha'); assert.equal(bb2.canReceive, false); assert.match(bb2.reason, /alpha\.test 도메인만 다루는데/);
// enableSite: 등록부에 없는 서버 id 는 거절, 빈 값은 자동
await assert.rejects(mailsys.enableSite({ id: 'e'.repeat(32), domains: ['beta.test'] }, { domain: 'beta.test', mailServer: 'ghost' }), /등록부에 없습니다/);
const s5 = { id: 'f'.repeat(32), domains: ['beta.test'] }; await mailsys.enableSite(s5, { domain: 'beta.test', mailServer: '' }, 'admin'); assert.equal(s5.webmail.mailServer, ''); assert.equal(ms.pick(s5).id, 'beta');
});
// ---------------- v1.6.39 ----------------
test('v1.6.39 (요구 2): 비밀번호 덮어쓰기 전 보관 → 되돌리기, 로그인 잠금이면 연결 확인을 쉬고, 파일은 .bak 을 남긴다', async () => {
const ms = require('../lib/mailservers'); const vault = require('../lib/vault');
assert.equal(ms.get('alpha').adminPassword, 'PwAlpha!1'); assert.equal(ms.pub(ms.get('alpha')).hasPrevPassword, false);
// 비밀번호를 새 값으로 바꾸면 직전 값이 prev-arimail-<id> 로 보관된다 (등록부 파일에는 둘 다 원문 없음)
const a = ms.upsert({ id: 'alpha', nickname: 'Alpha 메일 2', ip: '115.68.194.192', url: A.url, domain: 'alpha.test', adminLogin: 'wm-admin', password: 'Changed!1' }, 'tester');
assert.equal(a.hasPrevPassword, true); assert.ok(a.prevPasswordAt); assert.equal(vault.secretOf('arimail-alpha'), 'Changed!1'); assert.equal(vault.secretOf('prev-arimail-alpha'), 'PwAlpha!1');
assert.ok(!fs.readFileSync(ms.FILE, 'utf8').includes('Changed!1'));
const bad = await ms.test('alpha'); assert.equal(bad.ok, false); assert.match(bad.detail, /관리자 로그인 실패/); assert.equal(bad.code, 'invalid_credentials');
// 같은 값으로 다시 저장하면 이전 값은 그대로 (덮어쓰지 않음)
ms.upsert({ id: 'alpha', nickname: 'Alpha 메일 2', ip: '115.68.194.192', url: A.url, domain: 'alpha.test', adminLogin: 'wm-admin', password: 'Changed!1' }, 'tester'); assert.equal(vault.secretOf('prev-arimail-alpha'), 'PwAlpha!1');
// 되돌리기: 이전 값이 현재로, 현재 값이 이전으로 (맞바꾸기)
const rv = ms.revertPassword('alpha', 'tester'); assert.equal(vault.secretOf('arimail-alpha'), 'PwAlpha!1'); assert.equal(vault.secretOf('prev-arimail-alpha'), 'Changed!1'); assert.equal(rv.hasPrevPassword, true);
const ok = await ms.test('alpha'); assert.equal(ok.ok, true, ok.detail);
assert.throws(() => ms.revertPassword('ghost'), /그 메일 서버가 없습니다/);
// 잠금: Arimail 이 rate_limited(429) → 해제 시각 기록, 그동안 test() 는 서버를 부르지 않는다
A.ctl.lockAll = true; const n0 = A.log.filter((x) => x.u === '/api/login').length;
const lk = await ms.test('alpha'); assert.equal(lk.ok, false); assert.equal(lk.code, 'rate_limited'); assert.equal(lk.locked, true); assert.ok(lk.server.lockedUntil); assert.match(lk.detail, /너무 많아/);
const lk2 = await ms.test('alpha'); assert.equal(lk2.locked, true); assert.match(lk2.detail, /로그인 잠금 중 — \d\d:\d\d 이후/); assert.equal(A.log.filter((x) => x.u === '/api/login').length, n0 + 1, '잠금 중에는 로그인 시도를 하지 않는다');
A.ctl.lockAll = false; assert.ok(ms.pub(ms.get('alpha')).lockedUntil, '잠금은 Arimail 쪽이라 lockAll 을 풀어도 기록은 남는다(15분)');
// 해제 시각을 지나면 다시 확인하고 성공 시 잠금 기록이 지워진다
const d = ms.load(); d.items.alpha.lockedUntil = new Date(Date.now() - 1000).toISOString(); fs.writeFileSync(ms.FILE, JSON.stringify(d));
const again = await ms.test('alpha'); assert.equal(again.ok, true); assert.equal(ms.pub(ms.get('alpha')).lockedUntil, null); assert.equal(ms.load().items.alpha.lockedUntil, null);
// 파일 백업
assert.ok(fs.existsSync(ms.FILE + '.bak'), 'mail-servers.json.bak'); assert.ok(fs.existsSync(path.join(tmp, 'credentials.json.bak')), 'credentials.json.bak');
const cfg = require('../lib/config'); cfg.set({ 'mail.nickname': 'x' }, 'tester'); cfg.set({ 'mail.nickname': 'y' }, 'tester'); assert.ok(fs.existsSync(path.join(tmp, 'config.json.bak')), 'config.json.bak');
// 깨진 등록부 파일은 .bak 에서 읽는다
fs.writeFileSync(ms.FILE, '{broken'); assert.ok(ms.get('alpha'), '.bak 으로 복구'); fs.writeFileSync(ms.FILE, JSON.stringify(ms.load()));
});
test('v1.6.39 (요구 4): 연동 관리자 비밀번호 보기·초기화(저장된 비밀번호로 / 다른 관리자 계정으로)·관리자 계정 보호', async () => {
const ms = require('../lib/mailservers'); const vault = require('../lib/vault'); const arimail = require('../lib/connectors/arimail');
const rv = ms.revealPassword('alpha'); assert.equal(rv.password, 'PwAlpha!1'); assert.equal(rv.adminLogin, 'wm-admin'); assert.equal(rv.adminUrl, A.url + '/admin');
assert.throws(() => ms.revealPassword('ghost'), /그 메일 서버가 없습니다/);
// 초기화(저장된 비밀번호로 로그인) → 새 16자 비밀번호가 Arimail 과 보관소에 모두 들어가고, 이전 값은 되돌리기용으로 남는다
const r1 = await ms.resetPassword('alpha', {}, 'tester');
assert.equal(r1.password.length, 16); assert.match(r1.password, /[A-Z]/); assert.match(r1.password, /[a-z]/); assert.match(r1.password, /\d/); assert.match(r1.password, /[^A-Za-z0-9]/);
assert.equal(A.users.find((u) => u.login === 'wm-admin').password, r1.password); assert.equal(vault.secretOf('arimail-alpha'), r1.password); assert.equal(vault.secretOf('prev-arimail-alpha'), 'PwAlpha!1');
assert.equal(r1.created, false); assert.match(r1.how, /저장된 비밀번호로/); assert.equal(r1.test && r1.test.ok, true, '새 비밀번호로 바로 연결 확인'); assert.equal(r1.server.hasPassword, true);
// 저장된 비밀번호가 틀린 상태(운영 사례) → 그대로는 실패, 다른 관리자 계정(root)으로는 성공. via 비밀번호는 어디에도 저장되지 않는다
ms.upsert({ id: 'alpha', nickname: 'Alpha 메일 2', ip: '115.68.194.192', url: A.url, domain: 'alpha.test', adminLogin: 'wm-admin', password: 'Nope!' }, 'tester');
await assert.rejects(ms.resetPassword('alpha', {}, 'tester'), /Arimail 로그인 실패.*다른 관리자 계정으로 재설정/);
await assert.rejects(ms.resetPassword('alpha', { via: { login: 'root', password: 'wrong' } }, 'tester'), /Arimail 로그인 실패/);
const r2 = await ms.resetPassword('alpha', { via: { login: 'root', password: 'RootPw!1' } }, 'tester');
assert.match(r2.how, /다른 관리자 계정\(root\)/); assert.equal(A.users.find((u) => u.login === 'wm-admin').password, r2.password); assert.equal(vault.secretOf('arimail-alpha'), r2.password);
assert.ok(!JSON.stringify(vault.list()).includes('RootPw') && !fs.readFileSync(path.join(tmp, 'credentials.json'), 'utf8').includes('RootPw'));
assert.equal((await ms.test('alpha')).ok, true);
// 연동 계정이 Arimail 에 없으면 다른 관리자 계정으로 새로 만든다 (관리자 역할)
ms.upsert({ id: 'alpha', nickname: 'Alpha 메일 2', ip: '115.68.194.192', url: A.url, domain: 'alpha.test', adminLogin: 'wm-admin2', password: 'x' }, 'tester');
const r3 = await ms.resetPassword('alpha', { via: { login: 'root', password: 'RootPw!1' } }, 'tester'); assert.equal(r3.created, true); const nu = A.users.find((u) => u.login === 'wm-admin2'); assert.equal(nu.role, 'admin'); assert.equal(nu.password, r3.password);
assert.equal((await ms.test('alpha')).ok, true);
// 관리자 계정 보호: 연동 계정·관리자 역할 계정은 지우거나 역할을 내릴 수 없다. 일반 메일 계정은 그대로 지울 수 있다
const sa = arimail.forServer(ms.get('alpha'));
await assert.rejects(sa.deleteUserByLogin('wm-admin2'), /보호됩니다/); await assert.rejects(sa.deleteUserByLogin('root'), /보호됩니다/); await assert.rejects(sa.deleteUserById('adm'), /보호됩니다/);
await assert.rejects(sa.updateUser('root', { role: 'user' }), /보호됩니다/); await assert.rejects(sa.updateUser('adm', { status: 'disabled' }), /보호됩니다/);
await sa.updateUser('adm', { password: 'Fine!1234' }); assert.equal(A.users.find((u) => u.id === 'adm').password, 'Fine!1234', '비밀번호 변경은 허용');
assert.equal(await sa.deleteUserByLogin('kim'), true); assert.ok(!A.users.some((u) => u.login === 'kim'));
assert.ok(A.users.some((u) => u.login === 'root') && A.users.some((u) => u.login === 'wm-admin2'));
});
test('v1.6.39 (요구 3): 사이트 관리자 비밀번호 ↔ 메일 서버 비밀번호 동기화 (로그인 때 맞추기, 켤 때 synced 표시, 실패 기록)', async () => {
const mailsys = require('../lib/mailsys'); const ms = require('../lib/mailservers');
ms.upsert({ id: 'alpha', nickname: 'Alpha 메일 2', ip: '115.68.194.192', url: A.url, domain: 'alpha.test', adminLogin: 'root', password: 'RootPw!1' }, 'tester');
const siteA = { id: 'a'.repeat(32), ownerId: 'owner', webmail: { enabled: true, domain: 'alpha.test' } };
mailsys.backendFor(siteA); await new Promise((r) => setTimeout(r, 300));
// 앞 테스트에서 만든 lee(m1) 계정 — 등록부에 providerId 가 있다. 관리자 레코드(해시 포함) 흉내
const lee = { id: 'm1', loginId: 'lee', name: 'Lee', mail: true, hash: 'hash-v1' };
assert.deepEqual(await mailsys.syncPassword(siteA, { ...lee, mail: false }, 'NewPw!456'), { skipped: 'mail-off' });
const s1 = await mailsys.syncPassword(siteA, lee, 'NewPw!456', 'lee'); assert.equal(s1.ok, true); assert.equal(A.users.find((u) => u.login === 'lee').password, 'NewPw!456');
assert.deepEqual(await mailsys.syncPassword(siteA, lee, 'NewPw!456'), { skipped: 'already-synced' }, '같은 해시면 다시 보내지 않는다');
assert.equal(mailsys.syncState(siteA, lee).state, 'synced');
// 관리자 비밀번호가 바뀌면(해시 변경) 다음 로그인 때 다시 맞춘다
const lee2 = { ...lee, hash: 'hash-v2' }; assert.equal(mailsys.syncState(siteA, lee2).state, 'pending');
assert.equal((await mailsys.syncPassword(siteA, lee2, 'Third!789')).ok, true); assert.equal(A.users.find((u) => u.login === 'lee').password, 'Third!789');
// Arimail 정책 거절(weak_password) → 실패 기록, 상태 error, 비밀번호 원문은 기록에 없음
A.ctl.rejectWeak = true; const lee3 = { ...lee, hash: 'hash-v3' }; const bad = await mailsys.syncPassword(siteA, lee3, 'short'); assert.equal(bad.ok, false); assert.match(bad.error, /비밀번호 정책/);
const st3 = mailsys.syncState(siteA, lee3); assert.equal(st3.state, 'error'); assert.match(st3.error, /정책/); A.ctl.rejectWeak = false;
const acc = mailsys.accountOf(siteA.id, 'm1'); assert.ok(!JSON.stringify(acc).includes('short') && !JSON.stringify(acc).includes('Third!789'));
const au = require('../lib/audit').list ? require('../lib/audit').list({ action: 'mail.account.sync' }) : null; if (au) assert.ok(!JSON.stringify(au).includes('Third!789'));
// 메일 사용 켜기: synced 면 등록부에 해시를 적고 안내 문구가 "같습니다", 아니면 임시 비밀번호 안내
const park = { id: 'm2', loginId: 'park', name: 'Park', hash: 'hash-park' };
const e1 = await mailsys.enableMember(siteA, park, { password: 'ParkPw!123', synced: true }); assert.equal(e1.address, 'park@alpha.test'); assert.equal(e1.synced, true); assert.match(e1.note, /사이트 관리자 비밀번호와 같습니다/); assert.equal(mailsys.accountOf(siteA.id, 'm2').pwSyncHash, 'hash-park');
const choi = { id: 'm3', loginId: 'choi', name: 'Choi', hash: 'hash-choi' };
const e2 = await mailsys.enableMember(siteA, choi, { password: 'TempPw!123', synced: false }); assert.equal(e2.synced, false); assert.match(e2.note, /임시입니다/); assert.equal(mailsys.accountOf(siteA.id, 'm3').pwSyncHash, null);
assert.equal(mailsys.syncState(siteA, { ...choi, mail: true }).state, 'pending');
});
// v1.7.3 (요구 2) 메일 사용량 연동 — 키 발급·보관(비밀값 분리) · 사용량 조회·정리 · 사이트 계정만 걸러내기 · 오류 안내 · 캐시
test('v1.7.3 메일 사용량: 키 발급 → 보관소 · 사용량 조회(서버/도메인/사이트) · 키 오류·버전 안내 · 캐시', async () => {
const ms = require('../lib/mailservers'); const mu = require('../lib/mailusage'); const vault = require('../lib/vault'); const mailsys = require('../lib/mailsys');
const alpha = ms.get('alpha'); assert.ok(alpha, 'alpha 서버가 앞 테스트에서 등록됨'); assert.equal(ms.pub(alpha).hasUsageKey, false);
// 키 없음 → 안내 (Arimail 을 부르지 않음)
const u0 = await mu.forServer('alpha', { force: true }); assert.equal(u0.ok, false); assert.equal(u0.error.code, 'no_usage_key'); assert.match(u0.error.message, /사용량 API 키가 없습니다/);
// 발급: 저장된 관리자 계정으로 Arimail 에 로그인해 받는다 → 보관소 arimail-usage-alpha, 등록부 파일에는 원문 없음
const r = await ms.issueUsageKey('alpha', 'tester'); assert.equal(r.server.hasUsageKey, true); assert.equal(r.replaced, false); assert.ok(A.ctl.usageKey && A.ctl.usageKey.startsWith('au_'));
assert.equal(vault.secretOf('arimail-usage-alpha'), A.ctl.usageKey); assert.equal(vault.get('arimail-usage-alpha').kind, 'arimail-usage');
assert.ok(!fs.readFileSync(ms.FILE, 'utf8').includes(A.ctl.usageKey), '등록부 파일에 키 원문 없음'); assert.ok(!JSON.stringify(ms.list()).includes(A.ctl.usageKey), '화면용 응답에 키 원문 없음'); assert.ok(r.server.usageKeyHint && r.server.usageKeyHint.length < A.ctl.usageKey.length);
assert.ok(A.log.some((l) => l.u === '/api/setup/usage-key' && /mg_admin=tok-/.test(l.cookie)), '관리자 세션으로 발급');
// 서버 요약
const u1 = await mu.forServer('alpha', { force: true }); assert.equal(u1.ok, true); assert.equal(u1.totals.storageBytes, 123456789); assert.equal(u1.domains.length, 1); assert.equal(u1.domains[0].domain, 'alpha.test'); assert.equal(u1.domains[0].traffic.month.sent, 31); assert.equal(u1.domains[0].accountList, undefined, '요약에는 계정 목록 없음');
// 캐시: 같은 요청은 Arimail 을 다시 부르지 않는다
const calls = A.ctl.usageCalls; const u1b = await mu.forServer('alpha'); assert.equal(u1b.cached, true); assert.equal(A.ctl.usageCalls, calls);
// 도메인 상세 + 사이트용(이 사이트 계정만)
const u2 = await mu.forDomain('alpha', 'alpha.test', { force: true }); assert.equal(u2.ok, true); assert.equal(u2.report.accountList.length, 3); assert.equal(u2.report.daily.length, 2);
const siteX = { id: 'site-usage-x', title: 'X', domains: ['alpha.test'], webmail: { enabled: true, domain: 'alpha.test', mailServer: 'alpha' } };
mailsys.setAccount(siteX.id, 'm-kim', { login: 'kim', address: 'kim@alpha.test', providerId: 'u9', provider: 'arimail', state: 'active' });
const u3 = await mu.forSite(siteX); assert.equal(u3.ok, true); assert.equal(u3.storage.bytes, 123456789); assert.equal(u3.accounts.total, 3); assert.equal(u3.accounts.mine, 1); assert.equal(u3.accountList.length, 1); assert.equal(u3.accountList[0].mailbox, 'kim@alpha.test'); assert.equal(u3.accountList[0].mine, true); assert.equal(u3.myBytes, 100000000);
assert.ok(!JSON.stringify(u3).includes('other@'), '다른 사이트 계정 이름은 사이트 응답에 없음');
const off = await mu.forSite({ id: 'nope', webmail: { enabled: false } }); assert.equal(off.ok, false); assert.equal(off.code, 'mail_off');
const mism = await mu.forSite({ id: 'nope2', webmail: { enabled: true, domain: 'beta.test', mailServer: 'alpha' } }); assert.equal(mism.ok, false); assert.equal(mism.code, 'domain_mismatch');
// Arimail 에서 키를 다시 만들어 우리 키가 틀려진 경우 → 401 → 안내
A.ctl.usageKey = 'au_changed_on_arimail_side_000000000'; mu.invalidate('alpha');
const u4 = await mu.forServer('alpha', { force: true }); assert.equal(u4.ok, false); assert.equal(u4.error.code, 'unauthenticated'); assert.match(u4.error.message, /키가 Arimail 의 키와 다릅니다/);
// 다시 발급하면 바뀐 키로 맞춰진다(replaced)
const r2 = await ms.issueUsageKey('alpha', 'tester'); assert.equal(r2.replaced, true); assert.equal(vault.secretOf('arimail-usage-alpha'), A.ctl.usageKey);
assert.equal((await mu.forServer('alpha', { force: true })).ok, true);
// Arimail 쪽에서 키를 지우면(API 꺼짐) → 404 → 안내
A.ctl.usageKey = null; const u5 = await mu.forServer('alpha', { force: true }); assert.equal(u5.ok, false); assert.equal(u5.error.code, 'usage_api_disabled'); assert.match(u5.error.message, /꺼져 있습니다/);
// 낮은 버전(1.0.8) 은 "키가 틀렸다" 대신 업데이트 안내
A.ctl.version = '1.0.8'; const u6 = await mu.forServer('alpha', { force: true }); assert.equal(u6.ok, false); assert.equal(u6.error.code, 'usage_unsupported'); assert.match(u6.error.message, /1\.0\.9 이상으로 업데이트/);
await assert.rejects(ms.issueUsageKey('alpha', 'tester'), /사용량 API 가 없습니다/); A.ctl.version = null;
// 직접 붙여 넣기 · 형식 검사 · 삭제
assert.throws(() => ms.writeUsageKey('alpha', 'bad key with spaces', 'tester'), /형식이 올바르지 않습니다/);
const w = ms.upsert({ id: 'alpha', nickname: 'Alpha 메일', url: A.url, domain: 'alpha.test', adminLogin: 'wm-admin', usageKey: 'au_pasted_from_arimail_setup_screen_1234' }, 'tester'); assert.equal(w.hasUsageKey, true); assert.equal(vault.secretOf('arimail-usage-alpha'), 'au_pasted_from_arimail_setup_screen_1234');
const c = ms.clearUsageKey('alpha'); assert.equal(c.hasUsageKey, false); assert.equal(vault.secretOf('arimail-usage-alpha'), '');
mailsys.removeAccount(siteX.id, 'm-kim');
});
// v1.7.3 추가(운영자 요청): 웹메이커 서비스 관리자 비밀번호 ↔ Arimail 같은 ID 계정 동기화 — 로그인 때 호출되는 syncServiceAdmin
test('v1.7.3 관리자 비밀번호 동기화: 같은 ID 계정만 · 지문이 같으면 생략 · 계정 없으면 생략 · 실패 기록', async () => {
const mailsys = require('../lib/mailsys'); const ms = require('../lib/mailservers');
// 앞 테스트(비밀번호 되돌리기·초기화)가 alpha 의 보관 비밀번호를 가짜 서버와 어긋나게 남길 수 있다 — 가짜 서버의 실제 연동 계정 비밀번호로 다시 맞춘다
ms.upsert({ id: 'alpha', nickname: 'Alpha 메일', url: A.url, domain: 'alpha.test', adminLogin: 'wm-admin', password: A.users.find((u) => u.login === 'wm-admin').password }, 'tester');
const rootA = A.users.find((u) => u.login === 'root'); const before = rootA.password;
// 일반 사용자는 아무 일도 하지 않는다
assert.deepEqual(await mailsys.syncServiceAdmin({ id: 'root', role: 'user' }, 'NewRoot!123', { fingerprint: 'fp1' }), []);
const r1 = await mailsys.syncServiceAdmin({ id: 'root', role: 'admin' }, 'NewRoot!123', { fingerprint: 'fp1', by: 'root' });
const a1 = r1.find((x) => /Alpha/.test(x.server)); assert.ok(a1 && a1.ok === true, JSON.stringify(r1)); assert.equal(rootA.password, 'NewRoot!123', 'Arimail(alpha) root 비밀번호가 바뀜'); assert.notEqual(before, rootA.password);
const b1 = r1.find((x) => /Beta/.test(x.server)); assert.ok(b1 && (b1.ok === true || b1.skipped), 'beta 도 같은 ID 계정이 있으면 맞춘다');
const rec = ms.adminSyncOf('alpha').root; assert.equal(rec.fp, 'fp1'); assert.ok(rec.at); assert.ok(!JSON.stringify(ms.load()).includes('NewRoot!123'), '등록부에 비밀번호 없음');
assert.ok(ms.list().find((s) => s.id === 'alpha').adminSync.some((x) => x.login === 'root' && x.at), '화면용 응답에 동기화 기록');
// 같은 지문이면 Arimail 을 부르지 않는다
const calls = A.log.length; const r2 = await mailsys.syncServiceAdmin({ id: 'root', role: 'admin' }, 'NewRoot!123', { fingerprint: 'fp1' }); assert.equal(r2.find((x) => /Alpha/.test(x.server)).skipped, 'already-synced'); assert.equal(A.log.length, calls);
// 비밀번호가 바뀌면(지문 변경) 다시 맞춘다
const r3 = await mailsys.syncServiceAdmin({ id: 'root', role: 'admin' }, 'Another!456', { fingerprint: 'fp2' }); assert.equal(r3.find((x) => /Alpha/.test(x.server)).ok, true); assert.equal(rootA.password, 'Another!456');
// Arimail 에 없는 ID 는 생략(계정을 만들지 않는다)
const r4 = await mailsys.syncServiceAdmin({ id: 'nobody', role: 'admin' }, 'Nobody!789', { fingerprint: 'fp3' }); assert.equal(r4.find((x) => /Alpha/.test(x.server)).skipped, 'no-account'); assert.ok(!A.users.some((u) => u.login === 'nobody'));
// 정책 거부(weak_password) → 실패 기록, 예외 없음
A.ctl.rejectWeak = true; const r5 = await mailsys.syncServiceAdmin({ id: 'root', role: 'admin' }, 'short', { fingerprint: 'fp4' }); A.ctl.rejectWeak = false;
const f5 = r5.find((x) => /Alpha/.test(x.server)); assert.equal(f5.ok, false); assert.match(f5.error, /비밀번호 정책/); assert.ok(ms.adminSyncOf('alpha').root.error);
const au = require('../lib/audit').list({ action: 'mail.admin.sync' }); assert.ok(au.length >= 2); assert.ok(!JSON.stringify(au).includes('NewRoot!123') && !JSON.stringify(au).includes('Another!456'), '작업 기록에 비밀번호 없음');
});