修复后台删不掉内容系统发布的文章,登录状态重启后不再失效
- 删除文章:先删引用它的发布记录和实体关联再删文章(之前顺序反了,数据库外键拒绝删除),并放在一个事务里,失败时不会只删掉一半 - 管理员登录状态存到数据库(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:
@@ -5,9 +5,9 @@ import { FormEvent, useEffect, useMemo, useState } from 'react';
|
|||||||
type Row = {
|
type Row = {
|
||||||
content_id: string; title: string; publish_status: string; created_at: string; updated_at: string;
|
content_id: string; title: string; publish_status: string; created_at: string; updated_at: string;
|
||||||
category?: string | null; tags?: string | null; author?: string | null; published_at?: string | null;
|
category?: string | null; tags?: string | null; author?: string | null; published_at?: string | null;
|
||||||
source: 'content_system' | 'manual'; public_path: string;
|
source: 'content_system' | 'manual'; public_path: string; summary?: string | null;
|
||||||
};
|
};
|
||||||
type Draft = { content_id: string; title: string; body: string; publish_status: string; category: string; tags: string; author: string; published_at: string; source?: Row['source'] };
|
type Draft = { content_id: string; title: string; body: string; publish_status: string; category: string; tags: string; author: string; published_at: string; summary: string; source?: Row['source'] };
|
||||||
type Request = (path: string, options?: RequestInit) => Promise<any>;
|
type Request = (path: string, options?: RequestInit) => Promise<any>;
|
||||||
|
|
||||||
const STATUS: Record<string, string> = { published: '已发布', draft: '草稿', review: '审核中', unpublished: '已下线', historical: '历史版本' };
|
const STATUS: Record<string, string> = { published: '已发布', draft: '草稿', review: '审核中', unpublished: '已下线', historical: '历史版本' };
|
||||||
@@ -26,7 +26,7 @@ function toText(value?: string | null) {
|
|||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
const empty = (): Draft => ({ content_id: '', title: '', body: '', publish_status: 'draft', category: '', tags: '', author: '阿美巴', published_at: '' });
|
const empty = (): Draft => ({ content_id: '', title: '', body: '', publish_status: 'draft', category: '', tags: '', author: '阿美巴', published_at: '', summary: '' });
|
||||||
|
|
||||||
/** Articles: search, filter, publish or take offline, edit, and delete one or many. */
|
/** Articles: search, filter, publish or take offline, edit, and delete one or many. */
|
||||||
export function ArticleManager({ request, onMessage, onError }: { request: Request; onMessage: (text: string) => void; onError: (text: string) => void }) {
|
export function ArticleManager({ request, onMessage, onError }: { request: Request; onMessage: (text: string) => void; onError: (text: string) => void }) {
|
||||||
@@ -72,23 +72,24 @@ export function ArticleManager({ request, onMessage, onError }: { request: Reque
|
|||||||
function toggle(row: Row) {
|
function toggle(row: Row) {
|
||||||
const next = row.publish_status === 'published' ? 'unpublished' : 'published';
|
const next = row.publish_status === 'published' ? 'unpublished' : 'published';
|
||||||
void run(() => request(`/api/admin/contents/article/${row.content_id}`, { method: 'PUT', body: JSON.stringify({ publish_status: next }) }),
|
void run(() => request(`/api/admin/contents/article/${row.content_id}`, { method: 'PUT', body: JSON.stringify({ publish_status: next }) }),
|
||||||
next === 'published' ? '文章已上线。' : '文章已下线,网站上不再显示。');
|
next === 'published' ? '文章已上线,网站约 1 分钟内显示。' : '文章已下线,网站约 1 分钟内不再显示。');
|
||||||
}
|
}
|
||||||
async function edit(row: Row) {
|
async function edit(row: Row) {
|
||||||
try {
|
try {
|
||||||
const full = await request(`/api/admin/contents/article/${row.content_id}`);
|
const full = await request(`/api/admin/contents/article/${row.content_id}`);
|
||||||
setEditing({ content_id: row.content_id, title: full.title || '', body: full.body || '', publish_status: full.publish_status || 'draft',
|
setEditing({ content_id: row.content_id, title: full.title || '', body: full.body || '', publish_status: full.publish_status || 'draft',
|
||||||
category: full.category || '', tags: full.tags || '', author: full.author || '', published_at: toInput(full.published_at), source: row.source });
|
category: full.category || '', tags: full.tags || '', author: full.author || '', published_at: toInput(full.published_at), summary: row.summary || '', source: row.source });
|
||||||
} catch (e: any) { onError(e.message); }
|
} catch (e: any) { onError(e.message); }
|
||||||
}
|
}
|
||||||
async function save(event: FormEvent) {
|
async function save(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
const data = { title: editing.title.trim(), body: editing.body, publish_status: editing.publish_status, category: editing.category,
|
const data = { title: editing.title.trim(), body: editing.body, publish_status: editing.publish_status, category: editing.category,
|
||||||
tags: editing.tags, author: editing.author, published_at: editing.published_at ? `${editing.published_at.replace('T', ' ')}:00` : '' };
|
tags: editing.tags, author: editing.author, published_at: editing.published_at ? `${editing.published_at.replace('T', ' ')}:00` : '',
|
||||||
|
...(editing.source === 'content_system' ? { summary: editing.summary } : {}) };
|
||||||
const path = editing.content_id ? `/api/admin/contents/article/${editing.content_id}` : '/api/admin/contents/article';
|
const path = editing.content_id ? `/api/admin/contents/article/${editing.content_id}` : '/api/admin/contents/article';
|
||||||
await run(async () => { await request(path, { method: editing.content_id ? 'PUT' : 'POST', body: JSON.stringify(data) }); setEditing(null); },
|
await run(async () => { await request(path, { method: editing.content_id ? 'PUT' : 'POST', body: JSON.stringify(data) }); setEditing(null); },
|
||||||
editing.content_id ? '修改已保存,网站上立即生效。' : '文章已新增。');
|
editing.content_id ? '修改已保存,网站约 1 分钟内更新。' : '文章已新增。');
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className="article-manager">
|
return <div className="article-manager">
|
||||||
@@ -134,8 +135,9 @@ export function ArticleManager({ request, onMessage, onError }: { request: Reque
|
|||||||
{editing && <div className="article-editor-backdrop" role="presentation" onMouseDown={(e) => { if (e.target === e.currentTarget) setEditing(null); }}>
|
{editing && <div className="article-editor-backdrop" role="presentation" onMouseDown={(e) => { if (e.target === e.currentTarget) setEditing(null); }}>
|
||||||
<form className="editor-form article-editor" onSubmit={save} role="dialog" aria-modal="true" aria-label={editing.content_id ? '编辑文章' : '新增文章'}>
|
<form className="editor-form article-editor" onSubmit={save} role="dialog" aria-modal="true" aria-label={editing.content_id ? '编辑文章' : '新增文章'}>
|
||||||
<header><h3>{editing.content_id ? '编辑文章' : '新增文章'}</h3><button type="button" className="admin-quiet-button" onClick={() => setEditing(null)}>关闭</button></header>
|
<header><h3>{editing.content_id ? '编辑文章' : '新增文章'}</h3><button type="button" className="admin-quiet-button" onClick={() => setEditing(null)}>关闭</button></header>
|
||||||
{editing.source === 'content_system' && <p className="article-note">这篇文章来自内容系统。这里的修改会立即生效,但内容系统再次发布同一篇文章时会覆盖这里的修改。</p>}
|
{editing.source === 'content_system' && <p className="article-note">这篇文章来自内容系统。这里的修改约 1 分钟内在网站生效,但内容系统再次发布同一篇文章时会覆盖这里的修改。</p>}
|
||||||
<label>标题<input value={editing.title} onChange={(e) => setEditing({ ...editing, title: e.target.value })} required /></label>
|
<label>标题<input value={editing.title} onChange={(e) => setEditing({ ...editing, title: e.target.value })} required /></label>
|
||||||
|
{editing.source === 'content_system' && <label>摘要(知识中心列表里显示)<textarea rows={3} value={editing.summary} onChange={(e) => setEditing({ ...editing, summary: e.target.value })} /></label>}
|
||||||
<label>正文<small>{editing.body.replace(/\s/g, '').length} 字</small><textarea rows={16} value={editing.body} onChange={(e) => setEditing({ ...editing, body: e.target.value })} /></label>
|
<label>正文<small>{editing.body.replace(/\s/g, '').length} 字</small><textarea rows={16} value={editing.body} onChange={(e) => setEditing({ ...editing, body: e.target.value })} /></label>
|
||||||
<div className="article-editor-grid">
|
<div className="article-editor-grid">
|
||||||
<label>分类<input value={editing.category} onChange={(e) => setEditing({ ...editing, category: e.target.value })} /></label>
|
<label>分类<input value={editing.category} onChange={(e) => setEditing({ ...editing, category: e.target.value })} /></label>
|
||||||
|
|||||||
@@ -129,7 +129,8 @@ type Setting = { setting_key: string; setting_value: string; setting_group: stri
|
|||||||
|
|
||||||
async function request(path: string, token: string, options: RequestInit = {}) {
|
async function request(path: string, token: string, options: RequestInit = {}) {
|
||||||
const response = await fetch(`${API_URL}${path}`, { ...options, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, ...(options.headers || {}) } });
|
const response = await fetch(`${API_URL}${path}`, { ...options, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, ...(options.headers || {}) } });
|
||||||
const data = response.status === 204 ? null : await response.json();
|
const data = response.status === 204 ? null : await response.json().catch(() => null);
|
||||||
|
if (response.status === 401 && path !== '/api/admin/login') { window.dispatchEvent(new Event('ameiba-admin-unauthorized')); throw new Error('登录已过期,请重新登录。'); }
|
||||||
if (!response.ok) throw new Error(data?.error || '操作没有完成,请稍后重试');
|
if (!response.ok) throw new Error(data?.error || '操作没有完成,请稍后重试');
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -143,6 +144,7 @@ export default function AdminPage() {
|
|||||||
const [editing, setEditing] = useState<ContentItem | null>(null); const [details, setDetails] = useState<Record<string, string>>({}); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
|
const [editing, setEditing] = useState<ContentItem | null>(null); const [details, setDetails] = useState<Record<string, string>>({}); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
|
||||||
|
|
||||||
useEffect(() => { const saved = window.localStorage.getItem('ameiba_admin_token'); if (saved) { setToken(saved); loadSettings(saved); } }, []);
|
useEffect(() => { const saved = window.localStorage.getItem('ameiba_admin_token'); if (saved) { setToken(saved); loadSettings(saved); } }, []);
|
||||||
|
useEffect(() => { const expired = () => { window.localStorage.removeItem('ameiba_admin_token'); setToken(''); setMessage(''); setError('登录已过期,请重新登录。'); }; window.addEventListener('ameiba-admin-unauthorized', expired); return () => window.removeEventListener('ameiba-admin-unauthorized', expired); }, []);
|
||||||
async function loadSettings(authToken = token) { try { setSettings(await request('/api/admin/settings', authToken)); } catch (e: any) { setError(e.message); } }
|
async function loadSettings(authToken = token) { try { setSettings(await request('/api/admin/settings', authToken)); } catch (e: any) { setError(e.message); } }
|
||||||
async function loadContents(contentType = type, authToken = token) { try { setContents((await request(`/api/admin/contents/${contentType}?pageSize=100`, authToken)).list || []); } catch (e: any) { setError(e.message); } }
|
async function loadContents(contentType = type, authToken = token) { try { setContents((await request(`/api/admin/contents/${contentType}?pageSize=100`, authToken)).list || []); } catch (e: any) { setError(e.message); } }
|
||||||
async function login(event: FormEvent) { event.preventDefault(); setError(''); try { const response = await fetch(`${API_URL}/api/admin/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); const data = await response.json(); if (!response.ok) throw new Error(data.error || '用户名或密码错误'); window.localStorage.setItem('ameiba_admin_token', data.token); setToken(data.token); setPassword(''); await loadSettings(data.token); } catch (e: any) { setError(e.message); } }
|
async function login(event: FormEvent) { event.preventDefault(); setError(''); try { const response = await fetch(`${API_URL}/api/admin/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); const data = await response.json(); if (!response.ok) throw new Error(data.error || '用户名或密码错误'); window.localStorage.setItem('ameiba_admin_token', data.token); setToken(data.token); setPassword(''); await loadSettings(data.token); } catch (e: any) { setError(e.message); } }
|
||||||
|
|||||||
@@ -165,6 +165,18 @@ const createTables = () => {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// 管理员登录状态(存库,服务重启后不用重新登录)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_sessions (
|
||||||
|
token_hash CHAR(64) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(36) NOT NULL,
|
||||||
|
username VARCHAR(64) NOT NULL,
|
||||||
|
expires_at BIGINT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_admin_sessions_username (username)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
// 前台可编辑站点配置
|
// 前台可编辑站点配置
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS site_settings (
|
CREATE TABLE IF NOT EXISTS site_settings (
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ class Article extends Content {
|
|||||||
const setClause = Object.keys(articleData).map(key => `${key} = ?`).join(', ');
|
const setClause = Object.keys(articleData).map(key => `${key} = ?`).join(', ');
|
||||||
const values = Object.values(articleData);
|
const values = Object.values(articleData);
|
||||||
|
|
||||||
|
db.prepare('INSERT IGNORE INTO articles (article_id) VALUES (?)').run(contentId);
|
||||||
const stmt = db.prepare(`UPDATE articles SET ${setClause} WHERE article_id = ?`);
|
const stmt = db.prepare(`UPDATE articles SET ${setClause} WHERE article_id = ?`);
|
||||||
stmt.run(...values, contentId);
|
stmt.run(...values, contentId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ router.get('/articles', (req, res) => {
|
|||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT c.content_id, c.title, c.publish_status, c.created_at, c.updated_at,
|
SELECT c.content_id, c.title, c.publish_status, c.created_at, c.updated_at,
|
||||||
a.category, a.tags, a.author, a.published_at,
|
a.category, a.tags, a.author, a.published_at,
|
||||||
p.publication_id, p.slug, p.source_system
|
p.publication_id, p.slug, p.source_system, p.summary
|
||||||
FROM contents c
|
FROM contents c
|
||||||
LEFT JOIN articles a ON a.article_id = c.content_id
|
LEFT JOIN articles a ON a.article_id = c.content_id
|
||||||
LEFT JOIN integration_publications p ON p.content_id = c.content_id
|
LEFT JOIN integration_publications p ON p.content_id = c.content_id
|
||||||
@@ -124,6 +124,9 @@ router.post('/contents/:type', (req, res) => {
|
|||||||
router.put('/contents/:type/:id', (req, res) => {
|
router.put('/contents/:type/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const data = { ...req.body };
|
const data = { ...req.body };
|
||||||
|
// The summary shown in the knowledge list belongs to the content system's publication record.
|
||||||
|
const summary = req.params.type === 'article' && typeof data.summary === 'string' ? data.summary.trim() : undefined;
|
||||||
|
delete data.summary;
|
||||||
if (req.params.type === 'article') {
|
if (req.params.type === 'article') {
|
||||||
// The admin form sends "2026-10-19T15:30" (local time); MySQL wants "2026-10-19 15:30:00".
|
// The admin form sends "2026-10-19T15:30" (local time); MySQL wants "2026-10-19 15:30:00".
|
||||||
if (typeof data.published_at === 'string') {
|
if (typeof data.published_at === 'string') {
|
||||||
@@ -137,7 +140,11 @@ router.put('/contents/:type/:id', (req, res) => {
|
|||||||
else delete data.published_at;
|
else delete data.published_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
res.json(ContentService.update(req.params.id, data, req.params.type));
|
const updated = ContentService.update(req.params.id, data, req.params.type);
|
||||||
|
if (summary !== undefined) {
|
||||||
|
getDb().prepare('UPDATE integration_publications SET summary = ? WHERE content_id = ?').run(summary, req.params.id);
|
||||||
|
}
|
||||||
|
res.json(updated);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({ error: error.message });
|
res.status(400).json({ error: error.message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ const crypto = require('crypto');
|
|||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const { getDb } = require('../config/database');
|
const { getDb } = require('../config/database');
|
||||||
|
|
||||||
const sessions = new Map();
|
const SESSION_MS = 8 * 60 * 60 * 1000;
|
||||||
|
// Only a hash of the token is stored, so the table never holds a usable login.
|
||||||
|
const tokenHash = (token) => crypto.createHash('sha256').update(String(token)).digest('hex');
|
||||||
|
|
||||||
const hashPassword = (password, salt = crypto.randomBytes(16).toString('hex')) => {
|
const hashPassword = (password, salt = crypto.randomBytes(16).toString('hex')) => {
|
||||||
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
|
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
|
||||||
@@ -23,17 +25,18 @@ class AdminAuthService {
|
|||||||
throw new Error('用户名或密码错误');
|
throw new Error('用户名或密码错误');
|
||||||
}
|
}
|
||||||
const token = crypto.randomBytes(32).toString('hex');
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
sessions.set(token, { userId: user.user_id, username: user.username, expiresAt: Date.now() + 8 * 60 * 60 * 1000 });
|
const db = getDb();
|
||||||
return { token, username: user.username, expiresIn: 8 * 60 * 60 };
|
db.prepare('DELETE FROM admin_sessions WHERE expires_at < ?').run(Date.now());
|
||||||
|
db.prepare('INSERT INTO admin_sessions (token_hash, user_id, username, expires_at) VALUES (?, ?, ?, ?)')
|
||||||
|
.run(tokenHash(token), user.user_id, user.username, Date.now() + SESSION_MS);
|
||||||
|
return { token, username: user.username, expiresIn: SESSION_MS / 1000 };
|
||||||
}
|
}
|
||||||
|
|
||||||
static verify(token) {
|
static verify(token) {
|
||||||
const session = sessions.get(token);
|
if (!token) return null;
|
||||||
if (!session || session.expiresAt < Date.now()) {
|
const row = getDb().prepare('SELECT user_id, username, expires_at FROM admin_sessions WHERE token_hash = ?').get(tokenHash(token));
|
||||||
if (token) sessions.delete(token);
|
if (!row || Number(row.expires_at) < Date.now()) return null;
|
||||||
return null;
|
return { userId: row.user_id, username: row.username, expiresAt: Number(row.expires_at) };
|
||||||
}
|
|
||||||
return session;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static changePassword(username, currentPassword, newPassword) {
|
static changePassword(username, currentPassword, newPassword) {
|
||||||
@@ -43,14 +46,12 @@ class AdminAuthService {
|
|||||||
if (!user || !verifyPassword(currentPassword, user.password_hash)) throw new Error('当前密码错误');
|
if (!user || !verifyPassword(currentPassword, user.password_hash)) throw new Error('当前密码错误');
|
||||||
db.prepare('UPDATE admin_users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE username = ?')
|
db.prepare('UPDATE admin_users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE username = ?')
|
||||||
.run(hashPassword(newPassword), username);
|
.run(hashPassword(newPassword), username);
|
||||||
for (const [token, session] of sessions.entries()) {
|
db.prepare('DELETE FROM admin_sessions WHERE username = ?').run(username);
|
||||||
if (session.username === username) sessions.delete(token);
|
|
||||||
}
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
static logout(token) {
|
static logout(token) {
|
||||||
sessions.delete(token);
|
if (token) getDb().prepare('DELETE FROM admin_sessions WHERE token_hash = ?').run(tokenHash(token));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -175,10 +175,21 @@ class ContentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Model = this.getModelByType(content.content_type);
|
const Model = this.getModelByType(content.content_type);
|
||||||
const result = Model.delete(contentId);
|
// MySQL refuses to delete content that other rows still point at, so those go first: the content system's
|
||||||
// A deleted article published by the content system must be publishable again: forget its publication record.
|
// publication record (which also lets the content system publish the article again) and entity links.
|
||||||
getDb().prepare('DELETE FROM integration_publications WHERE content_id = ?').run(contentId);
|
// One transaction, so a failure leaves the article whole instead of half deleted.
|
||||||
return result;
|
const db = getDb();
|
||||||
|
db.prepare('START TRANSACTION').run();
|
||||||
|
try {
|
||||||
|
db.prepare('DELETE FROM integration_publications WHERE content_id = ?').run(contentId);
|
||||||
|
db.prepare('DELETE FROM entity_content_joins WHERE content_id = ?').run(contentId);
|
||||||
|
const result = Model.delete(contentId);
|
||||||
|
db.prepare('COMMIT').run();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
db.prepare('ROLLBACK').run();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 内容状态流转
|
// 内容状态流转
|
||||||
|
|||||||
@@ -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