mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-28 04:38:59 +00:00
fix: fail closed on corrupt mute schedules and clean invalid replicas
Empty or whitespace stored schedules no longer act as all-day mutes. Invalid schedules trigger remote DELETE cleanup, and the weekly-window form gains accessibility attributes plus component coverage.
This commit is contained in:
@@ -103,5 +103,12 @@ describe('scheduleAllowsSuppression / stored parse', () => {
|
||||
expect(scheduleAllowsSuppression(null, true, Date.now())).toBe(false);
|
||||
expect(parseStoredNotificationSchedule('{not-json')).toEqual({ kind: 'invalid' });
|
||||
expect(parseStoredNotificationSchedule(null)).toEqual({ kind: 'null' });
|
||||
expect(parseStoredNotificationSchedule(undefined)).toEqual({ kind: 'null' });
|
||||
});
|
||||
|
||||
it('treats empty and whitespace strings as invalid, not legacy null', () => {
|
||||
expect(parseStoredNotificationSchedule('')).toEqual({ kind: 'invalid' });
|
||||
expect(parseStoredNotificationSchedule(' ')).toEqual({ kind: 'invalid' });
|
||||
expect(parseStoredNotificationSchedule('null')).toEqual({ kind: 'invalid' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { scheduleAllowsSuppression } from '../helpers/notificationSchedule';
|
||||
|
||||
function resetDatabaseSingleton(): void {
|
||||
const holder = DatabaseService as unknown as { instance?: DatabaseService };
|
||||
@@ -89,4 +90,46 @@ describe('notification suppression schedule column migration', () => {
|
||||
const cols2 = db2.getDb().prepare('PRAGMA table_info(notification_suppression_rules)').all() as Array<{ name: string }>;
|
||||
expect(cols2.filter((c) => c.name === 'schedule')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('empty-string schedule column is invalid and does not suppress via enabled load', { timeout: 60_000 }, () => {
|
||||
scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-supp-sched-empty-'));
|
||||
const dbPath = path.join(scratchDir, 'sencho.db');
|
||||
const seed = new Database(dbPath);
|
||||
try {
|
||||
seed.exec(`
|
||||
CREATE TABLE notification_suppression_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
node_id INTEGER NULL,
|
||||
stack_patterns TEXT NOT NULL,
|
||||
label_ids TEXT NULL,
|
||||
categories TEXT NULL,
|
||||
levels TEXT NULL,
|
||||
applies_to TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
expires_at INTEGER NULL,
|
||||
schedule TEXT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO notification_suppression_rules
|
||||
(name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, schedule, created_at, updated_at)
|
||||
VALUES ('Empty schedule mute', NULL, '[]', NULL, '["monitor_alert"]', NULL, 'both', 1, NULL, '', 1, 1);
|
||||
`);
|
||||
} finally {
|
||||
seed.close();
|
||||
}
|
||||
|
||||
prevDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = scratchDir;
|
||||
resetDatabaseSingleton();
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
const rule = db.getEnabledNotificationSuppressionRules().find((r) => r.name === 'Empty schedule mute');
|
||||
expect(rule).toBeDefined();
|
||||
expect(rule!.schedule).toBeNull();
|
||||
expect(rule!.scheduleInvalid).toBe(true);
|
||||
|
||||
expect(scheduleAllowsSuppression(rule!.schedule, rule!.scheduleInvalid, Date.now())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,16 +155,67 @@ describe('notificationSuppressionSync', () => {
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(true);
|
||||
});
|
||||
|
||||
it('skips sync when scheduleInvalid (does not POST as unscheduled)', async () => {
|
||||
it('scheduleInvalid: DELETE success, no POST', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: null,
|
||||
scheduleInvalid: true,
|
||||
}));
|
||||
await vi.waitFor(() => expect(warn).toHaveBeenCalled());
|
||||
await vi.waitFor(() => {
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica removed'))).toBe(true);
|
||||
});
|
||||
|
||||
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(false);
|
||||
});
|
||||
|
||||
it('scheduleInvalid: DELETE 404 counts as cleanup success, no POST', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 404, text: async () => 'gone' });
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: null,
|
||||
scheduleInvalid: true,
|
||||
}));
|
||||
await vi.waitFor(() => {
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica removed'))).toBe(true);
|
||||
});
|
||||
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
|
||||
});
|
||||
|
||||
it('scheduleInvalid: DELETE failure logs pending cleanup, no POST', async () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 503, text: async () => 'down' });
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: null,
|
||||
scheduleInvalid: true,
|
||||
}));
|
||||
await vi.waitFor(() => expect(error).toHaveBeenCalled());
|
||||
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(true);
|
||||
});
|
||||
|
||||
it('scheduleInvalid: no proxy target logs pending, no POST', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockGetProxyTarget.mockReturnValue(null);
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: null,
|
||||
scheduleInvalid: true,
|
||||
}));
|
||||
await vi.waitFor(() => expect(error).toHaveBeenCalled());
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('corrupt schedule'))).toBe(true);
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica removed'))).toBe(false);
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(true);
|
||||
});
|
||||
|
||||
it('unscheduled-to-scheduled on unsupported target attempts DELETE', async () => {
|
||||
|
||||
@@ -73,22 +73,29 @@ export function parseNotificationSchedule(raw: unknown): ParseNotificationSchedu
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a DB column value. Missing/null → legacy null (always in window).
|
||||
* Non-null corrupt JSON → invalid (caller must not suppress).
|
||||
* Parse a DB column value. Only SQL null / undefined → legacy null (always in window).
|
||||
* Empty string, whitespace, or any non-null corrupt value → invalid (must not suppress).
|
||||
*/
|
||||
export function parseStoredNotificationSchedule(
|
||||
raw: unknown,
|
||||
): { kind: 'null' } | { kind: 'ok'; schedule: NotificationSchedule } | { kind: 'invalid' } {
|
||||
if (raw == null || raw === '') return { kind: 'null' };
|
||||
let parsed: unknown = raw;
|
||||
if (raw == null) return { kind: 'null' };
|
||||
if (typeof raw === 'string') {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') return { kind: 'invalid' };
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { kind: 'invalid' };
|
||||
}
|
||||
// JSON null is not SQL NULL; treat as corrupt rather than legacy always-on.
|
||||
if (parsed == null) return { kind: 'invalid' };
|
||||
const result = parseNotificationSchedule(parsed);
|
||||
if (!result.ok) return { kind: 'invalid' };
|
||||
return { kind: 'ok', schedule: result.schedule };
|
||||
}
|
||||
const result = parseNotificationSchedule(parsed);
|
||||
const result = parseNotificationSchedule(raw);
|
||||
if (!result.ok) return { kind: 'invalid' };
|
||||
return { kind: 'ok', schedule: result.schedule };
|
||||
}
|
||||
|
||||
@@ -102,11 +102,25 @@ async function pushOrCleanupScheduled(node: Node, rule: NotificationSuppressionR
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupInvalidScheduleReplica(node: Node, rule: NotificationSuppressionRule): Promise<void> {
|
||||
// Never POST an invalid schedule (would mute all day on remotes that ignore the field).
|
||||
// Attempt DELETE so a prior valid/unscheduled replica cannot keep muting.
|
||||
try {
|
||||
await deleteRuleOnNode(node, rule.id);
|
||||
console.warn(
|
||||
`[SuppressionSync] Corrupt schedule on rule ${rule.id}: replica removed on node "${node.name}" (id=${node.id}); not posting`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[SuppressionSync] Corrupt schedule on rule ${rule.id}: cleanup pending on node "${node.name}" (id=${node.id}); ` +
|
||||
`DELETE failed (${getErrorMessage(err, String(err))}). Prior replica may remain until connectivity returns`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncRuleToNode(node: Node, rule: NotificationSuppressionRule): Promise<void> {
|
||||
if (rule.scheduleInvalid) {
|
||||
console.warn(
|
||||
`[SuppressionSync] Skipping push of rule ${rule.id} to node "${node.name}": corrupt schedule; not syncing as unscheduled`,
|
||||
);
|
||||
await cleanupInvalidScheduleReplica(node, rule);
|
||||
return;
|
||||
}
|
||||
if (rule.schedule != null) {
|
||||
|
||||
Reference in New Issue
Block a user