const assert = require('node:assert/strict'); const { test } = require('node:test'); const { mkdtempSync, rmSync } = require('node:fs'); const { tmpdir } = require('node:os'); const path = require('node:path'); const { spawn } = require('node:child_process'); const { once } = require('node:events'); const net = require('node:net'); const { pathToFileURL } = require('node:url'); // 所有写入都在独立临时库,不能加载本地业务库。 test('security and quotation regressions', { timeout: 30000 }, async t => { const dataDir = mkdtempSync(path.join(tmpdir(), 'happy-regression-')); process.env.DATA_DIR = dataDir; const db = require('../db/db'); const auth = require('../lib/auth'); let server; t.after(async () => { if (server && server.exitCode === null) { const exited = once(server, 'exit'); server.kill(); await exited; } db.close(); rmSync(dataDir, { recursive: true, force: true }); }); const now = new Date().toISOString(); const admin = db.prepare("SELECT * FROM users WHERE role = 'admin'").get(); const repId = db.prepare('INSERT INTO users (username, password_hash, role, created_at) VALUES (?, ?, ?, ?)') .run('regression_rep', auth.hashPassword('test-password'), 'sales_rep', now).lastInsertRowid; const insertCustomer = db.prepare('INSERT INTO customers (company, owner_id, pool_status, created_at) VALUES (?, ?, ?, ?)'); const ownId = insertCustomer.run('scope-own', repId, 'owned', now).lastInsertRowid; const otherId = insertCustomer.run('scope-other', admin.id, 'owned', now).lastInsertRowid; const poolId = insertCustomer.run('scope-pool', repId, 'public', now).lastInsertRowid; for (const [id, marker] of [[ownId, 'scope-own'], [otherId, 'scope-other'], [poolId, 'scope-pool']]) { db.prepare('INSERT INTO contacts (customer_id, name, email, created_at) VALUES (?, ?, ?, ?)') .run(id, marker, `${marker}@example.test`, now); db.prepare('INSERT INTO activities (customer_id, activity_type, content, created_at) VALUES (?, ?, ?, ?)') .run(id, 'call', marker, now); db.prepare('INSERT INTO deals (customer_id, title, stage, created_at) VALUES (?, ?, ?, ?)') .run(id, marker, 'new', now); db.prepare('INSERT INTO todos (customer_id, title, created_at) VALUES (?, ?, ?)').run(id, marker, now); } db.prepare('INSERT INTO activities (activity_type, content, created_at) VALUES (?, ?, ?)').run('todo', 'unlinked-activity', now); db.prepare('INSERT INTO todos (title, created_at) VALUES (?, ?)').run('unlinked-todo', now); const insertOrder = db.prepare('INSERT INTO orders (order_no, customer_id, order_date, owner_id, created_at) VALUES (?, ?, ?, ?, ?)'); const ownOrderId = insertOrder.run('REP-OWN', ownId, now.slice(0, 10), repId, now).lastInsertRowid; const otherOrderId = insertOrder.run('ADMIN-OTHER', otherId, now.slice(0, 10), admin.id, now).lastInsertRowid; insertOrder.run('LEGACY-UNOWNED', otherId, now.slice(0, 10), null, now); const adminCookie = `happy_sid=${auth.createSession(admin.id)}`; const repCookie = `happy_sid=${auth.createSession(repId)}`; // 由系统分配空闲端口,避免干扰已在运行的开发服务。 const probe = net.createServer(); probe.listen(0, '127.0.0.1'); await once(probe, 'listening'); const port = probe.address().port; await new Promise(resolve => probe.close(resolve)); server = spawn(process.execPath, [path.join(__dirname, '../server.js')], { env: { ...process.env, PORT: String(port), SECURE_COOKIE: '1' }, stdio: ['ignore', 'pipe', 'pipe'] }); let logs = ''; server.stdout.on('data', chunk => { logs += chunk; }); server.stderr.on('data', chunk => { logs += chunk; }); const base = `http://127.0.0.1:${port}`; for (let attempt = 0; ; attempt++) { try { if ((await fetch(`${base}/api/health`)).ok) break; } catch { /* 等待启动 */ } if (attempt >= 100 || server.exitCode !== null) throw new Error(`Server startup failed: ${logs}`); await new Promise(resolve => setTimeout(resolve, 50)); } const search = async (term, cookie) => { const res = await fetch(`${base}/api/search?q=${encodeURIComponent(term)}`, { headers: { cookie } }); assert.equal(res.status, 200); return (await res.json()).results; }; await t.test('search isolates customers, contacts, activities, deals and todos', async () => { assert.equal((await fetch(`${base}/api/search?q=scope`)).status, 401); for (const term of ['scope-other', 'scope-pool', 'scope-other@example.test']) { assert.deepEqual(await search(term, repCookie), []); assert.ok((await search(term, adminCookie)).length > 0); } const own = await search('scope-own', repCookie); assert.equal(own.length, 5); assert.deepEqual(new Set(own.map(r => r.typeLabel)), new Set(['客户', '客户联系人', '活动', '商机', '待办'])); assert.ok((await search('unlinked-activity', repCookie)).length > 0); assert.ok((await search('unlinked-todo', repCookie)).length > 0); // 通配符不能令 OR 条件跳过归属检查。 const broad = JSON.stringify(await search('%', repCookie)); assert.ok(!broad.includes('scope-other') && !broad.includes('scope-pool')); }); await t.test('order lists, global search and expense details respect ownership', async () => { const listRes = await fetch(`${base}/api/orders`, { headers: { cookie: repCookie } }); assert.equal(listRes.status, 200); const visibleOrders = await listRes.json(); assert.ok(visibleOrders.some(order => order.id === ownOrderId)); assert.ok(visibleOrders.some(order => order.order_no === 'LEGACY-UNOWNED')); assert.ok(!visibleOrders.some(order => order.id === otherOrderId)); assert.deepEqual(await search('ADMIN-OTHER', repCookie), []); assert.ok((await search('ADMIN-OTHER', adminCookie)).length > 0); assert.equal((await fetch(`${base}/api/orders/${otherOrderId}/expenses`, { headers: { cookie: repCookie } })).status, 403); }); await t.test('session cookies support HTTP development and secure HTTPS login/logout', () => { for (const secure of ['0', '1']) { process.env.SECURE_COOKIE = secure; const cookies = []; const res = { append: (key, value) => cookies.push(value) }; auth.setSessionCookie(res, 'test-session'); auth.clearSessionCookie(res); for (const cookie of cookies) { assert.equal(cookie.includes('; Secure'), secure === '1'); assert.ok(cookie.includes('HttpOnly') && cookie.includes('SameSite=Lax')); } assert.ok(cookies[1].includes('Max-Age=0')); } }); await t.test('stored quotation amounts use the displayed rounded price', async () => { const res = await fetch(`${base}/api/quotes`, { method: 'POST', headers: { cookie: adminCookie, 'Content-Type': 'application/json' }, body: JSON.stringify({ customer_id: ownId, items: [ { sku: 'rounding-a', unit_price: 2.99995, qty: 1000 }, { sku: 'rounding-b', unit_price: 1.00005, qty: 10000 } ] }) }); assert.equal(res.status, 201, await res.clone().text()); const { items } = await res.json(); assert.deepEqual(items.map(i => [i.unit_price, i.total_price]), [[3, 3000], [1.0001, 10001]]); }); await t.test('multer 2 accepts contract multipart uploads', async () => { const form = new FormData(); form.append('files', new Blob(['SKU,Name,Price\nTEST-01,Test product,12.50']), 'regression.csv'); const res = await fetch(`${base}/api/contracts/import`, { method: 'POST', headers: { cookie: adminCookie }, body: form }); assert.equal(res.status, 201, await res.clone().text()); }); await t.test('only admins can start updates or inspect update logs', async () => { assert.equal((await fetch(`${base}/api/system/update/status`)).status, 401); assert.equal((await fetch(`${base}/api/system/update/status`, { headers: { cookie: repCookie } })).status, 403); assert.equal((await fetch(`${base}/api/system/update/status`, { headers: { cookie: adminCookie } })).status, 200); const post = async (cookie, password) => fetch(`${base}/api/system/update`, { method: 'POST', headers: { ...(cookie ? { cookie } : {}), 'Content-Type': 'application/json' }, body: JSON.stringify({ current_password: password }) }); assert.equal((await post('', 'admin123')).status, 401); assert.equal((await post(repCookie, 'test-password')).status, 403); const wrong = await post(adminCookie, 'wrong-password'); assert.equal(wrong.status, 400); assert.match((await wrong.json()).error, /密码不正确/); }); await t.test('profile cannot promote a sales rep to admin', async () => { const res = await fetch(`${base}/api/me`, { method: 'PATCH', headers: { cookie: repCookie, 'Content-Type': 'application/json' }, body: JSON.stringify({ role: 'admin' }) }); assert.equal(res.status, 403); assert.equal(db.prepare('SELECT role FROM users WHERE id = ?').get(repId).role, 'sales_rep'); }); await t.test('username changes verify password, preserve ownership and expire other sessions', async () => { const patch = async body => fetch(`${base}/api/me/username`, { method: 'PATCH', headers: { cookie: adminCookie, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); assert.equal((await patch({ username: 'renamed_admin' })).status, 400); assert.equal((await patch({ username: 'renamed_admin', current_password: 'wrong-password' })).status, 400); assert.equal((await patch({ username: 'REGRESSION_REP', current_password: 'admin123' })).status, 409); assert.equal((await patch({ username: 'bad name', current_password: 'admin123' })).status, 400); const otherSession = auth.createSession(admin.id); const res = await patch({ username: 'renamed_admin', current_password: 'admin123' }); assert.equal(res.status, 200, await res.clone().text()); const renamed = (await res.json()).user; assert.equal(renamed.id, admin.id); assert.equal(renamed.username, 'renamed_admin'); assert.equal(db.prepare('SELECT owner_id FROM customers WHERE id = ?').get(otherId).owner_id, admin.id); assert.equal(db.prepare('SELECT id FROM sessions WHERE id = ?').get(otherSession), undefined); assert.equal((await fetch(`${base}/api/auth/me`, { headers: { cookie: adminCookie } })).status, 200); const login = async username => fetch(`${base}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password: 'admin123' }) }); assert.equal((await login('admin')).status, 400); assert.equal((await login('renamed_admin')).status, 200); // 在第二个进程重新初始化数据库,改名后不能重建带默认密码的 admin。 const child = spawn(process.execPath, ['-e', "require('./db/db').close()"], { cwd: path.join(__dirname, '..'), env: { ...process.env, DATA_DIR: dataDir }, stdio: 'ignore' }); const [code] = await once(child, 'exit'); assert.equal(code, 0); assert.equal(db.prepare("SELECT id FROM users WHERE username = 'admin'").get(), undefined); }); await t.test('password verification locks repeated guesses before sensitive operations', async () => { for (let attempt = 1; attempt <= 5; attempt++) { const res = await fetch(`${base}/api/me/username`, { method: 'PATCH', headers: { cookie: repCookie, 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'another_name', current_password: 'wrong-password' }) }); assert.equal(res.status, attempt === 5 ? 429 : 400); } assert.equal((await fetch(`${base}/api/auth/me`, { headers: { cookie: repCookie } })).status, 401); const rep = db.prepare('SELECT username, locked_until FROM users WHERE id = ?').get(repId); assert.equal(rep.username, 'regression_rep'); assert.ok(Date.parse(rep.locked_until) > Date.now()); }); await t.test('amount words separate minor units and preserve group conjunctions', async () => { const { amountToWords: words } = await import(pathToFileURL(path.join(__dirname, '../../frontend/src/amountInWords.js'))); const cases = [ [100.01, 'SAY US DOLLARS ONE HUNDRED AND CENTS ONE ONLY'], [1005, 'SAY US DOLLARS ONE THOUSAND AND FIVE ONLY'], [1015, 'SAY US DOLLARS ONE THOUSAND AND FIFTEEN ONLY'], [2001, 'SAY US DOLLARS TWO THOUSAND AND ONE ONLY'], [1200, 'SAY US DOLLARS ONE THOUSAND TWO HUNDRED ONLY'], [1000005, 'SAY US DOLLARS ONE MILLION AND FIVE ONLY'], [0, 'SAY US DOLLARS ZERO ONLY'], [0.5, 'SAY US DOLLARS ZERO AND CENTS FIFTY ONLY'] ]; for (const [amount, expected] of cases) assert.equal(words(amount), expected); assert.equal(words(1e15, 'JPY'), 'SAY JAPANESE YEN ONE QUADRILLION ONLY'); assert.equal(words(1e20), ''); assert.equal(words('invalid'), ''); }); });