修复后台删不掉内容系统发布的文章,登录状态重启后不再失效
- 删除文章:先删引用它的发布记录和实体关联再删文章(之前顺序反了,数据库外键拒绝删除),并放在一个事务里,失败时不会只删掉一半 - 管理员登录状态存到数据库(admin_sessions,只存令牌哈希),服务重启或部署后不用重新登录;登录过期时后台回到登录页并提示,而不是操作一直失败 - 内容系统发布的文章可修改知识中心列表里的摘要 - 文章缺少扩展记录时,修改分类和发布时间也能保存 - 提示文字改为“约 1 分钟内在网站生效”(前台页面缓存 60 秒) - 新增不连数据库的单元测试 tests/adminFixes.unit.test.js(其他测试会写 .env 里的库) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
// Unit tests with the database replaced by a fake: the other suites in this folder write to the database in
|
||||
// .env, which is the production one, so these must never touch it.
|
||||
const statements = [];
|
||||
let rows = {};
|
||||
let failOn = null;
|
||||
|
||||
const fakeDb = {
|
||||
prepare(sql) {
|
||||
return {
|
||||
run: (...params) => {
|
||||
statements.push(sql);
|
||||
if (failOn && sql.startsWith(failOn)) throw new Error(`fake failure: ${failOn}`);
|
||||
// Like MySQL's foreign keys: content still referenced by a publication cannot be deleted.
|
||||
if (sql.startsWith('DELETE FROM contents') && rows.publication) throw new Error('ER_ROW_IS_REFERENCED_2');
|
||||
if (sql.startsWith('DELETE FROM integration_publications')) rows.publication = null;
|
||||
if (sql.startsWith('INSERT INTO admin_sessions')) rows.session = { token_hash: params[0], user_id: params[1], username: params[2], expires_at: params[3] };
|
||||
if (sql.startsWith('DELETE FROM admin_sessions WHERE token_hash')) rows.session = null;
|
||||
return { changes: 1 };
|
||||
},
|
||||
get: (...params) => {
|
||||
statements.push(sql);
|
||||
if (sql.includes('FROM contents')) return rows.content;
|
||||
if (sql.includes('FROM admin_users')) return rows.user;
|
||||
if (sql.includes('FROM admin_sessions')) return rows.session && rows.session.token_hash === params[0] ? rows.session : undefined;
|
||||
return undefined;
|
||||
},
|
||||
all: () => [],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('../src/config/database', () => ({ getDb: () => fakeDb, closeDb: () => {}, toMySqlParam: (v) => v }));
|
||||
|
||||
const crypto = require('crypto');
|
||||
const ContentService = require('../src/services/ContentService');
|
||||
const AdminAuthService = require('../src/services/AdminAuthService');
|
||||
|
||||
beforeEach(() => {
|
||||
statements.length = 0;
|
||||
failOn = null;
|
||||
rows = { content: { content_id: 'c1', content_type: 'article' }, publication: { content_id: 'c1' } };
|
||||
});
|
||||
|
||||
test('deleting a content-system article removes what points at it first, in one transaction', () => {
|
||||
ContentService.delete('c1');
|
||||
expect(statements.filter((sql) => !sql.startsWith('SELECT'))).toEqual([
|
||||
'START TRANSACTION',
|
||||
'DELETE FROM integration_publications WHERE content_id = ?',
|
||||
'DELETE FROM entity_content_joins WHERE content_id = ?',
|
||||
'DELETE FROM articles WHERE article_id = ?',
|
||||
'DELETE FROM contents WHERE content_id = ?',
|
||||
'COMMIT',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a failed delete is rolled back instead of leaving half an article', () => {
|
||||
failOn = 'DELETE FROM contents';
|
||||
expect(() => ContentService.delete('c1')).toThrow('fake failure');
|
||||
expect(statements[statements.length - 1]).toBe('ROLLBACK');
|
||||
expect(statements).not.toContain('COMMIT');
|
||||
});
|
||||
|
||||
test('admin logins are kept in the database, so a restart does not log the admin out', () => {
|
||||
const salt = 'ab'.repeat(16);
|
||||
rows.user = { user_id: 'u1', username: 'admin', password_hash: `${salt}:${crypto.scryptSync('secret-pass', salt, 64).toString('hex')}` };
|
||||
const { token } = AdminAuthService.login('admin', 'secret-pass');
|
||||
expect(rows.session.token_hash).not.toBe(token);
|
||||
jest.isolateModules(() => {
|
||||
// A fresh copy of the service stands in for the restarted server.
|
||||
const Restarted = require('../src/services/AdminAuthService');
|
||||
expect(Restarted.verify(token)).toMatchObject({ username: 'admin' });
|
||||
expect(Restarted.verify('wrong-token')).toBeNull();
|
||||
});
|
||||
AdminAuthService.logout(token);
|
||||
expect(AdminAuthService.verify(token)).toBeNull();
|
||||
});
|
||||
|
||||
test('an expired login is refused', () => {
|
||||
rows.session = { token_hash: crypto.createHash('sha256').update('t').digest('hex'), user_id: 'u1', username: 'admin', expires_at: Date.now() - 1 };
|
||||
expect(AdminAuthService.verify('t')).toBeNull();
|
||||
});
|
||||
Reference in New Issue
Block a user