Files
sencho/backend/src/__tests__/database-cascade.test.ts
T
Anso a17b16b258 fix(scheduler): harden auto-update policies with cascade deletes, error reporting, and UI fixes (#545)
* fix(scheduler): harden auto-update policies with cascade deletes, error reporting, and UI fixes

- Fix orphaned task runs on policy/node deletion with transaction-wrapped cascade deletes
- Make manual trigger non-blocking (202 Accepted) to prevent proxy timeouts
- Distinguish registry check failures from clean "no update" results via structured ImageCheckResult
- Trim whitespace-only policy names in both frontend and backend validation
- Add strokeWidth={1.5} to action icons per design system
- Add sr-only DialogDescription for Radix accessibility
- Replace Select with Combobox for frequency picker
- Wrap run history sheet content in ScrollArea
- Support concurrent Run Now indicators via Set-based state
- Abort stale stack fetches on node switch with AbortController
- Add standard and diagnostic logging to SchedulerService and ImageUpdateService
- Add tests for cascade deletes, image checking, and scheduler edge cases
- Add troubleshooting section to auto-update docs

* fix(tests): resolve lint errors in image-update-service tests

Remove unused mock variables (mockGetImage, mockGetDocker) and unused
ImageCheckResult type import. Replace CommonJS require('yaml') with
ESM import to satisfy no-require-imports rule.

* chore(deps): bump Docker CLI to 29.4.0 and Compose to v5.1.2

Resolves Trivy CVE-2026-32282 (Go stdlib symlink follow in Root.Chmod)
by upgrading to releases that ship Go 1.25.9. Compose v5.1.2 also bumps
grpc to 1.80.0, resolving CVE-2026-33186.

* chore(security): accept CVE-2026-32282 in .trivyignore, update stale refs

Go stdlib symlink-following in Root.Chmod (CVE-2026-32282) affects both
Docker CLI 29.4.0 (Go 1.26.1) and Compose v5.1.2 (Go 1.25.8). Fix
requires Go 1.25.9 or 1.26.2; no upstream static binary ships a patched
runtime yet. The vulnerable code path requires a chroot context with
attacker-controlled filesystem, which does not apply to our usage.

Also updates version references from v5.1.1/v29.3.1 to v5.1.2/v29.4.0
for existing CVE entries, and notes that Compose v5.1.2 resolved
CVE-2026-33186 (grpc bumped to 1.80.0) for the compose binary.
2026-04-13 09:49:11 -04:00

220 lines
8.0 KiB
TypeScript

/**
* Tests for cascade delete behavior in DatabaseService.
*
* Verifies that deleting a scheduled task also removes its runs,
* and deleting a node cascades through tasks and their runs.
*
* Uses an in-memory SQLite database (via better-sqlite3 directly)
* to test actual SQL behavior without touching disk.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
// Minimal schema for the tables we need
const SCHEMA = `
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'local',
status TEXT DEFAULT 'online',
is_default INTEGER DEFAULT 0,
api_url TEXT,
api_token TEXT
);
CREATE TABLE IF NOT EXISTS scheduled_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
target_type TEXT NOT NULL DEFAULT 'stack',
target_id TEXT,
node_id INTEGER,
action TEXT NOT NULL DEFAULT 'update',
cron_expression TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
created_by TEXT NOT NULL DEFAULT 'admin',
created_at INTEGER,
updated_at INTEGER,
last_run_at INTEGER,
next_run_at INTEGER,
last_status TEXT,
last_error TEXT,
prune_targets TEXT,
target_services TEXT,
prune_label_filter TEXT,
FOREIGN KEY(node_id) REFERENCES nodes(id)
);
CREATE TABLE IF NOT EXISTS scheduled_task_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
started_at INTEGER NOT NULL,
completed_at INTEGER,
status TEXT NOT NULL DEFAULT 'running',
output TEXT,
error TEXT,
triggered_by TEXT DEFAULT 'scheduler',
FOREIGN KEY(task_id) REFERENCES scheduled_tasks(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS stack_update_status (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL,
stack_name TEXT NOT NULL,
has_update INTEGER DEFAULT 0,
checked_at INTEGER
);
`;
describe('Cascade delete behavior (in-memory SQLite)', () => {
let db: Database.Database;
beforeEach(() => {
db = new Database(':memory:');
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.prepare(SCHEMA.split(';').filter(s => s.trim())[0] + ';').run();
db.prepare(SCHEMA.split(';').filter(s => s.trim())[1] + ';').run();
db.prepare(SCHEMA.split(';').filter(s => s.trim())[2] + ';').run();
db.prepare(SCHEMA.split(';').filter(s => s.trim())[3] + ';').run();
});
afterEach(() => {
db.close();
});
function insertNode(name = 'test-node'): number {
return db.prepare(
'INSERT INTO nodes (name, type, status, is_default) VALUES (?, ?, ?, ?)'
).run(name, 'local', 'online', 0).lastInsertRowid as number;
}
function insertTask(nodeId: number, name = 'test-task'): number {
return db.prepare(
'INSERT INTO scheduled_tasks (name, node_id, action, cron_expression, created_by) VALUES (?, ?, ?, ?, ?)'
).run(name, nodeId, 'update', '0 3 * * *', 'admin').lastInsertRowid as number;
}
function insertRun(taskId: number): number {
return db.prepare(
'INSERT INTO scheduled_task_runs (task_id, started_at, status, triggered_by) VALUES (?, ?, ?, ?)'
).run(taskId, Date.now(), 'success', 'scheduler').lastInsertRowid as number;
}
function insertStackStatus(nodeId: number, stackName: string): void {
db.prepare(
'INSERT INTO stack_update_status (node_id, stack_name, has_update, checked_at) VALUES (?, ?, ?, ?)'
).run(nodeId, stackName, 0, Date.now());
}
function countRows(table: string): number {
return (db.prepare(`SELECT COUNT(*) as c FROM ${table}`).get() as { c: number }).c;
}
// ── deleteScheduledTask cascade ─────────────────────────────────────
describe('deleteScheduledTask cascade', () => {
it('removes associated runs when deleting a task', () => {
const nodeId = insertNode();
const taskId = insertTask(nodeId);
insertRun(taskId);
insertRun(taskId);
insertRun(taskId);
expect(countRows('scheduled_task_runs')).toBe(3);
// Simulate the cascade delete (same logic as DatabaseService.deleteScheduledTask)
db.transaction(() => {
db.prepare('DELETE FROM scheduled_task_runs WHERE task_id = ?').run(taskId);
db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(taskId);
})();
expect(countRows('scheduled_tasks')).toBe(0);
expect(countRows('scheduled_task_runs')).toBe(0);
});
it('only removes runs for the deleted task, not other tasks', () => {
const nodeId = insertNode();
const task1 = insertTask(nodeId, 'task-1');
const task2 = insertTask(nodeId, 'task-2');
insertRun(task1);
insertRun(task1);
insertRun(task2);
insertRun(task2);
expect(countRows('scheduled_task_runs')).toBe(4);
db.transaction(() => {
db.prepare('DELETE FROM scheduled_task_runs WHERE task_id = ?').run(task1);
db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(task1);
})();
expect(countRows('scheduled_tasks')).toBe(1);
expect(countRows('scheduled_task_runs')).toBe(2);
// Remaining runs belong to task2
const remaining = db.prepare('SELECT task_id FROM scheduled_task_runs').all() as { task_id: number }[];
expect(remaining.every(r => r.task_id === task2)).toBe(true);
});
});
// ── deleteNode cascade ──────────────────────────────────────────────
describe('deleteNode cascade', () => {
it('removes tasks, runs, and status when deleting a node', () => {
const nodeId = insertNode();
const task1 = insertTask(nodeId, 'task-a');
const task2 = insertTask(nodeId, 'task-b');
insertRun(task1);
insertRun(task1);
insertRun(task2);
insertStackStatus(nodeId, 'my-stack');
expect(countRows('nodes')).toBe(1);
expect(countRows('scheduled_tasks')).toBe(2);
expect(countRows('scheduled_task_runs')).toBe(3);
expect(countRows('stack_update_status')).toBe(1);
// Simulate the cascade delete (same logic as DatabaseService.deleteNode)
db.transaction(() => {
db.prepare('DELETE FROM scheduled_task_runs WHERE task_id IN (SELECT id FROM scheduled_tasks WHERE node_id = ?)').run(nodeId);
db.prepare('DELETE FROM scheduled_tasks WHERE node_id = ?').run(nodeId);
db.prepare('DELETE FROM stack_update_status WHERE node_id = ?').run(nodeId);
db.prepare('DELETE FROM nodes WHERE id = ?').run(nodeId);
})();
expect(countRows('nodes')).toBe(0);
expect(countRows('scheduled_tasks')).toBe(0);
expect(countRows('scheduled_task_runs')).toBe(0);
expect(countRows('stack_update_status')).toBe(0);
});
it('does not affect other nodes or their data', () => {
const node1 = insertNode('node-1');
const node2 = insertNode('node-2');
const task1 = insertTask(node1, 'task-node1');
const task2 = insertTask(node2, 'task-node2');
insertRun(task1);
insertRun(task2);
insertStackStatus(node1, 'stack-1');
insertStackStatus(node2, 'stack-2');
// Delete node1
db.transaction(() => {
db.prepare('DELETE FROM scheduled_task_runs WHERE task_id IN (SELECT id FROM scheduled_tasks WHERE node_id = ?)').run(node1);
db.prepare('DELETE FROM scheduled_tasks WHERE node_id = ?').run(node1);
db.prepare('DELETE FROM stack_update_status WHERE node_id = ?').run(node1);
db.prepare('DELETE FROM nodes WHERE id = ?').run(node1);
})();
// node2 data should be untouched
expect(countRows('nodes')).toBe(1);
expect(countRows('scheduled_tasks')).toBe(1);
expect(countRows('scheduled_task_runs')).toBe(1);
expect(countRows('stack_update_status')).toBe(1);
const remainingNode = db.prepare('SELECT name FROM nodes').get() as { name: string };
expect(remainingNode.name).toBe('node-2');
});
});
});