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.
This commit is contained in:
Anso
2026-07-25 23:47:37 -04:00
committed by GitHub
parent 6688da97b1
commit 9859ce60b8
11 changed files with 1601 additions and 157 deletions
@@ -506,7 +506,7 @@ describe('Notification suppression - CRUD', () => {
DatabaseService.getInstance().deleteNotificationSuppressionRule(940005);
});
it('replica does not resurrect a rule after it was deleted, even with a newer updated_at', async () => {
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' });
@@ -538,9 +538,8 @@ describe('Notification suppression - CRUD', () => {
expect(del.status).toBe(200);
expect(DatabaseService.getInstance().getNotificationSuppressionRule(950006)).toBeUndefined();
// A delayed POST arrives after the DELETE, reordered by the network. Even
// though its updated_at is newer than anything the sender ever sent before
// the delete, the delete is authoritative: this id must stay gone.
// 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}`)
@@ -612,21 +611,479 @@ describe('Notification suppression - CRUD', () => {
.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();
// This is the worst case the fix protects: a capability-cleanup DELETE
// retracts an all-day/scheduled mute from a node that stopped supporting
// it. A delayed re-push of the scheduled rule must not undo that cleanup.
// 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}`);
.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: 2000 }) });
.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);
});
});
@@ -1,5 +1,6 @@
/**
* Fleet sync for suppression rules: node_id normalize, capability gate, stale DELETE.
* Fleet sync for suppression rules: node_id normalize, capability gate, stale DELETE,
* durable pending retractions, permanent fan-out to all remotes.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
@@ -8,12 +9,18 @@ const mockGetProxyTarget = vi.fn();
const mockGetNodes = vi.fn();
const mockGetNode = vi.fn();
const mockRemoteAdvertises = vi.fn();
const mockUpsertPending = vi.fn();
const mockDeletePending = vi.fn();
const mockListPending = vi.fn();
vi.mock('../services/DatabaseService', () => ({
DatabaseService: {
getInstance: () => ({
getNodes: mockGetNodes,
getNode: mockGetNode,
upsertNotificationSuppressionPendingRetraction: mockUpsertPending,
deleteNotificationSuppressionPendingRetraction: mockDeletePending,
listNotificationSuppressionPendingRetractions: mockListPending,
}),
},
}));
@@ -46,6 +53,7 @@ import {
syncSuppressionRuleToFleet,
syncSuppressionRuleUpdateToFleet,
replicationTargetIds,
flushPendingSuppressionRetractions,
} from '../helpers/notificationSuppressionSync';
import type { NotificationSuppressionRule } from '../services/DatabaseService';
@@ -72,6 +80,21 @@ function makeRule(overrides: Partial<NotificationSuppressionRule> = {}): Notific
const remoteA = { id: 10, name: 'remote-a', type: 'remote' as const };
const remoteB = { id: 11, name: 'remote-b', type: 'remote' as const };
const RETRACTION_CAP = 'notification-suppression-replica-retraction';
function metaWith(...caps: string[]) {
return { capabilities: caps, online: true };
}
function okDeleteResponse(outcome = 'applied') {
return {
ok: true,
status: 200,
text: async () => '',
json: async () => ({ success: true, outcome }),
};
}
describe('notificationSuppressionSync', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -83,8 +106,10 @@ describe('notificationSuppressionSync', () => {
apiUrl: `http://node-${id}.example:1852`,
apiToken: 'tok',
}));
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
mockFetch.mockResolvedValue(okDeleteResponse());
mockRemoteAdvertises.mockResolvedValue(true);
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP, 'notification-suppression-schedule'));
mockListPending.mockReturnValue([]);
});
afterEach(() => {
@@ -100,6 +125,7 @@ describe('notificationSuppressionSync', () => {
syncSuppressionRuleToFleet(makeRule({ schedule: null, node_id: 10 }));
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
expect(mockRemoteAdvertises).not.toHaveBeenCalled();
expect(mockFetchMeta).not.toHaveBeenCalled();
const body = JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body);
expect(body.rule.node_id).toBeNull();
expect(body.rule.schedule).toBeNull();
@@ -121,44 +147,99 @@ describe('notificationSuppressionSync', () => {
expect(body.rule.schedule.days).toEqual([6]);
});
it('probe false + DELETE success: no POST; cleanup logged as removed', async () => {
it('schedule unsupported + retraction supported: recoverable DELETE, no POST', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
mockRemoteAdvertises.mockResolvedValue(false);
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
syncSuppressionRuleToFleet(makeRule({
node_id: 10,
updated_at: 555,
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
}));
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica was removed'))).toBe(true);
expect(JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body)).toEqual({
kind: 'recoverable',
source_updated_at: 555,
});
expect(warn.mock.calls.some((c) => String(c[0]).includes('recoverable DELETE applied'))).toBe(true);
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(false);
expect(mockUpsertPending).not.toHaveBeenCalled();
expect(mockDeletePending).toHaveBeenCalledWith(42, 10);
});
it('probe false + no proxy target: no successful-cleanup claim', async () => {
it('schedule unsupported + retraction unsupported: no DELETE; queues pending', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
mockRemoteAdvertises.mockResolvedValue(false);
mockFetchMeta.mockResolvedValue(metaWith('notification-suppression'));
syncSuppressionRuleToFleet(makeRule({
node_id: 10,
updated_at: 555,
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
}));
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
expect(mockFetch).not.toHaveBeenCalled();
expect(warn.mock.calls.some((c) => String(c[0]).includes('queued pending retraction'))).toBe(true);
expect(mockUpsertPending).toHaveBeenCalledWith(
expect.objectContaining({
rule_id: 42,
node_id: 10,
kind: 'recoverable',
source_updated_at: 555,
}),
);
});
it('schedule unsupported + probe unreachable (offline meta): no DELETE; queues pending', async () => {
mockRemoteAdvertises.mockResolvedValue(false);
mockFetchMeta.mockResolvedValue({ capabilities: [], online: false });
syncSuppressionRuleToFleet(makeRule({
node_id: 10,
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
}));
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
expect(mockFetch).not.toHaveBeenCalled();
expect(mockUpsertPending.mock.calls[0][0].last_error).toMatch(/unreachable/);
});
it('schedule unsupported + probe unreachable (throw): no DELETE; queues pending', async () => {
mockRemoteAdvertises.mockResolvedValue(false);
mockFetchMeta.mockRejectedValue(new Error('timeout'));
syncSuppressionRuleToFleet(makeRule({
node_id: 10,
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
}));
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
expect(mockFetch).not.toHaveBeenCalled();
});
it('schedule unsupported + no proxy after supported probe: queues pending, no POST', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
mockRemoteAdvertises.mockResolvedValue(false);
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
mockGetProxyTarget.mockReturnValue(null);
syncSuppressionRuleToFleet(makeRule({
node_id: 10,
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
}));
await vi.waitFor(() => expect(error).toHaveBeenCalled());
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
expect(mockFetch).not.toHaveBeenCalled();
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica was removed'))).toBe(false);
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(true);
});
it('scheduleInvalid: DELETE success, no POST', async () => {
it('scheduleInvalid: DELETE success when retraction supported, no POST', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
syncSuppressionRuleToFleet(makeRule({
node_id: 10,
@@ -166,31 +247,44 @@ describe('notificationSuppressionSync', () => {
scheduleInvalid: true,
}));
await vi.waitFor(() => {
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica removed'))).toBe(true);
expect(warn.mock.calls.some((c) => String(c[0]).includes('recoverable DELETE applied'))).toBe(true);
});
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(false);
});
it('scheduleInvalid: DELETE 404 counts as cleanup success, no POST', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
mockFetch.mockResolvedValue({ ok: false, status: 404, text: async () => 'gone' });
syncSuppressionRuleToFleet(makeRule({
node_id: 10,
schedule: null,
scheduleInvalid: true,
}));
await vi.waitFor(() => {
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica removed'))).toBe(true);
it('scheduleInvalid: opaque DELETE 404 queues pending (not treated as success)', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
mockFetch.mockResolvedValue({
ok: false,
status: 404,
text: async () => 'Not Found',
json: async () => {
throw new Error('no json');
},
});
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
syncSuppressionRuleToFleet(makeRule({
node_id: 10,
schedule: null,
scheduleInvalid: true,
}));
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(true);
expect(mockDeletePending).not.toHaveBeenCalled();
});
it('scheduleInvalid: DELETE failure logs pending cleanup, no POST', async () => {
it('scheduleInvalid: DELETE failure queues pending, no POST', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
mockFetch.mockResolvedValue({ ok: false, status: 503, text: async () => 'down' });
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
mockFetch.mockResolvedValue({
ok: false,
status: 503,
text: async () => 'down',
json: async () => ({}),
});
syncSuppressionRuleToFleet(makeRule({
node_id: 10,
@@ -199,28 +293,32 @@ describe('notificationSuppressionSync', () => {
}));
await vi.waitFor(() => expect(error).toHaveBeenCalled());
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
expect(mockUpsertPending).toHaveBeenCalled();
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(true);
});
it('scheduleInvalid: no proxy target logs pending, no POST', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
mockGetProxyTarget.mockReturnValue(null);
it('scheduleInvalid without retraction capability: no DELETE; queues pending', async () => {
mockFetchMeta.mockResolvedValue(metaWith());
syncSuppressionRuleToFleet(makeRule({
node_id: 10,
updated_at: 777,
schedule: null,
scheduleInvalid: true,
}));
await vi.waitFor(() => expect(error).toHaveBeenCalled());
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
expect(mockFetch).not.toHaveBeenCalled();
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica removed'))).toBe(false);
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(true);
expect(mockUpsertPending).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'recoverable',
source_updated_at: 777,
}),
);
});
it('unscheduled-to-scheduled on unsupported target attempts DELETE', async () => {
it('unscheduled-to-scheduled on unsupported schedule target attempts recoverable path', async () => {
mockRemoteAdvertises.mockResolvedValue(false);
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
const previous = makeRule({ node_id: 10, schedule: null });
const updated = makeRule({
node_id: 10,
@@ -231,22 +329,6 @@ describe('notificationSuppressionSync', () => {
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
});
it('probe false + DELETE failure: no POST; logs cleanup pending', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
mockRemoteAdvertises.mockResolvedValue(false);
mockFetch.mockResolvedValue({ ok: false, status: 503, text: async () => 'down' });
syncSuppressionRuleToFleet(makeRule({
node_id: 10,
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
}));
await vi.waitFor(() => expect(error).toHaveBeenCalled());
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(true);
expect(error.mock.calls.some((c) => String(c[0]).includes('rule 42'))).toBe(true);
});
it('scheduled-to-unscheduled POST refresh does not require capability', async () => {
mockRemoteAdvertises.mockResolvedValue(false);
const previous = makeRule({
@@ -261,7 +343,8 @@ describe('notificationSuppressionSync', () => {
expect(body.rule.schedule).toBeNull();
});
it('stale targets receive DELETE on scope change', async () => {
it('stale targets receive recoverable DELETE when retraction supported', async () => {
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
const previous = makeRule({ node_id: null, schedule: null });
const updated = makeRule({ node_id: 10, schedule: null });
syncSuppressionRuleUpdateToFleet(previous, updated);
@@ -272,4 +355,147 @@ describe('notificationSuppressionSync', () => {
const posts = mockFetch.mock.calls.filter((c) => (c[1] as { method: string }).method === 'POST');
expect(posts.some((c) => String(c[0]).includes('node-10'))).toBe(true);
});
it('stale-target without retraction capability: no DELETE; queues pending', async () => {
mockFetchMeta.mockResolvedValue(metaWith());
const previous = makeRule({ id: 42, node_id: null, schedule: null, updated_at: 10 });
const updated = makeRule({ id: 42, node_id: 10, schedule: null, updated_at: 99 });
syncSuppressionRuleUpdateToFleet(previous, updated);
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
const deletes = mockFetch.mock.calls.filter((c) => (c[1] as { method: string }).method === 'DELETE');
expect(deletes).toHaveLength(0);
expect(mockUpsertPending).toHaveBeenCalledWith(
expect.objectContaining({
rule_id: 42,
node_id: 11,
kind: 'recoverable',
source_updated_at: 99,
}),
);
});
it('stale-target DELETE sends recoverable watermark from updated rule', async () => {
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
const previous = makeRule({ id: 42, node_id: null, schedule: null, updated_at: 10 });
const updated = makeRule({ id: 42, node_id: 10, schedule: null, updated_at: 99 });
syncSuppressionRuleUpdateToFleet(previous, updated);
await vi.waitFor(() => expect(mockFetch.mock.calls.length).toBeGreaterThanOrEqual(2));
const deletes = mockFetch.mock.calls.filter((c) => (c[1] as { method: string }).method === 'DELETE');
const stale = deletes.find((c) => String(c[0]).includes('node-11'));
expect(stale).toBeTruthy();
expect(JSON.parse((stale![1] as { body: string }).body)).toEqual({
kind: 'recoverable',
source_updated_at: 99,
});
});
it('authoritative fleet delete fans permanent retraction to all remotes', async () => {
const { deleteSuppressionRuleFromFleet } = await import('../helpers/notificationSuppressionSync');
deleteSuppressionRuleFromFleet(makeRule({ node_id: 10, updated_at: 321 }));
await vi.waitFor(() => expect(mockFetch.mock.calls.length).toBe(2));
for (const call of mockFetch.mock.calls) {
const [, init] = call as [string, { method: string; body: string }];
expect(init.method).toBe('DELETE');
expect(JSON.parse(init.body)).toEqual({ kind: 'permanent', source_updated_at: 321 });
}
expect(mockFetch.mock.calls.some((c) => String(c[0]).includes('node-10'))).toBe(true);
expect(mockFetch.mock.calls.some((c) => String(c[0]).includes('node-11'))).toBe(true);
});
it('authoritative delete transport failure queues pending permanent row', async () => {
const { deleteSuppressionRuleFromFleet } = await import('../helpers/notificationSuppressionSync');
mockFetch.mockResolvedValue({
ok: false,
status: 503,
text: async () => 'down',
json: async () => ({}),
});
deleteSuppressionRuleFromFleet(makeRule({ node_id: 10, updated_at: 321 }));
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
expect(mockUpsertPending).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'permanent',
source_updated_at: 321,
}),
);
});
it('ignored_stale DELETE keeps pending and does not clear', async () => {
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
mockFetch.mockResolvedValue({
ok: true,
status: 200,
text: async () => '',
json: async () => ({ success: true, outcome: 'ignored_stale' }),
});
syncSuppressionRuleToFleet(makeRule({
node_id: 10,
schedule: null,
scheduleInvalid: true,
}));
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
expect(mockDeletePending).not.toHaveBeenCalled();
expect(mockUpsertPending).toHaveBeenCalledWith(
expect.objectContaining({
last_error: expect.stringMatching(/ignored_stale/),
}),
);
});
it('flushPendingSuppressionRetractions retries recoverable only when supported', async () => {
mockListPending.mockReturnValue([
{
rule_id: 7,
node_id: 10,
kind: 'recoverable',
source_updated_at: 50,
created_at: 1,
updated_at: 2,
attempts: 1,
last_error: 'earlier',
},
]);
mockFetchMeta.mockResolvedValue(metaWith());
await flushPendingSuppressionRetractions(10);
expect(mockFetch).not.toHaveBeenCalled();
expect(mockUpsertPending).toHaveBeenCalled();
mockUpsertPending.mockClear();
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
await flushPendingSuppressionRetractions(10);
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
expect(JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body)).toEqual({
kind: 'recoverable',
source_updated_at: 50,
});
expect(mockDeletePending).toHaveBeenCalledWith(7, 10);
});
it('flushPendingSuppressionRetractions sends permanent without retraction capability', async () => {
mockListPending.mockReturnValue([
{
rule_id: 8,
node_id: 11,
kind: 'permanent',
source_updated_at: 90,
created_at: 1,
updated_at: 2,
attempts: 2,
last_error: 'offline',
},
]);
mockFetchMeta.mockResolvedValue(metaWith());
await flushPendingSuppressionRetractions(11);
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
expect(JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body)).toEqual({
kind: 'permanent',
source_updated_at: 90,
});
expect(mockDeletePending).toHaveBeenCalledWith(8, 11);
});
});
@@ -0,0 +1,159 @@
/**
* Additive kind + source_updated_at on notification_suppression_rule_tombstones.
*/
import { describe, it, expect, afterEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import Database from 'better-sqlite3';
import { DatabaseService } from '../services/DatabaseService';
function resetDatabaseSingleton(): void {
const holder = DatabaseService as unknown as { instance?: DatabaseService };
const existing = holder.instance;
if (existing) {
try {
existing.getDb().close();
} catch {
// already closed
}
holder.instance = undefined;
}
}
describe('notification suppression tombstone migration', () => {
let scratchDir: string | null = null;
let prevDataDir: string | undefined;
afterEach(() => {
resetDatabaseSingleton();
if (prevDataDir === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = prevDataDir;
}
if (scratchDir) {
try {
fs.rmSync(scratchDir, { recursive: true, force: true });
} catch {
// best-effort
}
scratchDir = null;
}
});
it('adds kind and source_updated_at; legacy rows stay permanent', { timeout: 60_000 }, () => {
scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-supp-tomb-mig-'));
const dbPath = path.join(scratchDir, 'sencho.db');
const seed = new Database(dbPath);
try {
seed.exec(`
CREATE TABLE notification_suppression_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
node_id INTEGER NULL,
stack_patterns TEXT NOT NULL,
label_ids TEXT NULL,
categories TEXT NULL,
levels TEXT NULL,
applies_to TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
expires_at INTEGER NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE notification_suppression_rule_tombstones (
id INTEGER PRIMARY KEY,
deleted_at INTEGER NOT NULL
);
INSERT INTO notification_suppression_rule_tombstones (id, deleted_at)
VALUES (42, 1700000000000);
`);
} finally {
seed.close();
}
prevDataDir = process.env.DATA_DIR;
process.env.DATA_DIR = scratchDir;
resetDatabaseSingleton();
const db = DatabaseService.getInstance();
const cols = db.getDb().prepare('PRAGMA table_info(notification_suppression_rule_tombstones)').all() as Array<{
name: string;
}>;
const names = cols.map((c) => c.name);
expect(names).toContain('kind');
expect(names).toContain('source_updated_at');
const tomb = db.getNotificationSuppressionRuleTombstone(42);
expect(tomb?.kind).toBe('permanent');
expect(tomb?.source_updated_at).toBe(1700000000000);
// Legacy permanent cannot be cleared by a newer hub POST.
db.upsertNotificationSuppressionRuleReplica({
id: 42,
name: 'should-stay-gone',
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: 9_999_999_999_999,
});
expect(db.getNotificationSuppressionRule(42)).toBeUndefined();
expect(db.getNotificationSuppressionRuleTombstone(42)?.kind).toBe('permanent');
});
it('creates pending retractions table and merges permanent over recoverable', { timeout: 60_000 }, () => {
scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-supp-pending-'));
prevDataDir = process.env.DATA_DIR;
process.env.DATA_DIR = scratchDir;
resetDatabaseSingleton();
const db = DatabaseService.getInstance();
const cols = db.getDb().prepare('PRAGMA table_info(notification_suppression_pending_retractions)').all() as Array<{
name: string;
}>;
expect(cols.map((c) => c.name)).toEqual(
expect.arrayContaining([
'rule_id',
'node_id',
'kind',
'source_updated_at',
'attempts',
'last_error',
]),
);
db.upsertNotificationSuppressionPendingRetraction({
rule_id: 7,
node_id: 3,
kind: 'recoverable',
source_updated_at: 100,
last_error: 'unsupported',
});
db.upsertNotificationSuppressionPendingRetraction({
rule_id: 7,
node_id: 3,
kind: 'permanent',
source_updated_at: 50,
last_error: 'offline',
});
const rows = db.listNotificationSuppressionPendingRetractions(3);
expect(rows).toHaveLength(1);
expect(rows[0].kind).toBe('permanent');
expect(rows[0].source_updated_at).toBe(100);
expect(rows[0].attempts).toBe(2);
expect(rows[0].last_error).toBe('offline');
db.deleteNotificationSuppressionPendingRetraction(7, 3);
expect(db.listNotificationSuppressionPendingRetractions(3)).toHaveLength(0);
});
});