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:
Anso
2026-07-21 23:17:52 -04:00
committed by GitHub
parent e15b9d1244
commit a3edee5e6a
18 changed files with 1821 additions and 38 deletions
@@ -40,10 +40,42 @@ interface NotificationSuppressionRule {
applies_to: AppliesTo;
enabled: boolean;
expires_at: number | null;
schedule: MuteRuleSchedule | null;
scheduleInvalid: boolean;
created_at: number;
updated_at: number;
}
type MuteRuleSchedule = {
days: number[];
start_minute: number;
end_minute: number;
tz: 'UTC';
};
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const;
function minuteToTimeInput(minute: number): string {
const h = Math.floor(minute / 60);
const m = minute % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
function timeInputToMinute(value: string): number | null {
const match = /^(\d{2}):(\d{2})(?::\d{2})?$/.exec(value);
if (!match) return null;
const h = Number(match[1]);
const m = Number(match[2]);
if (h > 23 || m > 59) return null;
return h * 60 + m;
}
function formatScheduleSummary(schedule: MuteRuleSchedule | null): string | null {
if (!schedule) return null;
const days = schedule.days.map((d) => DAY_LABELS[d] ?? String(d)).join(', ');
return `UTC ${days} ${minuteToTimeInput(schedule.start_minute)}-${minuteToTimeInput(schedule.end_minute)}`;
}
const LEVEL_LABELS: Record<NotificationLevel, string> = {
info: 'Info',
warning: 'Warning',
@@ -127,6 +159,15 @@ export function NotificationSuppressionSection({
const [formEnabled, setFormEnabled] = useState(true);
const [formExpirationPreset, setFormExpirationPreset] = useState<ExpirationPreset>('forever');
const [formCustomExpiry, setFormCustomExpiry] = useState('');
const [formScheduleEnabled, setFormScheduleEnabled] = useState(false);
const [formScheduleDays, setFormScheduleDays] = useState<number[]>([]);
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 {
@@ -176,6 +217,12 @@ export function NotificationSuppressionSection({
});
setFormExpirationPreset('forever');
setFormCustomExpiry('');
setFormScheduleEnabled(false);
setFormScheduleDays([]);
setFormScheduleStart('02:00');
setFormScheduleEnd('06:00');
setFormScheduleInvalid(false);
setFormScheduleTouched(false);
setEditingId(null);
setShowForm(true);
onPrefillConsumed?.();
@@ -192,6 +239,12 @@ export function NotificationSuppressionSection({
setFormEnabled(true);
setFormExpirationPreset('forever');
setFormCustomExpiry('');
setFormScheduleEnabled(false);
setFormScheduleDays([]);
setFormScheduleStart('02:00');
setFormScheduleEnd('06:00');
setFormScheduleInvalid(false);
setFormScheduleTouched(false);
setEditingId(null);
setShowForm(false);
};
@@ -209,11 +262,30 @@ export function NotificationSuppressionSection({
setFormEnabled(rule.enabled);
setFormExpirationPreset(preset);
setFormCustomExpiry(customMs != null ? new Date(customMs).toISOString().slice(0, 16) : '');
if (rule.schedule) {
setFormScheduleEnabled(true);
setFormScheduleDays([...rule.schedule.days]);
setFormScheduleStart(minuteToTimeInput(rule.schedule.start_minute));
setFormScheduleEnd(minuteToTimeInput(rule.schedule.end_minute));
} else {
setFormScheduleEnabled(false);
setFormScheduleDays([]);
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 on then off to confirm clearing it.",
);
return;
}
const preparedPatterns = patternChipsRef.current?.prepareSave();
if (!preparedPatterns?.ok) {
toast.error('Fix invalid stack patterns before saving.');
@@ -225,6 +297,30 @@ export function NotificationSuppressionSection({
return;
}
let schedule: MuteRuleSchedule | null = null;
if (formScheduleEnabled) {
if (formScheduleDays.length === 0) {
toast.error('Select at least one day for the weekly window.');
return;
}
const startMinute = timeInputToMinute(formScheduleStart);
const endMinute = timeInputToMinute(formScheduleEnd);
if (startMinute == null || endMinute == null) {
toast.error('Enter valid UTC start and end times.');
return;
}
if (startMinute === endMinute) {
toast.error('Weekly window start and end must differ.');
return;
}
schedule = {
days: [...new Set(formScheduleDays)].sort((a, b) => a - b),
start_minute: startMinute,
end_minute: endMinute,
tz: 'UTC',
};
}
setSaving(true);
try {
const body = {
@@ -237,6 +333,7 @@ export function NotificationSuppressionSection({
applies_to: formAppliesTo,
enabled: formEnabled,
expires_at: expirationFromPreset(formExpirationPreset, customMs),
schedule,
};
const url = editingId
@@ -483,6 +580,77 @@ export function NotificationSuppressionSection({
)}
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<TogglePill
checked={formScheduleEnabled}
onChange={(v) => { setFormScheduleEnabled(v); markScheduleTouched(); }}
id="mute-rule-schedule"
/>
<Label htmlFor="mute-rule-schedule" className="mb-0">Weekly window (UTC)</Label>
</div>
{formScheduleInvalid && !formScheduleTouched && (
<p className="text-xs text-destructive">
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.
</p>
)}
{formScheduleEnabled && (
<div className="space-y-2 rounded-md border border-border/60 p-3">
<div className="flex flex-wrap gap-1.5">
{DAY_LABELS.map((label, day) => {
const selected = formScheduleDays.includes(day);
return (
<Button
key={label}
type="button"
size="sm"
variant={selected ? 'default' : 'outline'}
className="h-7 px-2 text-xs"
aria-pressed={selected}
aria-label={label}
onClick={() => {
setFormScheduleDays((prev) =>
selected
? prev.filter((d) => d !== day)
: [...prev, day].sort((a, b) => a - b),
);
markScheduleTouched();
}}
>
{label}
</Button>
);
})}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="mute-schedule-start" className="text-xs text-muted-foreground">Start (UTC)</Label>
<Input
id="mute-schedule-start"
type="time"
value={formScheduleStart}
onChange={(e) => { setFormScheduleStart(e.target.value); markScheduleTouched(); }}
/>
</div>
<div className="space-y-1">
<Label htmlFor="mute-schedule-end" className="text-xs text-muted-foreground">End (UTC)</Label>
<Input
id="mute-schedule-end"
type="time"
value={formScheduleEnd}
onChange={(e) => { setFormScheduleEnd(e.target.value); markScheduleTouched(); }}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
Outside this window the rule does not mute. Cross-midnight windows use the start day only.
</p>
</div>
)}
</div>
<div className="flex items-center gap-2">
<TogglePill checked={formEnabled} onChange={setFormEnabled} id="mute-rule-enabled" />
<span className="text-sm text-stat-value select-none">
@@ -531,6 +699,11 @@ export function NotificationSuppressionSection({
{rule.expires_at != null && rule.expires_at <= Date.now() && (
<Badge variant="secondary" className="text-[10px] shrink-0 text-muted-foreground">Expired</Badge>
)}
{rule.scheduleInvalid && (
<Badge variant="destructive" className="text-[10px] shrink-0" title="Stored weekly window could not be read; alerts deliver normally until repaired in Edit">
Invalid schedule
</Badge>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<TogglePill checked={rule.enabled} onChange={() => handleToggleEnabled(rule)} className="scale-75" />
@@ -553,6 +726,12 @@ export function NotificationSuppressionSection({
)}
<span className="text-muted-foreground/50">|</span>
<span className="tabular-nums">Expires: {formatExpiry(rule.expires_at)}</span>
{formatScheduleSummary(rule.schedule) && (
<>
<span className="text-muted-foreground/50">|</span>
<span className="tabular-nums">{formatScheduleSummary(rule.schedule)}</span>
</>
)}
</div>
</div>
))}
@@ -1,8 +1,8 @@
/**
* NotificationSuppressionSection stack pattern chips.
* NotificationSuppressionSection stack pattern chips and weekly schedule UI.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
@@ -38,10 +38,67 @@ vi.mock('@/lib/muteRules', () => ({
}));
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { NotificationSuppressionSection } from '../NotificationSuppressionSection';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
const scheduledRule = {
id: 42,
name: 'Weekend mute',
node_id: null,
stack_patterns: ['prod-*'],
label_ids: null,
categories: null,
levels: null,
applies_to: 'both',
enabled: true,
expires_at: null,
schedule: {
days: [6],
start_minute: 120,
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,
};
function mockListRules(rules: unknown[] = []) {
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (url === '/notification-suppression-rules' && !opts?.method) {
return { ok: true, json: async () => rules };
}
if (url === '/stacks') return { ok: true, json: async () => ['staging'] };
if (url === '/labels') return { ok: true, json: async () => [] };
if (url === '/notification-suppression-rules' && opts?.method === 'POST') {
return { ok: true, json: async () => ({ id: 1 }) };
}
if (typeof url === 'string' && url.startsWith('/notification-suppression-rules/') && opts?.method === 'PUT') {
return { ok: true, json: async () => ({ id: 42 }) };
}
return { ok: true, json: async () => ([]) };
});
}
async function openMuteForm() {
render(<NotificationSuppressionSection />);
await waitFor(() => expect(screen.getByRole('button', { name: /Add mute rule|Add rule/i })).toBeInTheDocument());
@@ -49,20 +106,16 @@ async function openMuteForm() {
await waitFor(() => expect(screen.getByRole('dialog', { name: /New mute rule/i })).toBeInTheDocument());
}
async function enableWeeklyWindow() {
await userEvent.click(screen.getByLabelText(/Weekly window \(UTC\)/i));
await waitFor(() => expect(screen.getByRole('button', { name: 'Sat' })).toBeInTheDocument());
}
describe('NotificationSuppressionSection', () => {
beforeEach(() => {
mockedFetch.mockReset();
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (url === '/notification-suppression-rules' && !opts?.method) {
return { ok: true, json: async () => [] };
}
if (url === '/stacks') return { ok: true, json: async () => ['staging'] };
if (url === '/labels') return { ok: true, json: async () => [] };
if (url === '/notification-suppression-rules' && opts?.method === 'POST') {
return { ok: true, json: async () => ({ id: 1 }) };
}
return { ok: true, json: async () => ([]) };
});
vi.mocked(toast.error).mockClear();
mockListRules([]);
});
it('posts normalized stack patterns and null levels', async () => {
@@ -129,4 +182,215 @@ describe('NotificationSuppressionSection', () => {
expect(posts).toHaveLength(0);
});
});
it('posts a weekly UTC schedule with accessible day and time controls', async () => {
await openMuteForm();
await userEvent.type(screen.getByPlaceholderText(/Mute staging/i), 'Sat window');
await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), 'prod-*{Enter}');
await enableWeeklyWindow();
const sat = screen.getByRole('button', { name: 'Sat' });
expect(sat).toHaveAttribute('aria-pressed', 'false');
await userEvent.click(sat);
expect(sat).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByLabelText('Start (UTC)')).toHaveAttribute('id', 'mute-schedule-start');
expect(screen.getByLabelText('End (UTC)')).toHaveAttribute('id', 'mute-schedule-end');
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 post = mockedFetch.mock.calls.find(
([url, opts]) => url === '/notification-suppression-rules' && (opts as { method?: string })?.method === 'POST',
);
expect(post).toBeTruthy();
const body = JSON.parse((post![1] as { body: string }).body);
expect(body.schedule).toEqual({
days: [6],
start_minute: 120,
end_minute: 360,
tz: 'UTC',
});
});
});
it('blocks scheduled create when no weekday is selected', async () => {
await openMuteForm();
await userEvent.type(screen.getByPlaceholderText(/Mute staging/i), 'No days');
await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), 'prod-*{Enter}');
await enableWeeklyWindow();
await userEvent.click(screen.getByRole('button', { name: /Create|Update/i }));
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Select at least one day for the weekly window.');
});
const posts = mockedFetch.mock.calls.filter(
([url, opts]) => url === '/notification-suppression-rules' && (opts as { method?: string })?.method === 'POST',
);
expect(posts).toHaveLength(0);
});
it('hydrates edit form from an existing schedule and keeps it on PUT', async () => {
mockListRules([scheduledRule]);
render(<NotificationSuppressionSection />);
await waitFor(() => expect(screen.getByText('Weekend mute')).toBeInTheDocument());
expect(screen.getByText(/UTC Sat 02:00-06:00/)).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', 'true');
expect(screen.getByRole('button', { name: 'Sat' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByLabelText('Start (UTC)')).toHaveValue('02:00');
expect(screen.getByLabelText('End (UTC)')).toHaveValue('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/42' && (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',
});
});
});
it('clears schedule to null when weekly window is turned off on edit', async () => {
mockListRules([scheduledRule]);
render(<NotificationSuppressionSection />);
await waitFor(() => expect(screen.getByText('Weekend mute')).toBeInTheDocument());
await userEvent.click(screen.getByTitle('Edit'));
await waitFor(() => expect(screen.getByRole('dialog', { name: /Edit mute rule/i })).toBeInTheDocument());
await userEvent.click(screen.getByLabelText(/Weekly window \(UTC\)/i));
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Sat' })).not.toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button', { name: /Create|Update/i }));
await waitFor(() => {
const put = mockedFetch.mock.calls.find(
([url, opts]) =>
url === '/notification-suppression-rules/42' && (opts as { method?: string })?.method === 'PUT',
);
expect(put).toBeTruthy();
const body = JSON.parse((put![1] as { body: string }).body);
expect(body.schedule).toBeNull();
});
});
it('marks a corrupt stored schedule as invalid instead of an ordinary unscheduled rule', async () => {
mockListRules([corruptScheduleRule]);
render(<NotificationSuppressionSection />);
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(<NotificationSuppressionSection />);
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(<NotificationSuppressionSection />);
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(<NotificationSuppressionSection />);
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',
});
});
});
it('does not carry the invalid-schedule save gate over to a later edit of a different, valid rule', async () => {
mockListRules([corruptScheduleRule, scheduledRule]);
render(<NotificationSuppressionSection />);
await waitFor(() => expect(screen.getByText('Corrupt window')).toBeInTheDocument());
const editButtons = screen.getAllByTitle('Edit');
await userEvent.click(editButtons[0]);
await waitFor(() => expect(screen.getByRole('dialog', { name: /Edit mute rule/i })).toBeInTheDocument());
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
await userEvent.click(screen.getAllByTitle('Edit')[1]);
await waitFor(() => expect(screen.getByRole('dialog', { name: /Edit mute rule/i })).toBeInTheDocument());
await userEvent.click(screen.getByRole('button', { name: /Create|Update/i }));
await waitFor(() => {
const put = mockedFetch.mock.calls.find(
([url, opts]) =>
url === '/notification-suppression-rules/42' && (opts as { method?: string })?.method === 'PUT',
);
expect(put).toBeTruthy();
});
expect(toast.error).not.toHaveBeenCalledWith(expect.stringContaining('could not be read'));
});
});