// 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(); });