/** * Integration tests for notification suppression rules CRUD. */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import request from 'supertest'; import bcrypt from 'bcrypt'; import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; let tmpDir: string; let app: import('express').Express; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; let authCookie: string; let viewerCookie: string; const validBody = { name: 'Mute staging', stack_patterns: ['staging'], categories: ['monitor_alert'], levels: ['warning'], applies_to: 'both', enabled: true, expires_at: null, }; beforeAll(async () => { tmpDir = await setupTestDb(); ({ DatabaseService } = await import('../services/DatabaseService')); const { LicenseService } = await import('../services/LicenseService'); vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); ({ app } = await import('../index')); authCookie = await loginAsTestAdmin(app); const viewerHash = await bcrypt.hash('viewerpass', 1); DatabaseService.getInstance().addUser({ username: 'viewer', password_hash: viewerHash, role: 'viewer' }); const viewerRes = await request(app) .post('/api/auth/login') .send({ username: 'viewer', password: 'viewerpass' }); const cookies = viewerRes.headers['set-cookie'] as string | string[]; viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies; }); afterAll(() => { cleanupTestDb(tmpDir); }); describe('Notification suppression - auth enforcement', () => { it('GET returns 401 without auth', async () => { const res = await request(app).get('/api/notification-suppression-rules'); expect(res.status).toBe(401); }); it('GET returns 403 for viewer', async () => { const res = await request(app) .get('/api/notification-suppression-rules') .set('Cookie', viewerCookie); expect(res.status).toBe(403); }); it('POST returns 403 for viewer', async () => { const res = await request(app) .post('/api/notification-suppression-rules') .set('Cookie', viewerCookie) .send(validBody); expect(res.status).toBe(403); }); }); describe('Notification suppression - CRUD', () => { it('POST creates a rule on Community tier', async () => { const res = await request(app) .post('/api/notification-suppression-rules') .set('Cookie', authCookie) .send(validBody); expect(res.status).toBe(201); expect(res.body.name).toBe('Mute staging'); expect(res.body.applies_to).toBe('both'); if (typeof res.body?.id === 'number') { DatabaseService.getInstance().deleteNotificationSuppressionRule(res.body.id); } }); it('GET lists rules', async () => { const res = await request(app) .get('/api/notification-suppression-rules') .set('Cookie', authCookie); expect(res.status).toBe(200); expect(Array.isArray(res.body)).toBe(true); }); it('POST rejects invalid applies_to', async () => { const res = await request(app) .post('/api/notification-suppression-rules') .set('Cookie', authCookie) .send({ ...validBody, applies_to: 'invalid' }); expect(res.status).toBe(400); }); it('POST rejects invalid levels', async () => { const res = await request(app) .post('/api/notification-suppression-rules') .set('Cookie', authCookie) .send({ ...validBody, levels: ['critical'] }); expect(res.status).toBe(400); }); it('POST accepts history-only category update_started (bell-visible)', async () => { const res = await request(app) .post('/api/notification-suppression-rules') .set('Cookie', authCookie) .send({ name: 'Mute stack updates', stack_patterns: [], categories: ['update_started'], levels: null, applies_to: 'both', enabled: true, expires_at: null, }); expect(res.status).toBe(201); expect(res.body.categories).toEqual(['update_started']); if (typeof res.body?.id === 'number') { DatabaseService.getInstance().deleteNotificationSuppressionRule(res.body.id); } }); it('POST accepts routable category image_update_available', async () => { const res = await request(app) .post('/api/notification-suppression-rules') .set('Cookie', authCookie) .send({ name: 'Mute image updates', stack_patterns: [], categories: ['image_update_available'], levels: null, applies_to: 'both', enabled: true, expires_at: null, }); expect(res.status).toBe(201); if (typeof res.body?.id === 'number') { DatabaseService.getInstance().deleteNotificationSuppressionRule(res.body.id); } }); it('PUT updates a rule', async () => { const created = await request(app) .post('/api/notification-suppression-rules') .set('Cookie', authCookie) .send(validBody); const id = created.body.id as number; const res = await request(app) .put(`/api/notification-suppression-rules/${id}`) .set('Cookie', authCookie) .send({ enabled: false }); expect(res.status).toBe(200); expect(res.body.enabled).toBe(false); DatabaseService.getInstance().deleteNotificationSuppressionRule(id); }); it('DELETE removes a rule', async () => { const created = await request(app) .post('/api/notification-suppression-rules') .set('Cookie', authCookie) .send(validBody); const id = created.body.id as number; const res = await request(app) .delete(`/api/notification-suppression-rules/${id}`) .set('Cookie', authCookie); expect(res.status).toBe(200); }); it('POST omits stack_patterns to []; rejects malformed and ReDoS patterns', async () => { const omitted = await request(app) .post('/api/notification-suppression-rules') .set('Cookie', authCookie) .send({ name: 'Mute omit', applies_to: 'both', }); expect(omitted.status).toBe(201); expect(omitted.body.stack_patterns).toEqual([]); DatabaseService.getInstance().deleteNotificationSuppressionRule(omitted.body.id); const bad = await request(app) .post('/api/notification-suppression-rules') .set('Cookie', authCookie) .send({ ...validBody, name: 'bad', stack_patterns: null }); expect(bad.status).toBe(400); const redos = await request(app) .post('/api/notification-suppression-rules') .set('Cookie', authCookie) .send({ ...validBody, name: 'redos', stack_patterns: ['****'] }); expect(redos.status).toBe(400); }); it('PUT enabled-only preserves patterns; explicit [] clears', async () => { const created = await request(app) .post('/api/notification-suppression-rules') .set('Cookie', authCookie) .send(validBody); const id = created.body.id as number; const partial = await request(app) .put(`/api/notification-suppression-rules/${id}`) .set('Cookie', authCookie) .send({ enabled: false }); expect(partial.status).toBe(200); expect(partial.body.enabled).toBe(false); expect(partial.body.stack_patterns).toEqual(['staging']); expect(partial.body.levels).toEqual(['warning']); const cleared = await request(app) .put(`/api/notification-suppression-rules/${id}`) .set('Cookie', authCookie) .send({ stack_patterns: [] }); expect(cleared.status).toBe(200); expect(cleared.body.stack_patterns).toEqual([]); DatabaseService.getInstance().deleteNotificationSuppressionRule(id); }); it('replica requires and validates stack_patterns', async () => { const jwt = await import('jsonwebtoken'); const { TEST_JWT_SECRET } = await import('./helpers/testConstants'); const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); const missing = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: { id: 910001, name: 'replica', applies_to: 'both', node_id: null, label_ids: null, categories: null, levels: null, enabled: true, expires_at: null, created_at: 1, updated_at: 1, }, }); expect(missing.status).toBe(400); const redos = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: { id: 910001, name: 'replica', applies_to: 'both', stack_patterns: ['****'], node_id: null, label_ids: null, categories: null, levels: null, enabled: true, expires_at: null, created_at: 1, updated_at: 1, }, }); expect(redos.status).toBe(400); expect(DatabaseService.getInstance().getNotificationSuppressionRule(910001)).toBeUndefined(); const ok = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: { id: 910001, name: 'replica', applies_to: 'both', stack_patterns: ['prod-*'], node_id: null, label_ids: null, categories: null, levels: null, enabled: true, expires_at: null, created_at: 1, updated_at: 1, }, }); expect(ok.status).toBe(200); expect(DatabaseService.getInstance().getNotificationSuppressionRule(910001)?.stack_patterns).toEqual(['prod-*']); expect(DatabaseService.getInstance().getNotificationSuppressionRule(910001)?.schedule).toBeNull(); DatabaseService.getInstance().deleteNotificationSuppressionRule(910001); const omitSched = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: { id: 910003, name: 'replica-omit-sched', applies_to: 'both', stack_patterns: [], node_id: null, label_ids: null, categories: null, levels: null, enabled: true, expires_at: null, created_at: 1, updated_at: 1, }, }); expect(omitSched.status).toBe(200); expect(DatabaseService.getInstance().getNotificationSuppressionRule(910003)?.schedule).toBeNull(); DatabaseService.getInstance().deleteNotificationSuppressionRule(910003); }); it('schedule: create omit null; PUT preserve; null clear; canonicalize days; reject invalid', async () => { const omitted = await request(app) .post('/api/notification-suppression-rules') .set('Cookie', authCookie) .send({ name: 'sched omit', applies_to: 'both' }); expect(omitted.status).toBe(201); expect(omitted.body.schedule).toBeNull(); const id = omitted.body.id as number; const withSched = await request(app) .put(`/api/notification-suppression-rules/${id}`) .set('Cookie', authCookie) .send({ schedule: { days: [3, 1], start_minute: 60, end_minute: 120, tz: 'UTC' }, }); expect(withSched.status).toBe(200); expect(withSched.body.schedule).toEqual({ days: [1, 3], start_minute: 60, end_minute: 120, tz: 'UTC', }); const preserved = await request(app) .put(`/api/notification-suppression-rules/${id}`) .set('Cookie', authCookie) .send({ enabled: false }); expect(preserved.status).toBe(200); expect(preserved.body.schedule).toEqual({ days: [1, 3], start_minute: 60, end_minute: 120, tz: 'UTC', }); const cleared = await request(app) .put(`/api/notification-suppression-rules/${id}`) .set('Cookie', authCookie) .send({ schedule: null }); expect(cleared.status).toBe(200); expect(cleared.body.schedule).toBeNull(); const bad = await request(app) .put(`/api/notification-suppression-rules/${id}`) .set('Cookie', authCookie) .send({ schedule: { days: [1], start_minute: 10, end_minute: 10, tz: 'UTC' } }); expect(bad.status).toBe(400); DatabaseService.getInstance().deleteNotificationSuppressionRule(id); }); it('replica rejects invalid schedule and accepts valid schedule', async () => { const jwt = await import('jsonwebtoken'); const { TEST_JWT_SECRET } = await import('./helpers/testConstants'); const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); const bad = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: { id: 920002, name: 'replica-sched', applies_to: 'both', stack_patterns: [], node_id: null, label_ids: null, categories: null, levels: null, enabled: true, expires_at: null, schedule: { days: [1], start_minute: 0, end_minute: 0, tz: 'UTC' }, created_at: 1, updated_at: 1, }, }); expect(bad.status).toBe(400); const ok = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: { id: 920002, name: 'replica-sched', applies_to: 'both', stack_patterns: [], node_id: null, label_ids: null, categories: null, levels: null, enabled: true, expires_at: null, schedule: { days: [6], start_minute: 1320, end_minute: 120, tz: 'UTC' }, created_at: 1, updated_at: 1, }, }); expect(ok.status).toBe(200); expect(DatabaseService.getInstance().getNotificationSuppressionRule(920002)?.schedule).toEqual({ days: [6], start_minute: 1320, end_minute: 120, tz: 'UTC', }); DatabaseService.getInstance().deleteNotificationSuppressionRule(920002); }); it('replica forces node_id to null regardless of the payload value', async () => { const jwt = await import('jsonwebtoken'); const { TEST_JWT_SECRET } = await import('./helpers/testConstants'); const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); const res = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: { id: 930004, name: 'replica-scoped', applies_to: 'both', stack_patterns: [], node_id: 5, label_ids: null, categories: null, levels: null, enabled: true, expires_at: null, created_at: 1, updated_at: 1, }, }); expect(res.status).toBe(200); expect(DatabaseService.getInstance().getNotificationSuppressionRule(930004)?.node_id).toBeNull(); DatabaseService.getInstance().deleteNotificationSuppressionRule(930004); }); it('replica ignores a stale write with an older updated_at than the stored row', async () => { const jwt = await import('jsonwebtoken'); const { TEST_JWT_SECRET } = await import('./helpers/testConstants'); const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); const replicaRule = (overrides: Record) => ({ id: 940005, name: 'replica-race', applies_to: 'both', stack_patterns: [], node_id: null, label_ids: null, categories: null, levels: null, enabled: true, expires_at: null, created_at: 1, ...overrides, }); const first = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: replicaRule({ name: 'v2-newer', updated_at: 2000 }) }); expect(first.status).toBe(200); const stale = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: replicaRule({ name: 'v1-delayed-stale', updated_at: 1000 }) }); expect(stale.status).toBe(200); expect(DatabaseService.getInstance().getNotificationSuppressionRule(940005)?.name).toBe('v2-newer'); const tie = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: replicaRule({ name: 'v2-tie-same-timestamp', updated_at: 2000 }) }); expect(tie.status).toBe(200); expect(DatabaseService.getInstance().getNotificationSuppressionRule(940005)?.name).toBe('v2-newer'); const newer = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: replicaRule({ name: 'v3-newest', updated_at: 3000 }) }); expect(newer.status).toBe(200); expect(DatabaseService.getInstance().getNotificationSuppressionRule(940005)?.name).toBe('v3-newest'); DatabaseService.getInstance().deleteNotificationSuppressionRule(940005); }); it('omitted-body replica DELETE is permanent; delayed POST cannot resurrect', async () => { const jwt = await import('jsonwebtoken'); const { TEST_JWT_SECRET } = await import('./helpers/testConstants'); const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); const replicaRule = (overrides: Record) => ({ id: 950006, name: 'replica-delete-race', applies_to: 'both', stack_patterns: [], node_id: null, label_ids: null, categories: null, levels: null, enabled: true, expires_at: null, created_at: 1, ...overrides, }); const first = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: replicaRule({ updated_at: 1000 }) }); expect(first.status).toBe(200); expect(DatabaseService.getInstance().getNotificationSuppressionRule(950006)).not.toBeUndefined(); const del = await request(app) .delete('/api/notification-suppression-rules/replica/950006') .set('Authorization', `Bearer ${token}`); expect(del.status).toBe(200); expect(DatabaseService.getInstance().getNotificationSuppressionRule(950006)).toBeUndefined(); // Omitted DELETE body (old hub) fails closed as permanent. A delayed POST // with any updated_at must stay blocked. const delayed = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: replicaRule({ updated_at: 2000 }) }); expect(delayed.status).toBe(200); expect(DatabaseService.getInstance().getNotificationSuppressionRule(950006)).toBeUndefined(); }); it('replica DELETE tombstones an id even when the remote never had that row', async () => { const jwt = await import('jsonwebtoken'); const { TEST_JWT_SECRET } = await import('./helpers/testConstants'); const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); // This remote never received rule 960007 (e.g. it just enrolled, or the rule // failed capability probing before its first push). A cleanup DELETE still // arrives unconditionally from deleteRuleOnNode. A POST reordered behind it // must not be allowed to create the rule for the first time. const del = await request(app) .delete('/api/notification-suppression-rules/replica/960007') .set('Authorization', `Bearer ${token}`); expect(del.status).toBe(200); expect(DatabaseService.getInstance().getNotificationSuppressionRule(960007)).toBeUndefined(); const delayed = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: { id: 960007, name: 'replica-delete-before-first-post', applies_to: 'both', stack_patterns: [], node_id: null, label_ids: null, categories: null, levels: null, enabled: true, expires_at: null, created_at: 1, updated_at: 1, }, }); expect(delayed.status).toBe(200); expect(DatabaseService.getInstance().getNotificationSuppressionRule(960007)).toBeUndefined(); }); it('replica does not resurrect a deleted rule with a schedule', async () => { const jwt = await import('jsonwebtoken'); const { TEST_JWT_SECRET } = await import('./helpers/testConstants'); const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); const replicaRule = (overrides: Record) => ({ id: 970008, name: 'replica-scheduled-delete-race', applies_to: 'both', stack_patterns: [], node_id: null, label_ids: null, categories: null, levels: null, enabled: true, expires_at: null, schedule: { days: [1], start_minute: 120, end_minute: 360, tz: 'UTC' }, created_at: 1, ...overrides, }); const first = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: replicaRule({ updated_at: 1000 }) }); expect(first.status).toBe(200); expect(first.body.outcome).toBe('applied'); expect(DatabaseService.getInstance().getNotificationSuppressionRule(970008)?.schedule).not.toBeNull(); // Capability-cleanup DELETE is recoverable at the pushed version. A delayed // re-push at the same or older watermark must not undo cleanup. const del = await request(app) .delete('/api/notification-suppression-rules/replica/970008') .set('Authorization', `Bearer ${token}`) .send({ kind: 'recoverable', source_updated_at: 1000 }); expect(del.status).toBe(200); expect(del.body.outcome).toBe('applied'); const delayed = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: replicaRule({ updated_at: 1000 }) }); expect(delayed.status).toBe(200); expect(delayed.body.outcome).toBe('ignored_recoverable_watermark'); expect(DatabaseService.getInstance().getNotificationSuppressionRule(970008)).toBeUndefined(); expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(970008)?.kind).toBe( 'recoverable', ); }); it('recoverable soft-cleanup allows recreate when hub re-save is newer', async () => { const jwt = await import('jsonwebtoken'); const { TEST_JWT_SECRET } = await import('./helpers/testConstants'); const replicaRule = (overrides: Record) => ({ id: 980009, name: 'replica-soft-cleanup-resave', applies_to: 'both', stack_patterns: [], node_id: null, label_ids: null, categories: null, levels: null, enabled: true, expires_at: null, schedule: { days: [2], start_minute: 60, end_minute: 120, tz: 'UTC' }, created_at: 1, ...overrides, }); // Receiver clock skew must not affect ordering. Sign the JWT under the same // mocked clock so exp verification stays valid. const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(9_000_000_000_000); const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1h' }); try { const first = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: replicaRule({ updated_at: 1000 }) }); expect(first.status).toBe(200); expect(first.body.outcome).toBe('applied'); const del = await request(app) .delete('/api/notification-suppression-rules/replica/980009') .set('Authorization', `Bearer ${token}`) .send({ kind: 'recoverable', source_updated_at: 1000 }); expect(del.status).toBe(200); expect(del.body.outcome).toBe('applied'); expect(DatabaseService.getInstance().getNotificationSuppressionRule(980009)).toBeUndefined(); const tie = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: replicaRule({ updated_at: 1000 }) }); expect(tie.status).toBe(200); expect(tie.body.outcome).toBe('ignored_recoverable_watermark'); expect(DatabaseService.getInstance().getNotificationSuppressionRule(980009)).toBeUndefined(); const resave = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: replicaRule({ name: 'replica-soft-cleanup-resave-v2', updated_at: 2000 }) }); expect(resave.status).toBe(200); expect(resave.body.outcome).toBe('applied'); const restored = DatabaseService.getInstance().getNotificationSuppressionRule(980009); expect(restored?.name).toBe('replica-soft-cleanup-resave-v2'); expect(restored?.updated_at).toBe(2000); expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(980009)).toBeUndefined(); } finally { nowSpy.mockRestore(); } }); it('permanent DELETE blocks any later POST regardless of updated_at', async () => { const jwt = await import('jsonwebtoken'); const { TEST_JWT_SECRET } = await import('./helpers/testConstants'); const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); const del = await request(app) .delete('/api/notification-suppression-rules/replica/990010') .set('Authorization', `Bearer ${token}`) .send({ kind: 'permanent', source_updated_at: 50 }); expect(del.status).toBe(200); expect(del.body.outcome).toBe('applied'); const post = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: { id: 990010, name: 'should-not-return', applies_to: 'both', stack_patterns: [], node_id: null, label_ids: null, categories: null, levels: null, enabled: true, expires_at: null, created_at: 1, updated_at: Number.MAX_SAFE_INTEGER, }, }); expect(post.status).toBe(200); expect(post.body.outcome).toBe('ignored_permanent_tombstone'); expect(DatabaseService.getInstance().getNotificationSuppressionRule(990010)).toBeUndefined(); expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(990010)?.kind).toBe( 'permanent', ); }); it('stale recoverable DELETE does not remove a newer stored row', async () => { const jwt = await import('jsonwebtoken'); const { TEST_JWT_SECRET } = await import('./helpers/testConstants'); const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); const replicaRule = (overrides: Record) => ({ id: 991011, name: 'v200', applies_to: 'both', stack_patterns: [], node_id: null, label_ids: null, categories: null, levels: null, enabled: true, expires_at: null, created_at: 1, ...overrides, }); const post = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: replicaRule({ updated_at: 200 }) }); expect(post.status).toBe(200); const del = await request(app) .delete('/api/notification-suppression-rules/replica/991011') .set('Authorization', `Bearer ${token}`) .send({ kind: 'recoverable', source_updated_at: 100 }); expect(del.status).toBe(200); expect(del.body.outcome).toBe('ignored_stale'); expect(DatabaseService.getInstance().getNotificationSuppressionRule(991011)?.updated_at).toBe(200); expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(991011)).toBeUndefined(); }); it('recoverable DELETE at exact stored version deletes and tombstones', async () => { const jwt = await import('jsonwebtoken'); const { TEST_JWT_SECRET } = await import('./helpers/testConstants'); const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: { id: 992012, name: 'exact-tie-delete', applies_to: 'both', stack_patterns: [], node_id: null, label_ids: null, categories: null, levels: null, enabled: true, expires_at: null, created_at: 1, updated_at: 150, }, }); const del = await request(app) .delete('/api/notification-suppression-rules/replica/992012') .set('Authorization', `Bearer ${token}`) .send({ kind: 'recoverable', source_updated_at: 150 }); expect(del.status).toBe(200); expect(DatabaseService.getInstance().getNotificationSuppressionRule(992012)).toBeUndefined(); const tomb = DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(992012); expect(tomb?.kind).toBe('recoverable'); expect(tomb?.source_updated_at).toBe(150); }); it('reordered recoverable tombstones keep the max watermark; permanent wins', async () => { const jwt = await import('jsonwebtoken'); const { TEST_JWT_SECRET } = await import('./helpers/testConstants'); const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); await request(app) .delete('/api/notification-suppression-rules/replica/993013') .set('Authorization', `Bearer ${token}`) .send({ kind: 'recoverable', source_updated_at: 200 }); await request(app) .delete('/api/notification-suppression-rules/replica/993013') .set('Authorization', `Bearer ${token}`) .send({ kind: 'recoverable', source_updated_at: 100 }); expect( DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(993013)?.source_updated_at, ).toBe(200); await request(app) .delete('/api/notification-suppression-rules/replica/993013') .set('Authorization', `Bearer ${token}`) .send({ kind: 'permanent', source_updated_at: 50 }); const tomb = DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(993013); expect(tomb?.kind).toBe('permanent'); expect(tomb?.source_updated_at).toBe(200); // Later recoverable cannot weaken permanent. await request(app) .delete('/api/notification-suppression-rules/replica/993013') .set('Authorization', `Bearer ${token}`) .send({ kind: 'recoverable', source_updated_at: 999 }); expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(993013)?.kind).toBe( 'permanent', ); }); it('partial or invalid DELETE body returns 400 without mutation', async () => { const jwt = await import('jsonwebtoken'); const { TEST_JWT_SECRET } = await import('./helpers/testConstants'); const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: { id: 994014, name: 'keep-me', applies_to: 'both', stack_patterns: [], node_id: null, label_ids: null, categories: null, levels: null, enabled: true, expires_at: null, created_at: 1, updated_at: 10, }, }); const cases: object[] = [ { kind: 'recoverable' }, { source_updated_at: 1 }, { kind: 'nope', source_updated_at: 1 }, { kind: 'recoverable', source_updated_at: 1.5 }, { kind: 'recoverable', source_updated_at: -1 }, { kind: 'recoverable', source_updated_at: '1' }, { kind: 'recoverable', source_updated_at: null }, { kind: 'recoverable', source_updated_at: Number.MAX_SAFE_INTEGER + 1 }, ]; for (const body of cases) { const res = await request(app) .delete('/api/notification-suppression-rules/replica/994014') .set('Authorization', `Bearer ${token}`) .send(body); expect(res.status).toBe(400); } expect(DatabaseService.getInstance().getNotificationSuppressionRule(994014)?.name).toBe('keep-me'); expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(994014)).toBeUndefined(); }); it('replica POST rejects missing or invalid created_at and updated_at', async () => { const jwt = await import('jsonwebtoken'); const { TEST_JWT_SECRET } = await import('./helpers/testConstants'); const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); const base = { id: 995015, name: 'bad-ts', applies_to: 'both', stack_patterns: [], node_id: null, label_ids: null, categories: null, levels: null, enabled: true, expires_at: null, }; for (const rule of [ { ...base, updated_at: 1 }, { ...base, created_at: 1 }, { ...base, created_at: -1, updated_at: 1 }, { ...base, created_at: 1, updated_at: 1.5 }, { ...base, created_at: 1, updated_at: '1' }, ]) { const res = await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule }); expect(res.status).toBe(400); } expect(DatabaseService.getInstance().getNotificationSuppressionRule(995015)).toBeUndefined(); }); it('permanent DELETE removes a newer stored row', async () => { const jwt = await import('jsonwebtoken'); const { TEST_JWT_SECRET } = await import('./helpers/testConstants'); const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); await request(app) .post('/api/notification-suppression-rules/replica') .set('Authorization', `Bearer ${token}`) .send({ rule: { id: 999019, name: 'newer-row', applies_to: 'both', stack_patterns: [], node_id: null, label_ids: null, categories: null, levels: null, enabled: true, expires_at: null, created_at: 1, updated_at: 200, }, }); const del = await request(app) .delete('/api/notification-suppression-rules/replica/999019') .set('Authorization', `Bearer ${token}`) .send({ kind: 'permanent', source_updated_at: 50 }); expect(del.status).toBe(200); expect(DatabaseService.getInstance().getNotificationSuppressionRule(999019)).toBeUndefined(); expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(999019)?.kind).toBe( 'permanent', ); }); it('empty JSON DELETE body fails closed as permanent', async () => { const jwt = await import('jsonwebtoken'); const { TEST_JWT_SECRET } = await import('./helpers/testConstants'); const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); const del = await request(app) .delete('/api/notification-suppression-rules/replica/999020') .set('Authorization', `Bearer ${token}`) .set('Content-Type', 'application/json') .send({}); expect(del.status).toBe(200); expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(999020)?.kind).toBe( 'permanent', ); expect( DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(999020)?.source_updated_at, ).toBe(0); }); it('one-arg deleteNotificationSuppressionRule defaults to permanent', () => { DatabaseService.getInstance().upsertNotificationSuppressionRuleReplica({ id: 996016, name: 'one-arg', applies_to: 'both', stack_patterns: [], node_id: null, label_ids: null, categories: null, levels: null, enabled: true, expires_at: null, schedule: null, scheduleInvalid: false, created_at: 1, updated_at: 5, }); DatabaseService.getInstance().deleteNotificationSuppressionRule(996016); expect(DatabaseService.getInstance().getNotificationSuppressionRule(996016)).toBeUndefined(); expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(996016)?.kind).toBe( 'permanent', ); }); it('delete rolls back when tombstone upsert fails', () => { const db = DatabaseService.getInstance(); db.upsertNotificationSuppressionRuleReplica({ id: 997017, name: 'atomic-del', applies_to: 'both', stack_patterns: [], node_id: null, label_ids: null, categories: null, levels: null, enabled: true, expires_at: null, schedule: null, scheduleInvalid: false, created_at: 1, updated_at: 7, }); const raw = db.getDb(); const orig = raw.prepare.bind(raw); const spy = vi.spyOn(raw, 'prepare').mockImplementation(((sql: string) => { if ( typeof sql === 'string' && sql.includes('INSERT INTO notification_suppression_rule_tombstones') ) { throw new Error('forced tombstone upsert failure'); } return orig(sql); }) as typeof raw.prepare); expect(() => db.deleteNotificationSuppressionRule(997017, { kind: 'recoverable', source_updated_at: 7, }), ).toThrow(/forced tombstone upsert failure/); spy.mockRestore(); expect(db.getNotificationSuppressionRule(997017)?.name).toBe('atomic-del'); expect(db.getNotificationSuppressionRuleTombstone(997017)).toBeUndefined(); }); it('recoverable recreate rolls back when insert fails after tombstone clear attempt', () => { const db = DatabaseService.getInstance(); db.deleteNotificationSuppressionRule(998018, { kind: 'recoverable', source_updated_at: 10, }); expect(db.getNotificationSuppressionRuleTombstone(998018)?.kind).toBe('recoverable'); const raw = db.getDb(); const orig = raw.prepare.bind(raw); const spy = vi.spyOn(raw, 'prepare').mockImplementation(((sql: string) => { if ( typeof sql === 'string' && sql.includes('INSERT INTO notification_suppression_rules') ) { throw new Error('forced replica insert failure'); } return orig(sql); }) as typeof raw.prepare); expect(() => db.upsertNotificationSuppressionRuleReplica({ id: 998018, name: 'recreate-fail', applies_to: 'both', stack_patterns: [], node_id: null, label_ids: null, categories: null, levels: null, enabled: true, expires_at: null, schedule: null, scheduleInvalid: false, created_at: 1, updated_at: 20, }), ).toThrow(/forced replica insert failure/); spy.mockRestore(); expect(db.getNotificationSuppressionRule(998018)).toBeUndefined(); expect(db.getNotificationSuppressionRuleTombstone(998018)?.kind).toBe('recoverable'); expect(db.getNotificationSuppressionRuleTombstone(998018)?.source_updated_at).toBe(10); }); });