Files
AriWeb-Suite/modules/webmaker/test/lib.test.js
T

126 lines
13 KiB
JavaScript

'use strict';
// v1.6.31 (요구 30) 자동 검증 — 라이브러리 단위. 실행: npm test (node --test). 외부 서비스·비용 없음.
const test = require('node:test'); const assert = require('node:assert/strict');
const fs = require('fs'); const path = require('path'); const os = require('os'); const { spawnSync } = require('child_process');
const ROOT = path.join(__dirname, '..');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-test-')); process.env.DATA_ROOT = tmp; process.env.DATA_DIR = path.join(tmp, 'sites'); process.env.SITES_ROOT = path.join(tmp, 'www'); fs.mkdirSync(process.env.SITES_ROOT, { recursive: true });
test('jsonstore: 원자적 쓰기·백업·손상 감지', () => {
const js = require(ROOT + '/lib/jsonstore'); const f = path.join(tmp, 'a.json');
js.writeJsonAtomic(f, { v: 1 }, { keepBackup: true }); js.writeJsonAtomic(f, { v: 2 }, { keepBackup: true });
assert.equal(js.readJson(f, null).v, 2); assert.equal(JSON.parse(fs.readFileSync(f + '.bak', 'utf8')).v, 1);
fs.writeFileSync(f, '{broken'); const got = js.readJson(f, null); assert.equal(got.v, 1, '.bak 으로 복구'); assert.ok(js.health().some((h) => h.file === f && h.recoveredFromBackup));
fs.unlinkSync(f + '.bak'); assert.throws(() => js.readJson(f, null, { strict: true }), /손상/);
assert.equal(fs.readdirSync(tmp).filter((x) => x.endsWith('.tmp')).length, 0, '임시 파일 잔여 없음');
});
test('jsonstore: withLock 직렬화', async () => {
const js = require(ROOT + '/lib/jsonstore'); const order = [];
await Promise.all([js.withLock('k', async () => { await new Promise((r) => setTimeout(r, 30)); order.push(1); }), js.withLock('k', async () => { order.push(2); })]);
assert.deepEqual(order, [1, 2]);
});
test('ratelimit: 창·신뢰 프록시', () => {
const rl = require(ROOT + '/lib/ratelimit');
assert.deepEqual([1, 2, 3, 4].map(() => rl.limited('t', 3, 1000)), [false, false, false, true]);
assert.equal(rl.clientIp({ socket: { remoteAddress: '::ffff:127.0.0.1' }, headers: { 'x-forwarded-for': '203.0.113.9, 127.0.0.1' } }), '203.0.113.9');
assert.equal(rl.clientIp({ socket: { remoteAddress: '198.51.100.7' }, headers: { 'x-forwarded-for': '10.0.0.1' } }), '198.51.100.7', '비신뢰 연결의 XFF 무시');
});
test('netguard: 내부 주소 차단·허용 목록', async () => {
const ng = require(ROOT + '/lib/netguard');
await assert.rejects(ng.assertExternalUrl('http://127.0.0.1:8795/', { allowHttp: true }), /내부|사설|공개/);
await assert.rejects(ng.assertExternalUrl('http://localhost/', { allowHttp: true }), /내부/);
await assert.rejects(ng.assertExternalUrl('http://169.254.169.254/latest/meta-data', { allowHttp: true }), /내부|사설|공개/);
await assert.rejects(ng.assertExternalUrl('http://api.example.com/'), /프로토콜/, '기본은 https 만');
await assert.rejects(ng.assertExternalUrl('ftp://example.com/'), /프로토콜/);
assert.equal(ng.isPrivateIp('192.168.20.66'), true); assert.equal(ng.isPrivateIp('8.8.8.8'), false);
});
test('audit: 비밀값 가림', () => {
const a = require(ROOT + '/lib/audit'); const r = a.redact({ password: 'p', apiKey: 'sk-ant-1234567890', note: 'token re_abcdefghijk', nested: { secret: 'x', ok: 1 } });
assert.equal(r.password, '(비밀값 생략)'); assert.equal(r.nested.secret, '(비밀값 생략)'); assert.ok(!/re_abcdefghijk/.test(r.note)); assert.equal(r.nested.ok, 1);
a.log({ by: 'tester', action: 'test.x', detail: { password: 'zz' } }); const l = a.list({ action: 'test.' }); assert.equal(l[0].detail.password, '(비밀값 생략)');
});
test('credits: 같은 작업(ref) 중복 차감 없음 · 금액 검증', () => {
const c = require(ROOT + '/lib/credits');
c.set('u1', 10000, 'admin'); c.charge('u1', 3000, { siteId: 's', cmdNo: 1 }, 'x'); const again = c.charge('u1', 3000, { siteId: 's', cmdNo: 1 }, 'x');
assert.equal(again.duplicate, true); assert.equal(c.balance('u1'), 7000);
assert.throws(() => c.apply({ userId: 'u1', type: 'add', amount: -5 }), /0보다/); assert.throws(() => c.set('u1', -1, 'admin'), /음수/); assert.throws(() => c.apply({ userId: 'u1', type: 'add', amount: 1e12 }), /너무 큽니다/);
});
test('auth: CSRF 토큰·세션 쿠키 Secure·기본 관리자 없음', () => {
const auth = require(ROOT + '/lib/auth');
assert.equal(auth.ensureBootstrapAdmin() && /최초 관리자/.test(auth.ensureBootstrapAdmin()), true);
assert.equal(Object.keys(JSON.parse(fs.existsSync(path.join(tmp, 'users.json')) ? fs.readFileSync(path.join(tmp, 'users.json'), 'utf8') : '{}')).length, 0, 'admin/admin 이 만들어지지 않음');
const u = auth.signup({ id: 'alice', password: 'Passw0rd!', name: 'A' });
const tok = auth.csrfToken(u); assert.ok(auth.csrfOk(u, tok)); assert.ok(!auth.csrfOk(u, tok + 'x'));
assert.match(auth.makeSessionCookie('alice', { secure: true }), /; Secure/); assert.doesNotMatch(auth.makeSessionCookie('alice'), /Secure/);
const before = auth.csrfToken(u); auth.changePassword('alice', 'Passw0rd!', 'NewPassw0rd!'); assert.notEqual(auth.csrfToken(u), before, '비밀번호 변경 뒤 토큰(세션 세대) 바뀜');
const r = auth.bootstrapAdmin('test'); assert.ok(r.created && r.password && r.password !== 'admin');
});
test('guard hook: 사이트 폴더 밖 읽기·셸 명령·내부 주소 차단', () => {
const site = path.join(tmp, 'gsite'); fs.mkdirSync(site, { recursive: true }); fs.writeFileSync(path.join(site, 'index.html'), 'x');
const run = (payload) => spawnSync('node', [path.join(ROOT, 'hooks', 'guard.js')], { input: JSON.stringify(payload), env: { ...process.env, SITE_DIR: site } }).status;
assert.equal(run({ tool_name: 'Read', tool_input: { file_path: path.join(site, 'index.html') } }), 0);
assert.equal(run({ tool_name: 'Read', tool_input: { file_path: '/etc/passwd' } }), 2);
assert.equal(run({ tool_name: 'Read', tool_input: { file_path: path.join(tmp, 'users.json') } }), 2);
assert.equal(run({ tool_name: 'Bash', tool_input: { command: 'cat ../other/index.html' } }), 2);
assert.equal(run({ tool_name: 'Bash', tool_input: { command: 'curl http://127.0.0.1:3080/api/admin/settings' } }), 2);
assert.equal(run({ tool_name: 'Bash', tool_input: { command: 'ls css && cat index.html' } }), 0);
assert.equal(run({ tool_name: 'WebFetch', tool_input: { url: 'http://192.168.20.66:8795/' } }), 2);
assert.equal(run({ tool_name: 'Glob', tool_input: { pattern: '**/*.json', path: '/var/www/sites' } }), 2);
});
test('codex: 샌드박스 없으면 실행 거부(fail-closed)', () => {
const codex = require(ROOT + '/lib/engine-codex'); const sb = codex.sandboxMode ? codex.sandboxMode() : null;
if (!sb) return; assert.ok(['workspace-write', 'blocked', 'none'].includes(sb.mode)); if (sb.restricted) assert.equal(sb.mode, 'blocked');
});
test('engine-openai safePath: 심볼릭 링크 탈출 차단', () => {
const eo = require(ROOT + '/lib/engine-openai'); const site = path.join(tmp, 'osite'); fs.mkdirSync(site, { recursive: true }); fs.symlinkSync('/etc', path.join(site, 'etclink'));
const sp = eo._safePath || eo.safePath; if (!sp) return;
assert.throws(() => sp(site, 'etclink/passwd'), /밖|심볼릭/); assert.throws(() => sp(site, '../x'), /밖/); assert.ok(sp(site, 'index.html').startsWith(site));
});
test('mailsys 등록부: 다른 사이트의 로그인 이름 충돌 감지', () => {
const m = require(ROOT + '/lib/mailsys');
m.setAccount('siteA', 'm1', { login: 'ceo', providerId: 'p1', state: 'active' });
assert.equal(m.ownerOfLogin('ceo').siteId, 'siteA'); assert.equal(m.ownerOfLogin('nobody'), null);
m.setAccount('siteA', 'm1', { state: 'deprovision_pending', lastError: 'x' }); assert.equal(m.listAccounts({ state: 'deprovision_pending' }).length, 1);
});
test('domains 구매 원장: 같은 도메인 중복 구매 차단 · 닫힌 뒤 재구매', () => {
const d = require(ROOT + '/lib/domains');
const p1 = d._openPurchase('u1', 'example.com', 'create', { period: 1, quoteKrw: 15000, registrar: 'porkbun' }).p; assert.ok(p1 && p1.id);
const p2 = d._openPurchase('u1', 'example.com', 'create', {}); assert.equal(p2.dup && p2.dup.id, p1.id, '열린 구매가 있으면 중복');
assert.equal(d.purchases({ open: true }).length, 1);
d.setPurchase(p1.id, { state: 'failed' }); assert.equal(d.purchases({ open: true }).length, 0);
const p3 = d._openPurchase('u1', 'example.com', 'create', {}); assert.ok(p3.p && !p3.dup, '닫힌 뒤에는 다시 가능');
});
test('v1.6.40 prepaid: 상품(정률·정액 인센티브) · 주문 접수 · 입금 확인 적립(중복 불가) · 취소 · 삭제 보호', () => {
const pp = require(ROOT + '/lib/prepaid'); const credits = require(ROOT + '/lib/credits');
const a = pp.savePackage({ name: '베이직', amount: 10000, bonusType: 'percent', bonusValue: 10 }, 'admin'); assert.equal(a.bonus, 1000); assert.equal(a.credit, 11000);
const b = pp.savePackage({ name: '플러스', amount: 50000, bonusType: 'fixed', bonusValue: 7000, sort: -1 }, 'admin'); assert.equal(b.credit, 57000);
assert.throws(() => pp.savePackage({ name: 'x', amount: 10 }, 'admin'), /금액은/); assert.throws(() => pp.savePackage({ name: 'x', amount: 10000, bonusType: 'percent', bonusValue: 900 }, 'admin'), /500%/);
assert.deepEqual(pp.listPackages().map((p) => p.name), ['플러스', '베이직'], 'sort 순');
pp.savePackage({ id: a.id, active: false }, 'admin'); assert.equal(pp.listPackages().length, 1, '숨긴 상품은 사용자 목록에서 빠짐'); assert.equal(pp.listPackages({ all: true }).length, 2);
assert.throws(() => pp.createOrder('u9', a.id), /없거나 판매가 끝났/);
const before = credits.balance('u9'); const o = pp.createOrder('u9', b.id); assert.equal(o.status, 'pending'); assert.equal(o.credit, 57000);
for (let i = 0; i < pp.MAX_PENDING_PER_USER - 1; i++) pp.createOrder('u9', b.id); assert.throws(() => pp.createOrder('u9', b.id), /결제 대기 주문이/);
const paid = pp.markPaid(o.id, 'admin', '입금 확인'); assert.equal(paid.order.status, 'paid'); assert.equal(credits.balance('u9'), before + 57000, '결제 금액 + 인센티브가 적립');
assert.ok(credits.ledger('u9', 5).some((e) => e.ref && e.ref.orderId === o.id && e.type === 'add'), '원장에 주문 참조');
assert.throws(() => pp.markPaid(o.id, 'admin'), /이미 적립된/); assert.throws(() => pp.cancelOrder(o.id, 'u9', { userId: 'u9' }), /이미 적립된/);
const o2 = pp.myOrders('u9').find((x) => x.status === 'pending'); assert.throws(() => pp.cancelOrder(o2.id, 'u8', { userId: 'u8' }), /주문이 없습니다/, '남의 주문은 보이지 않음'); assert.equal(pp.cancelOrder(o2.id, 'u9', { userId: 'u9' }).status, 'cancelled');
const rm = pp.removePackage(b.id); assert.equal(rm.deactivated, true, '주문이 있는 상품은 숨김으로'); assert.equal(pp.removePackage(a.id).removed, true, '주문 없는 상품은 삭제');
assert.ok(require('fs').existsSync(pp.FILE + '.bak'), '.bak 보관');
});
test('v1.7.2 stats.summary: 달력 기준 3개월 합계(이번 달은 누적 중) · series7 · 디스크 웹/DB/메일 구분', () => {
const st = require(ROOT + '/lib/stats'); const fs2 = require('fs'); const siteId = 'a'.repeat(32);
const day = (offsetDays) => st.kstDate(Date.now() - offsetDays * 86400000);
const today = day(0); const thisMonth = today.slice(0, 7); const prev = new Date(today + 'T00:00:00Z'); prev.setUTCMonth(prev.getUTCMonth() - 1, 15); const prevDay = prev.toISOString().slice(0, 10);
const prev2 = new Date(today + 'T00:00:00Z'); prev2.setUTCMonth(prev2.getUTCMonth() - 2, 10); const prev2Day = prev2.toISOString().slice(0, 10);
const rec = (pv, bi, bo) => ({ req: pv, pv, bytesIn: bi, bytesOut: bo, bots: 0, v: { x: 1 }, vOver: 0, pages: {}, refs: {}, hosts: {}, b: { pv: 0, v: {}, vOver: 0, bytesOut: 0, screens: {}, langs: {} } });
fs2.mkdirSync(st.STATS_DIR, { recursive: true });
fs2.writeFileSync(require('path').join(st.STATS_DIR, siteId + '.json'), JSON.stringify({ days: { [today]: rec(3, 100, 1000), [day(1)]: rec(2, 50, 500), [prevDay]: rec(7, 700, 7000), [prev2Day]: rec(1, 10, 20) }, totals: { req: 0, pv: 0, bytesIn: 0, bytesOut: 0, bots: 0, bpv: 0, bBytesOut: 0 }, updatedAt: new Date().toISOString() }));
const mailDir = require('path').join(tmp, 'mail', siteId); fs2.mkdirSync(mailDir, { recursive: true }); fs2.writeFileSync(require('path').join(mailDir, 'box.json'), 'x'.repeat(2048));
const s = st.summary(siteId, { mailDir, mailEnabled: true });
assert.equal(s.months.length, 3); assert.equal(s.months[2].month, thisMonth); assert.equal(s.months[2].current, true);
const cur = s.months[2]; assert.ok(cur.bytesOut >= 1000 && cur.bytesIn >= 100, '이번 달 누적 (어제가 지난달일 수도 있어 ≥)');
const pm = s.months[1]; assert.equal(pm.current, false); assert.ok(pm.bytesOut >= 7000, '직전 달에 15일 기록 포함: ' + JSON.stringify(pm));
assert.equal(s.months[0].bytesOut >= 20, true, '2개월 전');
assert.equal(s.series7.length, 7); assert.equal(s.disk.mail, 2048); assert.equal(typeof s.disk.web, 'number'); assert.equal(s.disk.limit, null);
const s2 = st.summary(siteId, { mailDir, mailEnabled: false }); assert.equal(s2.disk.mail, null, '메일 미설정이면 숨김(null)');
});