Files
sencho/backend/src/__tests__/notification-suppression-routes-api.test.ts
T
Anso 9859ce60b8 fix(notifications): version mute replica retractions for soft-cleanup restore (#1703)
* fix(notifications): version mute replica retractions for soft-cleanup restore

Soft cleanup and authoritative delete shared an unversioned permanent
tombstone, so a later hub re-save could not restore scheduled mutes on a
remote. Carry hub-authored kind and source_updated_at on replica DELETE,
allow recoverable recreate when updated_at is newer, keep permanent deletes
fail-closed, and reject stale recoverable DELETEs against newer rows.

* fix(notifications): durable mute retractions across mixed-version fleets

Gate recoverable replica DELETEs on a new capability, durable-queue failures
and incompatible remotes for retry, fan permanent deletes to every known
remote, and return applied vs ignored outcomes on replica writes.
2026-07-25 23:47:37 -04:00

1090 lines
39 KiB
TypeScript

/**
* 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<string, unknown>) => ({
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<string, unknown>) => ({
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<string, unknown>) => ({
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<string, unknown>) => ({
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<string, unknown>) => ({
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);
});
});