mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-16 21:48:45 +00:00
feat: weekly UTC maintenance windows for mute rules (#1661)
* feat: add weekly UTC maintenance windows to mute rules
Let mute rules suppress only during recurring UTC windows, normalize
replica node identity, and fail-open when remotes lack schedule support
so older nodes never keep an all-day scheduled mute after a successful cleanup DELETE.
* fix: fail closed on corrupt mute schedules and clean invalid replicas
Empty or whitespace stored schedules no longer act as all-day mutes. Invalid schedules trigger remote DELETE cleanup, and the weekly-window form gains accessibility attributes plus component coverage.
* fix: require explicit repair before clearing a corrupt mute schedule
The suppression engine already fails closed on an unreadable stored
schedule (scheduleInvalid), but the frontend never surfaced that flag:
a corrupt rule looked identical to an ordinary unscheduled one, and
opening Edit then clicking Update sent an explicit schedule: null,
silently turning the corruption into a valid all-day mute. Add the
flag to the rule type, show an Invalid schedule badge on the card, and
block saving in the edit form until the operator explicitly touches
the weekly window (configures a new one, or toggles it to confirm the
clear).
* fix: correct contradictory toggle-sequence copy in schedule-repair toast
The blocking toast told operators to toggle the weekly window "off then
on" to confirm clearing a corrupt schedule, but the toggle starts off
for a corrupt rule, so that sequence leaves it on and trips the
no-selected-day validation instead. The correct, tested sequence is on
then off, matching the inline hint below the toggle. Also add a
regression test confirming the invalid-schedule save gate resets
cleanly across edit sessions on different rules.
* fix: enforce replica node_id and guard fleet sync against stale writes
Two hardenings to the suppression-rule fleet sync path found during
review: the /replica endpoint trusted the payload's node_id instead of
forcing it to null server-side, so a direct proxy-authenticated caller
could persist a scoped replica; and upsertNotificationSuppressionRuleReplica
overwrote unconditionally with no ordering check, so a delayed older
POST arriving after a newer one could downgrade the stored rule. Force
node_id to null on every replica write, and skip (with a warning log)
any incoming write whose updated_at is not newer than what's stored.
* test: assert the exact-tie updated_at case in the fleet sync stale-write guard
The staleness guard added in c31458a1 uses >= (ties are ignored, not
just strictly older writes); add the missing assertion for that
boundary and make the comment explicit about it.
* fix: bump vulnerable transitive backend dependencies
npm audit flagged body-parser, fast-uri, and protobufjs (one high
severity: fast-uri host confusion via failed IDN canonicalization).
All three have patch/minor fixes within existing semver ranges;
npm audit fix resolves all three with no package.json changes.
* fix: sanitize suppression replica fields before logging
Log entries built from fleet-sync replica payloads embedded rule id
and timestamp values directly, allowing a compromised peer to forge
log lines via control characters.
* fix: prevent delayed replica writes from resurrecting deleted mute rules
A network-reordered replica POST arriving after a DELETE fell into the
insert-when-absent branch with no protection, since the staleness guard
only compares against a row that still exists. Add a permanent
per-id tombstone (safe because rule ids are AUTOINCREMENT and never
reused): every delete records one, and the replica upsert refuses to
recreate a tombstoned id regardless of the incoming updated_at.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isScheduleActive,
|
||||
parseNotificationSchedule,
|
||||
parseStoredNotificationSchedule,
|
||||
scheduleAllowsSuppression,
|
||||
type NotificationSchedule,
|
||||
} from '../helpers/notificationSchedule';
|
||||
|
||||
/** Build a UTC epoch for a known weekday. 2026-07-18 is Saturday (getUTCDay()===6). */
|
||||
function utcMs(iso: string): number {
|
||||
return Date.parse(iso);
|
||||
}
|
||||
|
||||
const satWindow: NotificationSchedule = {
|
||||
days: [6],
|
||||
start_minute: 22 * 60,
|
||||
end_minute: 2 * 60,
|
||||
tz: 'UTC',
|
||||
};
|
||||
|
||||
const sameDay: NotificationSchedule = {
|
||||
days: [1],
|
||||
start_minute: 2 * 60,
|
||||
end_minute: 6 * 60,
|
||||
tz: 'UTC',
|
||||
};
|
||||
|
||||
describe('parseNotificationSchedule', () => {
|
||||
it('accepts unique days in any order and returns sorted', () => {
|
||||
const result = parseNotificationSchedule({
|
||||
days: [3, 1, 6],
|
||||
start_minute: 0,
|
||||
end_minute: 60,
|
||||
tz: 'UTC',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
schedule: { days: [1, 3, 6], start_minute: 0, end_minute: 60, tz: 'UTC' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects duplicates, empty days, equal endpoints, and non-UTC tz', () => {
|
||||
expect(parseNotificationSchedule({
|
||||
days: [1, 1], start_minute: 0, end_minute: 1, tz: 'UTC',
|
||||
}).ok).toBe(false);
|
||||
expect(parseNotificationSchedule({
|
||||
days: [], start_minute: 0, end_minute: 1, tz: 'UTC',
|
||||
}).ok).toBe(false);
|
||||
expect(parseNotificationSchedule({
|
||||
days: [1], start_minute: 30, end_minute: 30, tz: 'UTC',
|
||||
}).ok).toBe(false);
|
||||
expect(parseNotificationSchedule({
|
||||
days: [1], start_minute: 0, end_minute: 1, tz: 'America/New_York',
|
||||
}).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isScheduleActive', () => {
|
||||
it('treats null schedule as always active', () => {
|
||||
expect(isScheduleActive(null, utcMs('2026-07-20T12:00:00.000Z'))).toBe(true);
|
||||
});
|
||||
|
||||
it('same-day: start inclusive, end exclusive', () => {
|
||||
// Monday 2026-07-20
|
||||
expect(isScheduleActive(sameDay, utcMs('2026-07-20T02:00:00.000Z'))).toBe(true);
|
||||
expect(isScheduleActive(sameDay, utcMs('2026-07-20T05:59:00.000Z'))).toBe(true);
|
||||
expect(isScheduleActive(sameDay, utcMs('2026-07-20T06:00:00.000Z'))).toBe(false);
|
||||
expect(isScheduleActive(sameDay, utcMs('2026-07-20T01:59:00.000Z'))).toBe(false);
|
||||
});
|
||||
|
||||
it('cross-midnight Saturday window: Sat evening and Sun morning only', () => {
|
||||
expect(isScheduleActive(satWindow, utcMs('2026-07-18T22:00:00.000Z'))).toBe(true); // Sat
|
||||
expect(isScheduleActive(satWindow, utcMs('2026-07-18T23:30:00.000Z'))).toBe(true);
|
||||
expect(isScheduleActive(satWindow, utcMs('2026-07-19T01:59:00.000Z'))).toBe(true); // Sun
|
||||
expect(isScheduleActive(satWindow, utcMs('2026-07-19T02:00:00.000Z'))).toBe(false);
|
||||
expect(isScheduleActive(satWindow, utcMs('2026-07-18T21:59:00.000Z'))).toBe(false);
|
||||
expect(isScheduleActive(satWindow, utcMs('2026-07-19T22:00:00.000Z'))).toBe(false); // Sun evening
|
||||
expect(isScheduleActive(satWindow, utcMs('2026-07-20T01:00:00.000Z'))).toBe(false); // Mon morning
|
||||
});
|
||||
|
||||
it('supports multiple start days', () => {
|
||||
const multi: NotificationSchedule = {
|
||||
days: [1, 3],
|
||||
start_minute: 10 * 60,
|
||||
end_minute: 11 * 60,
|
||||
tz: 'UTC',
|
||||
};
|
||||
expect(isScheduleActive(multi, utcMs('2026-07-20T10:30:00.000Z'))).toBe(true); // Mon
|
||||
expect(isScheduleActive(multi, utcMs('2026-07-22T10:30:00.000Z'))).toBe(true); // Wed
|
||||
expect(isScheduleActive(multi, utcMs('2026-07-21T10:30:00.000Z'))).toBe(false); // Tue
|
||||
});
|
||||
|
||||
it('uses UTC independent of host-local timezone interpretation of fixed instants', () => {
|
||||
const ms = utcMs('2026-07-18T22:00:00.000Z');
|
||||
expect(new Date(ms).getUTCDay()).toBe(6);
|
||||
expect(isScheduleActive(satWindow, ms)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduleAllowsSuppression / stored parse', () => {
|
||||
it('fails closed for invalid stored schedule', () => {
|
||||
expect(scheduleAllowsSuppression(null, true, Date.now())).toBe(false);
|
||||
expect(parseStoredNotificationSchedule('{not-json')).toEqual({ kind: 'invalid' });
|
||||
expect(parseStoredNotificationSchedule(null)).toEqual({ kind: 'null' });
|
||||
expect(parseStoredNotificationSchedule(undefined)).toEqual({ kind: 'null' });
|
||||
});
|
||||
|
||||
it('treats empty and whitespace strings as invalid, not legacy null', () => {
|
||||
expect(parseStoredNotificationSchedule('')).toEqual({ kind: 'invalid' });
|
||||
expect(parseStoredNotificationSchedule(' ')).toEqual({ kind: 'invalid' });
|
||||
expect(parseStoredNotificationSchedule('null')).toEqual({ kind: 'invalid' });
|
||||
});
|
||||
});
|
||||
@@ -235,7 +235,7 @@ describe('Notification suppression - CRUD', () => {
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
rule: {
|
||||
id: 9001,
|
||||
id: 910001,
|
||||
name: 'replica',
|
||||
applies_to: 'both',
|
||||
node_id: null,
|
||||
@@ -255,7 +255,7 @@ describe('Notification suppression - CRUD', () => {
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
rule: {
|
||||
id: 9001,
|
||||
id: 910001,
|
||||
name: 'replica',
|
||||
applies_to: 'both',
|
||||
stack_patterns: ['****'],
|
||||
@@ -270,14 +270,14 @@ describe('Notification suppression - CRUD', () => {
|
||||
},
|
||||
});
|
||||
expect(redos.status).toBe(400);
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(9001)).toBeUndefined();
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(910001)).toBeUndefined();
|
||||
|
||||
const ok = await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
rule: {
|
||||
id: 9001,
|
||||
id: 910001,
|
||||
name: 'replica',
|
||||
applies_to: 'both',
|
||||
stack_patterns: ['prod-*'],
|
||||
@@ -292,7 +292,341 @@ describe('Notification suppression - CRUD', () => {
|
||||
},
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(9001)?.stack_patterns).toEqual(['prod-*']);
|
||||
DatabaseService.getInstance().deleteNotificationSuppressionRule(9001);
|
||||
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('replica does not resurrect a rule after it was deleted, even with a newer 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 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();
|
||||
|
||||
// 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.
|
||||
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(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.
|
||||
const del = await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/970008')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(del.status).toBe(200);
|
||||
|
||||
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(970008)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Additive schedule column on notification_suppression_rules.
|
||||
*/
|
||||
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';
|
||||
import { scheduleAllowsSuppression } from '../helpers/notificationSchedule';
|
||||
|
||||
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 schedule column 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 schedule via DatabaseService startup; legacy rows load as null', { timeout: 60_000 }, () => {
|
||||
scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-supp-sched-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
|
||||
);
|
||||
INSERT INTO notification_suppression_rules
|
||||
(name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, created_at, updated_at)
|
||||
VALUES ('Legacy mute', NULL, '[]', NULL, NULL, NULL, 'both', 1, NULL, 1, 1);
|
||||
`);
|
||||
} finally {
|
||||
seed.close();
|
||||
}
|
||||
|
||||
prevDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = scratchDir;
|
||||
resetDatabaseSingleton();
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
const cols = db.getDb().prepare('PRAGMA table_info(notification_suppression_rules)').all() as Array<{ name: string }>;
|
||||
expect(cols.filter((c) => c.name === 'schedule')).toHaveLength(1);
|
||||
|
||||
const rule = db.getNotificationSuppressionRules().find((r) => r.name === 'Legacy mute');
|
||||
expect(rule).toBeDefined();
|
||||
expect(rule!.schedule).toBeNull();
|
||||
expect(rule!.scheduleInvalid).toBe(false);
|
||||
|
||||
resetDatabaseSingleton();
|
||||
process.env.DATA_DIR = scratchDir;
|
||||
const db2 = DatabaseService.getInstance();
|
||||
const cols2 = db2.getDb().prepare('PRAGMA table_info(notification_suppression_rules)').all() as Array<{ name: string }>;
|
||||
expect(cols2.filter((c) => c.name === 'schedule')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('empty-string schedule column is invalid and does not suppress via enabled load', { timeout: 60_000 }, () => {
|
||||
scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-supp-sched-empty-'));
|
||||
const dbPath = path.join(scratchDir, 'sencho.db');
|
||||
const seed = new Database(dbPath);
|
||||
try {
|
||||
seed.exec(`
|
||||
CREATE TABLE notification_suppression_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
node_id INTEGER NULL,
|
||||
stack_patterns TEXT NOT NULL,
|
||||
label_ids TEXT NULL,
|
||||
categories TEXT NULL,
|
||||
levels TEXT NULL,
|
||||
applies_to TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
expires_at INTEGER NULL,
|
||||
schedule TEXT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO notification_suppression_rules
|
||||
(name, node_id, stack_patterns, label_ids, categories, levels, applies_to, enabled, expires_at, schedule, created_at, updated_at)
|
||||
VALUES ('Empty schedule mute', NULL, '[]', NULL, '["monitor_alert"]', NULL, 'both', 1, NULL, '', 1, 1);
|
||||
`);
|
||||
} finally {
|
||||
seed.close();
|
||||
}
|
||||
|
||||
prevDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = scratchDir;
|
||||
resetDatabaseSingleton();
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
const rule = db.getEnabledNotificationSuppressionRules().find((r) => r.name === 'Empty schedule mute');
|
||||
expect(rule).toBeDefined();
|
||||
expect(rule!.schedule).toBeNull();
|
||||
expect(rule!.scheduleInvalid).toBe(true);
|
||||
|
||||
expect(scheduleAllowsSuppression(rule!.schedule, rule!.scheduleInvalid, Date.now())).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Fleet sync for suppression rules: node_id normalize, capability gate, stale DELETE.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const mockFetchMeta = vi.fn();
|
||||
const mockGetProxyTarget = vi.fn();
|
||||
const mockGetNodes = vi.fn();
|
||||
const mockGetNode = vi.fn();
|
||||
const mockRemoteAdvertises = vi.fn();
|
||||
|
||||
vi.mock('../services/DatabaseService', () => ({
|
||||
DatabaseService: {
|
||||
getInstance: () => ({
|
||||
getNodes: mockGetNodes,
|
||||
getNode: mockGetNode,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/NodeRegistry', () => ({
|
||||
NodeRegistry: {
|
||||
getInstance: () => ({
|
||||
getProxyTarget: mockGetProxyTarget,
|
||||
fetchMetaForNode: mockFetchMeta,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/LicenseService', () => ({
|
||||
LicenseService: {
|
||||
getInstance: () => ({
|
||||
getProxyHeaders: () => ({ tier: 'community' }),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../helpers/remoteCapabilities', () => ({
|
||||
remoteAdvertisesCapability: (...args: unknown[]) => mockRemoteAdvertises(...args),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
import {
|
||||
syncSuppressionRuleToFleet,
|
||||
syncSuppressionRuleUpdateToFleet,
|
||||
replicationTargetIds,
|
||||
} from '../helpers/notificationSuppressionSync';
|
||||
import type { NotificationSuppressionRule } from '../services/DatabaseService';
|
||||
|
||||
function makeRule(overrides: Partial<NotificationSuppressionRule> = {}): NotificationSuppressionRule {
|
||||
return {
|
||||
id: 42,
|
||||
name: 'Fleet mute',
|
||||
node_id: null,
|
||||
stack_patterns: [],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
applies_to: 'both',
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
schedule: null,
|
||||
scheduleInvalid: false,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const remoteA = { id: 10, name: 'remote-a', type: 'remote' as const };
|
||||
const remoteB = { id: 11, name: 'remote-b', type: 'remote' as const };
|
||||
|
||||
describe('notificationSuppressionSync', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetNodes.mockReturnValue([remoteA, remoteB]);
|
||||
mockGetNode.mockImplementation((id: number) =>
|
||||
[remoteA, remoteB].find((n) => n.id === id),
|
||||
);
|
||||
mockGetProxyTarget.mockImplementation((id: number) => ({
|
||||
apiUrl: `http://node-${id}.example:1852`,
|
||||
apiToken: 'tok',
|
||||
}));
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
|
||||
mockRemoteAdvertises.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('replicationTargetIds: fleet-wide all remotes; scoped one remote', () => {
|
||||
expect(replicationTargetIds(makeRule({ node_id: null }))).toEqual([10, 11]);
|
||||
expect(replicationTargetIds(makeRule({ node_id: 10 }))).toEqual([10]);
|
||||
});
|
||||
|
||||
it('unscheduled push sends node_id null without capability probe', async () => {
|
||||
syncSuppressionRuleToFleet(makeRule({ schedule: null, node_id: 10 }));
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
|
||||
expect(mockRemoteAdvertises).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();
|
||||
});
|
||||
|
||||
it('supported remote receives scheduled rule with node_id null', async () => {
|
||||
mockRemoteAdvertises.mockResolvedValue(true);
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: { days: [6], start_minute: 120, end_minute: 360, tz: 'UTC' },
|
||||
}));
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
|
||||
expect(mockRemoteAdvertises).toHaveBeenCalled();
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, { method: string; body: string }];
|
||||
expect(url).toContain('/api/notification-suppression-rules/replica');
|
||||
expect(init.method).toBe('POST');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.rule.node_id).toBeNull();
|
||||
expect(body.rule.schedule.days).toEqual([6]);
|
||||
});
|
||||
|
||||
it('probe false + DELETE success: no POST; cleanup logged as removed', 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 () => '' });
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
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(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(false);
|
||||
});
|
||||
|
||||
it('probe false + no proxy target: no successful-cleanup claim', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockRemoteAdvertises.mockResolvedValue(false);
|
||||
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());
|
||||
|
||||
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 () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: null,
|
||||
scheduleInvalid: true,
|
||||
}));
|
||||
await vi.waitFor(() => {
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica removed'))).toBe(true);
|
||||
});
|
||||
|
||||
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(false);
|
||||
});
|
||||
|
||||
it('scheduleInvalid: DELETE 404 counts as cleanup success, no POST', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 404, text: async () => 'gone' });
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: null,
|
||||
scheduleInvalid: true,
|
||||
}));
|
||||
await vi.waitFor(() => {
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica removed'))).toBe(true);
|
||||
});
|
||||
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
|
||||
});
|
||||
|
||||
it('scheduleInvalid: DELETE failure logs pending cleanup, no POST', async () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 503, text: async () => 'down' });
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: null,
|
||||
scheduleInvalid: true,
|
||||
}));
|
||||
await vi.waitFor(() => expect(error).toHaveBeenCalled());
|
||||
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(true);
|
||||
});
|
||||
|
||||
it('scheduleInvalid: no proxy target logs pending, no POST', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockGetProxyTarget.mockReturnValue(null);
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: null,
|
||||
scheduleInvalid: true,
|
||||
}));
|
||||
await vi.waitFor(() => expect(error).toHaveBeenCalled());
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica removed'))).toBe(false);
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(true);
|
||||
});
|
||||
|
||||
it('unscheduled-to-scheduled on unsupported target attempts DELETE', async () => {
|
||||
mockRemoteAdvertises.mockResolvedValue(false);
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
|
||||
const previous = makeRule({ node_id: 10, schedule: null });
|
||||
const updated = makeRule({
|
||||
node_id: 10,
|
||||
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
|
||||
});
|
||||
syncSuppressionRuleUpdateToFleet(previous, updated);
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
|
||||
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({
|
||||
node_id: 10,
|
||||
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
|
||||
});
|
||||
const updated = makeRule({ node_id: 10, schedule: null });
|
||||
syncSuppressionRuleUpdateToFleet(previous, updated);
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
|
||||
expect(mockRemoteAdvertises).not.toHaveBeenCalled();
|
||||
const body = JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body);
|
||||
expect(body.rule.schedule).toBeNull();
|
||||
});
|
||||
|
||||
it('stale targets receive DELETE on scope change', async () => {
|
||||
const previous = makeRule({ node_id: null, schedule: null });
|
||||
const updated = makeRule({ node_id: 10, schedule: null });
|
||||
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');
|
||||
expect(deletes.some((c) => String(c[0]).includes('node-11'))).toBe(true);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,8 @@ function makeSuppressionRule(overrides: Record<string, unknown> = {}) {
|
||||
applies_to: 'both' as const,
|
||||
enabled: true,
|
||||
expires_at: null as number | null,
|
||||
schedule: null as null,
|
||||
scheduleInvalid: false,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
...overrides,
|
||||
@@ -176,4 +178,74 @@ describe('NotificationService - suppression logic', () => {
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('suppresses inside weekly window and records match', async () => {
|
||||
// Monday 2026-07-20 03:00 UTC inside 02:00-06:00 Mon window
|
||||
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-20T03:00:00.000Z'));
|
||||
mockGetEnabledNotificationSuppressionRules.mockReturnValue([
|
||||
makeSuppressionRule({
|
||||
categories: ['monitor_alert'],
|
||||
applies_to: 'both',
|
||||
schedule: { days: [1], start_minute: 120, end_minute: 360, tz: 'UTC' },
|
||||
}),
|
||||
]);
|
||||
mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]);
|
||||
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'Crash', { stackName: 'my-app' });
|
||||
|
||||
expect(mockBroadcast).not.toHaveBeenCalled();
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(mockUpdateNotificationSuppressionMatch).toHaveBeenCalled();
|
||||
expect(mockGetEnabledNotificationSuppressionRules).toHaveBeenCalledWith(Date.parse('2026-07-20T03:00:00.000Z'));
|
||||
});
|
||||
|
||||
it('does not suppress outside weekly window', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-20T12:00:00.000Z'));
|
||||
mockGetEnabledNotificationSuppressionRules.mockReturnValue([
|
||||
makeSuppressionRule({
|
||||
categories: ['monitor_alert'],
|
||||
applies_to: 'both',
|
||||
schedule: { days: [1], start_minute: 120, end_minute: 360, tz: 'UTC' },
|
||||
}),
|
||||
]);
|
||||
mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]);
|
||||
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'Crash', { stackName: 'my-app' });
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalled();
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
expect(mockUpdateNotificationSuppressionMatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not suppress when stored schedule is invalid', async () => {
|
||||
mockGetEnabledNotificationSuppressionRules.mockReturnValue([
|
||||
makeSuppressionRule({
|
||||
categories: ['monitor_alert'],
|
||||
applies_to: 'both',
|
||||
schedule: null,
|
||||
scheduleInvalid: true,
|
||||
}),
|
||||
]);
|
||||
mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]);
|
||||
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'Crash', { stackName: 'my-app' });
|
||||
|
||||
expect(mockBroadcast).toHaveBeenCalled();
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
expect(mockUpdateNotificationSuppressionMatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not suppress when scheduled rule is expired relative to atMs', async () => {
|
||||
const atMs = Date.parse('2026-07-20T03:00:00.000Z');
|
||||
vi.spyOn(Date, 'now').mockReturnValue(atMs);
|
||||
// getEnabled already filters expiry; empty list simulates expired
|
||||
mockGetEnabledNotificationSuppressionRules.mockReturnValue([]);
|
||||
mockGetEnabledAgents.mockReturnValue([{ type: 'slack', url: 'https://hooks.slack.com/x', enabled: true }]);
|
||||
|
||||
await svc.dispatchAlert('error', 'monitor_alert', 'Crash', { stackName: 'my-app' });
|
||||
|
||||
expect(mockGetEnabledNotificationSuppressionRules).toHaveBeenCalledWith(atMs);
|
||||
expect(mockBroadcast).toHaveBeenCalled();
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user