feat: add admin web updates and verified username changes
Add isolated update jobs with database backups, progress logs and application rollback. Remove default account hints and prevent default admin recreation after renaming. Enforce customer and order search scopes, secure session cookies, fix quotation rounding and amount wording, and update backend dependencies. Add API and deployment regression coverage.
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
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', '[email protected]']) {
|
||||
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'), '');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const Database = require('better-sqlite3-multiple-ciphers');
|
||||
const { executeUpdate } = require('../lib/updateWorker');
|
||||
|
||||
for (const mode of ['success', 'build-failure', 'restart-failure', 'health-failure', 'rollback-failure']) {
|
||||
test(`web update deployment: ${mode}`, async t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'happy-update-'));
|
||||
let live;
|
||||
t.after(() => {
|
||||
if (live?.open) live.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const data = path.join(root, 'backend/data');
|
||||
const dir = path.join(data, 'system-update');
|
||||
const jobDir = path.join(dir, 'test-job');
|
||||
const stage = path.join(jobDir, 'source');
|
||||
const modules = path.join(root, 'backend/node_modules');
|
||||
const dist = path.join(root, 'frontend/dist');
|
||||
for (const folder of [jobDir, modules, dist]) fs.mkdirSync(folder, { recursive: true });
|
||||
fs.writeFileSync(path.join(modules, 'marker'), 'old modules');
|
||||
fs.writeFileSync(path.join(dist, 'index.html'), 'old page');
|
||||
fs.writeFileSync(path.join(root, 'server-code'), 'old');
|
||||
fs.writeFileSync(path.join(dir, 'status.json'), JSON.stringify({ state: 'queued' }));
|
||||
fs.writeFileSync(path.join(dir, 'lock'), 'locked');
|
||||
live = new Database(path.join(data, 'trade.db'));
|
||||
live.pragma('journal_mode = WAL');
|
||||
live.exec('CREATE TABLE assets (value TEXT); INSERT INTO assets VALUES (\'real business data\')');
|
||||
const commands = [];
|
||||
const hooks = {
|
||||
run: async (command, args, cwd) => {
|
||||
commands.push([command, ...args]);
|
||||
if (command === 'git') {
|
||||
if (args[0] === 'rev-parse') return args[1] === 'HEAD' ? 'old-commit' : 'new-commit';
|
||||
if (args[0] === 'status') return '';
|
||||
if (args[0] === 'worktree' && args[1] === 'add') {
|
||||
for (const folder of ['backend/node_modules', 'frontend/dist']) fs.mkdirSync(path.join(stage, folder), { recursive: true });
|
||||
fs.writeFileSync(path.join(stage, 'package.json'), JSON.stringify({ version: '2.0.0' }));
|
||||
fs.writeFileSync(path.join(stage, 'backend/node_modules/marker'), 'new modules');
|
||||
fs.writeFileSync(path.join(stage, 'frontend/dist/index.html'), 'new page');
|
||||
}
|
||||
if (args[0] === 'reset') fs.writeFileSync(path.join(root, 'server-code'), args[2] === 'old-commit' ? 'old' : 'new');
|
||||
if (args[0] === 'worktree' && args[1] === 'remove') fs.rmSync(stage, { recursive: true, force: true });
|
||||
}
|
||||
if (command === 'npm' && args[0] === 'run' && mode === 'build-failure') throw new Error('build failed');
|
||||
if (command === 'systemctl' && args[0] === 'restart' && ['restart-failure', 'rollback-failure'].includes(mode)) throw new Error('restart failed');
|
||||
if (command === 'systemctl' && args[0] === 'start' && mode === 'rollback-failure') throw new Error('rollback failed');
|
||||
return '';
|
||||
},
|
||||
backup: destination => live.backup(destination),
|
||||
healthy: async expected => { if (expected && mode === 'health-failure') throw new Error('health check failed'); }
|
||||
};
|
||||
await executeUpdate({ root, data, dir, jobDir, service: 'erp', repo: 'owner/repo', tag: 'v2.0.0',
|
||||
version: '2.0.0', node: process.execPath, port: '4300' }, hooks);
|
||||
const status = JSON.parse(fs.readFileSync(path.join(dir, 'status.json'), 'utf8'));
|
||||
const success = mode === 'success';
|
||||
assert.equal(status.state, success ? 'completed' : 'failed');
|
||||
assert.equal(fs.readFileSync(path.join(modules, 'marker'), 'utf8'), success ? 'new modules' : 'old modules');
|
||||
assert.equal(fs.readFileSync(path.join(dist, 'index.html'), 'utf8'), success ? 'new page' : 'old page');
|
||||
assert.equal(fs.readFileSync(path.join(root, 'server-code'), 'utf8'), success ? 'new' : 'old');
|
||||
assert.equal(fs.existsSync(path.join(dir, 'lock')), false);
|
||||
assert.equal(live.prepare('SELECT value FROM assets').get().value, 'real business data');
|
||||
if (mode === 'build-failure') {
|
||||
assert.ok(!commands.some(args => args[0] === 'systemctl' || args[1] === 'reset'));
|
||||
} else {
|
||||
const backup = new Database(path.join(jobDir, 'trade-before-update.db'), { readonly: true });
|
||||
assert.equal(backup.prepare('SELECT value FROM assets').get().value, 'real business data');
|
||||
backup.close();
|
||||
}
|
||||
if (['restart-failure', 'health-failure'].includes(mode)) assert.match(status.message, /已恢复上一版/);
|
||||
if (mode === 'rollback-failure') assert.match(status.message, /自动恢复失败/);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user