mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 05:58:37 +00:00
9859ce60b8
* 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.
1090 lines
39 KiB
TypeScript
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);
|
|
});
|
|
});
|