From cb97cc097cdb06d6eea8ddb32c0573cea8a41621 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Tue, 21 Jul 2026 14:04:44 -0400 Subject: [PATCH] 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). --- .../NotificationSuppressionSection.tsx | 37 ++++++- .../NotificationSuppressionSection.test.tsx | 99 +++++++++++++++++++ 2 files changed, 133 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/settings/NotificationSuppressionSection.tsx b/frontend/src/components/settings/NotificationSuppressionSection.tsx index 06af75ee..ecc9bb65 100644 --- a/frontend/src/components/settings/NotificationSuppressionSection.tsx +++ b/frontend/src/components/settings/NotificationSuppressionSection.tsx @@ -41,6 +41,7 @@ interface NotificationSuppressionRule { enabled: boolean; expires_at: number | null; schedule: MuteRuleSchedule | null; + scheduleInvalid: boolean; created_at: number; updated_at: number; } @@ -162,6 +163,11 @@ export function NotificationSuppressionSection({ const [formScheduleDays, setFormScheduleDays] = useState([]); const [formScheduleStart, setFormScheduleStart] = useState('02:00'); const [formScheduleEnd, setFormScheduleEnd] = useState('06:00'); + // True when editing a rule whose stored schedule could not be read. Its window + // must not be silently cleared: the operator has to touch the controls first. + const [formScheduleInvalid, setFormScheduleInvalid] = useState(false); + const [formScheduleTouched, setFormScheduleTouched] = useState(false); + const markScheduleTouched = () => setFormScheduleTouched(true); const fetchRules = useCallback(async () => { try { @@ -215,6 +221,8 @@ export function NotificationSuppressionSection({ setFormScheduleDays([]); setFormScheduleStart('02:00'); setFormScheduleEnd('06:00'); + setFormScheduleInvalid(false); + setFormScheduleTouched(false); setEditingId(null); setShowForm(true); onPrefillConsumed?.(); @@ -235,6 +243,8 @@ export function NotificationSuppressionSection({ setFormScheduleDays([]); setFormScheduleStart('02:00'); setFormScheduleEnd('06:00'); + setFormScheduleInvalid(false); + setFormScheduleTouched(false); setEditingId(null); setShowForm(false); }; @@ -263,11 +273,19 @@ export function NotificationSuppressionSection({ setFormScheduleStart('02:00'); setFormScheduleEnd('06:00'); } + setFormScheduleInvalid(rule.scheduleInvalid); + setFormScheduleTouched(false); setShowForm(true); }; const handleSave = async () => { if (!formName.trim()) { toast.error('Name is required.'); return; } + if (formScheduleInvalid && !formScheduleTouched) { + toast.error( + "This rule's stored weekly window could not be read. Turn the weekly window on to set a new schedule, or toggle it off then on to confirm clearing it.", + ); + return; + } const preparedPatterns = patternChipsRef.current?.prepareSave(); if (!preparedPatterns?.ok) { toast.error('Fix invalid stack patterns before saving.'); @@ -566,11 +584,18 @@ export function NotificationSuppressionSection({
{ setFormScheduleEnabled(v); markScheduleTouched(); }} id="mute-rule-schedule" />
+ {formScheduleInvalid && !formScheduleTouched && ( +

+ Stored weekly window could not be read and was not applied (alerts have been + delivering normally). Configure a new window above, or toggle it on then off to + confirm clearing it. +

+ )} {formScheduleEnabled && (
@@ -591,6 +616,7 @@ export function NotificationSuppressionSection({ ? prev.filter((d) => d !== day) : [...prev, day].sort((a, b) => a - b), ); + markScheduleTouched(); }} > {label} @@ -605,7 +631,7 @@ export function NotificationSuppressionSection({ id="mute-schedule-start" type="time" value={formScheduleStart} - onChange={(e) => setFormScheduleStart(e.target.value)} + onChange={(e) => { setFormScheduleStart(e.target.value); markScheduleTouched(); }} />
@@ -614,7 +640,7 @@ export function NotificationSuppressionSection({ id="mute-schedule-end" type="time" value={formScheduleEnd} - onChange={(e) => setFormScheduleEnd(e.target.value)} + onChange={(e) => { setFormScheduleEnd(e.target.value); markScheduleTouched(); }} />
@@ -673,6 +699,11 @@ export function NotificationSuppressionSection({ {rule.expires_at != null && rule.expires_at <= Date.now() && ( Expired )} + {rule.scheduleInvalid && ( + + Invalid schedule + + )}
handleToggleEnabled(rule)} className="scale-75" /> diff --git a/frontend/src/components/settings/__tests__/NotificationSuppressionSection.test.tsx b/frontend/src/components/settings/__tests__/NotificationSuppressionSection.test.tsx index 8fd53da3..c5b88f4d 100644 --- a/frontend/src/components/settings/__tests__/NotificationSuppressionSection.test.tsx +++ b/frontend/src/components/settings/__tests__/NotificationSuppressionSection.test.tsx @@ -60,6 +60,24 @@ const scheduledRule = { end_minute: 360, tz: 'UTC', }, + scheduleInvalid: false, + created_at: 1, + updated_at: 1, +}; + +const corruptScheduleRule = { + id: 43, + name: 'Corrupt window', + node_id: null, + stack_patterns: [], + label_ids: null, + categories: null, + levels: null, + applies_to: 'both', + enabled: true, + expires_at: null, + schedule: null, + scheduleInvalid: true, created_at: 1, updated_at: 1, }; @@ -269,4 +287,85 @@ describe('NotificationSuppressionSection', () => { expect(body.schedule).toBeNull(); }); }); + + it('marks a corrupt stored schedule as invalid instead of an ordinary unscheduled rule', async () => { + mockListRules([corruptScheduleRule]); + render(); + await waitFor(() => expect(screen.getByText('Corrupt window')).toBeInTheDocument()); + expect(screen.getByText('Invalid schedule')).toBeInTheDocument(); + }); + + it('blocks saving a corrupt-schedule rule until the operator touches the weekly window', async () => { + mockListRules([corruptScheduleRule]); + render(); + await waitFor(() => expect(screen.getByText('Corrupt window')).toBeInTheDocument()); + + await userEvent.click(screen.getByTitle('Edit')); + await waitFor(() => expect(screen.getByRole('dialog', { name: /Edit mute rule/i })).toBeInTheDocument()); + expect(screen.getByLabelText(/Weekly window \(UTC\)/i)).toHaveAttribute('aria-checked', 'false'); + + await userEvent.click(screen.getByRole('button', { name: /Create|Update/i })); + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + expect.stringContaining('could not be read'), + ); + }); + const puts = mockedFetch.mock.calls.filter( + ([url, opts]) => url === '/notification-suppression-rules/43' && (opts as { method?: string })?.method === 'PUT', + ); + expect(puts).toHaveLength(0); + }); + + it('allows saving a corrupt-schedule rule once the operator explicitly clears it', async () => { + mockListRules([corruptScheduleRule]); + render(); + await waitFor(() => expect(screen.getByText('Corrupt window')).toBeInTheDocument()); + + await userEvent.click(screen.getByTitle('Edit')); + await waitFor(() => expect(screen.getByRole('dialog', { name: /Edit mute rule/i })).toBeInTheDocument()); + + // Explicit acknowledgement: turn the window on, then off again, to intentionally clear it. + await userEvent.click(screen.getByLabelText(/Weekly window \(UTC\)/i)); + await userEvent.click(screen.getByLabelText(/Weekly window \(UTC\)/i)); + + await userEvent.click(screen.getByRole('button', { name: /Create|Update/i })); + await waitFor(() => { + const put = mockedFetch.mock.calls.find( + ([url, opts]) => + url === '/notification-suppression-rules/43' && (opts as { method?: string })?.method === 'PUT', + ); + expect(put).toBeTruthy(); + const body = JSON.parse((put![1] as { body: string }).body); + expect(body.schedule).toBeNull(); + }); + }); + + it('allows saving a corrupt-schedule rule once the operator configures a new valid schedule', async () => { + mockListRules([corruptScheduleRule]); + render(); + await waitFor(() => expect(screen.getByText('Corrupt window')).toBeInTheDocument()); + + await userEvent.click(screen.getByTitle('Edit')); + await waitFor(() => expect(screen.getByRole('dialog', { name: /Edit mute rule/i })).toBeInTheDocument()); + await enableWeeklyWindow(); + await userEvent.click(screen.getByRole('button', { name: 'Sat' })); + fireEvent.change(screen.getByLabelText('Start (UTC)'), { target: { value: '02:00' } }); + fireEvent.change(screen.getByLabelText('End (UTC)'), { target: { value: '06:00' } }); + + await userEvent.click(screen.getByRole('button', { name: /Create|Update/i })); + await waitFor(() => { + const put = mockedFetch.mock.calls.find( + ([url, opts]) => + url === '/notification-suppression-rules/43' && (opts as { method?: string })?.method === 'PUT', + ); + expect(put).toBeTruthy(); + const body = JSON.parse((put![1] as { body: string }).body); + expect(body.schedule).toEqual({ + days: [6], + start_minute: 120, + end_minute: 360, + tz: 'UTC', + }); + }); + }); });