feat: stack glob patterns and route severity levels (#1651)

* feat: add stack glob patterns and route severity levels

Operators can filter notification routes and mute rules with anchored * globs, and routes can target info, warning, or error. Matching is fail-closed for unsafe stored patterns; write paths keep partial-PUT semantics and ReDoS caps.

* test: split mute and routing chip tests to avoid dialog race

* fix: move stack pattern client validator out of PatternChips

* fix: bound stack glob matching and atomic pattern chip saves
This commit is contained in:
Anso
2026-07-19 14:54:48 -04:00
committed by GitHub
parent 63213c0960
commit 972f2b9483
26 changed files with 1158 additions and 118 deletions
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -24,6 +24,9 @@ import { SettingsCallout } from './SettingsCallout';
import { SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { classifyAppriseEndpoint, isStatelessAppriseEndpoint } from '@/lib/appriseEndpoint';
import { PatternChips, type PatternChipsHandle } from './PatternChips';
type NotificationLevel = 'info' | 'warning' | 'error';
interface NotificationRoute {
id: number;
@@ -32,6 +35,7 @@ interface NotificationRoute {
stack_patterns: string[];
label_ids: number[] | null;
categories: NotificationCategory[] | null;
levels: NotificationLevel[] | null;
channel_type: 'discord' | 'slack' | 'webhook' | 'apprise';
channel_url: string;
config: { mode: 'keyed' | 'stateless'; tags?: string; has_urls: boolean; providers?: string[]; url_count?: number } | null;
@@ -41,6 +45,12 @@ interface NotificationRoute {
updated_at: number;
}
const LEVEL_LABELS: Record<NotificationLevel, string> = {
info: 'Info',
warning: 'Warning',
error: 'Error',
};
const CHANNEL_LABELS: Record<string, string> = {
discord: 'Discord',
slack: 'Slack',
@@ -74,7 +84,9 @@ export function NotificationRoutingSection() {
const [formStacks, setFormStacks] = useState<string[]>([]);
const [formLabelIds, setFormLabelIds] = useState<number[]>([]);
const [formCategories, setFormCategories] = useState<NotificationCategory[]>([]);
const [formLevels, setFormLevels] = useState<NotificationLevel[]>([]);
const [formChannelType, setFormChannelType] = useState<'discord' | 'slack' | 'webhook' | 'apprise'>('discord');
const patternChipsRef = useRef<PatternChipsHandle>(null);
const [formChannelUrl, setFormChannelUrl] = useState('');
const [formAppriseUrls, setFormAppriseUrls] = useState('');
const [formAppriseTags, setFormAppriseTags] = useState('');
@@ -132,6 +144,7 @@ export function NotificationRoutingSection() {
setFormStacks([]);
setFormLabelIds([]);
setFormCategories([]);
setFormLevels([]);
setFormChannelType('discord');
setFormChannelUrl('');
setFormAppriseUrls('');
@@ -153,6 +166,7 @@ export function NotificationRoutingSection() {
setFormStacks([...route.stack_patterns]);
setFormLabelIds(route.label_ids ? [...route.label_ids] : []);
setFormCategories(route.categories ? [...route.categories] : []);
setFormLevels(route.levels ? [...route.levels] : []);
setFormChannelType(route.channel_type);
setFormChannelUrl(route.channel_url);
setFormAppriseTags(route.config?.tags ?? '');
@@ -170,6 +184,11 @@ export function NotificationRoutingSection() {
const handleSave = async () => {
if (!formName.trim()) { toast.error('Name is required.'); return; }
const preparedPatterns = patternChipsRef.current?.prepareSave();
if (!preparedPatterns?.ok) {
toast.error('Fix invalid stack patterns before saving.');
return;
}
if (!formChannelUrl.trim() || (formChannelType !== 'apprise' && !formChannelUrl.startsWith('https://'))) {
toast.error(formChannelType === 'apprise' ? 'Enter a valid Apprise endpoint.' : 'Channel URL must be a valid HTTPS URL.');
return;
@@ -204,9 +223,10 @@ export function NotificationRoutingSection() {
const body = {
name: formName.trim(),
node_id: formNodeId,
stack_patterns: formStacks,
stack_patterns: preparedPatterns.patterns,
label_ids: formLabelIds.length > 0 ? formLabelIds : null,
categories: formCategories.length > 0 ? formCategories : null,
levels: formLevels.length > 0 ? formLevels : null,
channel_type: formChannelType,
...(formChannelType !== 'apprise' || !editingId || appriseEndpointDirty || channelTypeChanged
? { channel_url: formChannelUrl.trim() }
@@ -305,10 +325,6 @@ export function NotificationRoutingSection() {
}
};
const removeStack = (stackName: string) => {
setFormStacks(prev => prev.filter(s => s !== stackName));
};
const addLabel = (idStr: string) => {
const id = Number(idStr);
if (!isNaN(id) && id > 0 && !formLabelIds.includes(id)) {
@@ -331,6 +347,17 @@ export function NotificationRoutingSection() {
setFormCategories(prev => prev.filter(c => c !== cat));
};
const addLevel = (level: string) => {
const l = level as NotificationLevel;
if ((l === 'info' || l === 'warning' || l === 'error') && !formLevels.includes(l)) {
setFormLevels(prev => [...prev, l]);
}
};
const removeLevel = (level: NotificationLevel) => {
setFormLevels(prev => prev.filter(l => l !== level));
};
const enabledRoutesCount = routes.filter(r => r.enabled).length;
useMastheadStats(
loading
@@ -355,6 +382,10 @@ export function NotificationRoutingSection() {
() => (Object.keys(CATEGORY_LABELS) as NotificationCategory[]).filter(c => !formCategories.includes(c)).map(c => ({ value: c, label: CATEGORY_LABELS[c] })),
[formCategories],
);
const availableLevelOptions = useMemo<ComboboxOption[]>(
() => (Object.keys(LEVEL_LABELS) as NotificationLevel[]).filter(l => !formLevels.includes(l)).map(l => ({ value: l, label: LEVEL_LABELS[l] })),
[formLevels],
);
return (
<CapabilityGate capability="notification-routing" featureName="Routing">
@@ -402,30 +433,21 @@ export function NotificationRoutingSection() {
<div className="space-y-2">
<Label>Stacks <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
<PatternChips
ref={patternChipsRef}
patterns={formStacks}
onChange={setFormStacks}
placeholder="Type a pattern (for example prod-*)"
data-testid="route-pattern-chips"
/>
<Combobox
options={availableStackOptions}
value=""
onValueChange={addStack}
placeholder="Add a stack..."
placeholder="Insert known stack name..."
searchPlaceholder="Search stacks..."
emptyText="No stacks found."
/>
{formStacks.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{formStacks.map(s => (
<Badge key={s} variant="secondary" className="font-mono text-xs gap-1 pr-1">
{s}
<button
type="button"
onClick={() => removeStack(s)}
className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
)}
</div>
<div className="space-y-2">
@@ -488,6 +510,34 @@ export function NotificationRoutingSection() {
<p className="text-xs text-muted-foreground">Leave blank to match all categories. All non-empty filters must match (AND).</p>
</div>
<div className="space-y-2">
<Label>Severity <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
<Combobox
options={availableLevelOptions}
value=""
onValueChange={addLevel}
placeholder="Add a severity..."
searchPlaceholder="Search..."
emptyText="No levels left."
/>
{formLevels.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{formLevels.map((l) => (
<Badge key={l} variant="outline" className="text-xs gap-1 pr-1">
{LEVEL_LABELS[l]}
<button
type="button"
onClick={() => removeLevel(l)}
className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label>Channel</Label>
<Tabs
@@ -685,9 +735,18 @@ export function NotificationRoutingSection() {
{route.categories && route.categories.length > 0 && route.categories.map(c => (
<Badge key={c} variant="outline" className="text-[10px] font-mono">{CATEGORY_LABELS[c] ?? c}</Badge>
))}
{route.stack_patterns.length === 0 && (!route.label_ids || route.label_ids.length === 0) && (!route.categories || route.categories.length === 0) && (
<span className="text-muted-foreground/50 text-[10px]">Matches all alerts</span>
)}
{route.levels && route.levels.length > 0 && route.levels.map((l) => (
<Badge key={l} variant="outline" className="text-[10px]">{LEVEL_LABELS[l]}</Badge>
))}
{route.stack_patterns.length === 0
&& (!route.label_ids || route.label_ids.length === 0)
&& (!route.categories || route.categories.length === 0)
&& (!route.levels || route.levels.length === 0)
&& (
route.node_id === null
? <span className="text-muted-foreground/50 text-[10px]">Matches all alerts</span>
: <span className="text-muted-foreground/50 text-[10px]">Matches all alerts on this node</span>
)}
<span className="text-muted-foreground/50">|</span>
<span className="font-mono truncate max-w-[200px]" title={route.channel_type === 'apprise' ? undefined : route.channel_url}>
{route.channel_url}
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -23,6 +23,7 @@ import { Plus, Trash2, Pencil, RefreshCw, X, BellOff } from 'lucide-react';
import { SettingsCallout } from './SettingsCallout';
import { SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { PatternChips, type PatternChipsHandle } from './PatternChips';
type NotificationLevel = 'info' | 'warning' | 'error';
type AppliesTo = 'bell' | 'external' | 'both';
@@ -118,6 +119,7 @@ export function NotificationSuppressionSection({
const [formName, setFormName] = useState('');
const [formNodeId, setFormNodeId] = useState<number | null>(null);
const [formStacks, setFormStacks] = useState<string[]>([]);
const patternChipsRef = useRef<PatternChipsHandle>(null);
const [formLabelIds, setFormLabelIds] = useState<number[]>([]);
const [formCategories, setFormCategories] = useState<NotificationCategory[]>([]);
const [formLevels, setFormLevels] = useState<NotificationLevel[]>([]);
@@ -212,6 +214,11 @@ export function NotificationSuppressionSection({
const handleSave = async () => {
if (!formName.trim()) { toast.error('Name is required.'); return; }
const preparedPatterns = patternChipsRef.current?.prepareSave();
if (!preparedPatterns?.ok) {
toast.error('Fix invalid stack patterns before saving.');
return;
}
const customMs = formCustomExpiry ? new Date(formCustomExpiry).getTime() : null;
if (formExpirationPreset === 'custom' && (customMs == null || Number.isNaN(customMs))) {
toast.error('Choose a valid custom expiration date.');
@@ -223,7 +230,7 @@ export function NotificationSuppressionSection({
const body = {
name: formName.trim(),
node_id: formNodeId,
stack_patterns: formStacks,
stack_patterns: preparedPatterns.patterns,
label_ids: formLabelIds.length > 0 ? formLabelIds : null,
categories: formCategories.length > 0 ? formCategories : null,
levels: formLevels.length > 0 ? formLevels : null,
@@ -297,7 +304,6 @@ export function NotificationSuppressionSection({
const addStack = (stackName: string) => {
if (stackName && !formStacks.includes(stackName)) setFormStacks((prev) => [...prev, stackName]);
};
const removeStack = (stackName: string) => setFormStacks((prev) => prev.filter((s) => s !== stackName));
const addLabel = (idStr: string) => {
const id = Number(idStr);
if (!Number.isNaN(id) && id > 0 && !formLabelIds.includes(id)) setFormLabelIds((prev) => [...prev, id]);
@@ -387,17 +393,14 @@ export function NotificationSuppressionSection({
<div className="space-y-2">
<Label>Stacks <span className="text-muted-foreground font-normal text-xs">(optional)</span></Label>
<Combobox options={availableStackOptions} value="" onValueChange={addStack} placeholder="Add a stack..." searchPlaceholder="Search stacks..." emptyText="No stacks found." />
{formStacks.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{formStacks.map((s) => (
<Badge key={s} variant="secondary" className="font-mono text-xs gap-1 pr-1">
{s}
<button type="button" onClick={() => removeStack(s)} className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"><X className="w-3 h-3" /></button>
</Badge>
))}
</div>
)}
<PatternChips
ref={patternChipsRef}
patterns={formStacks}
onChange={setFormStacks}
placeholder="Type a pattern (for example prod-*)"
data-testid="mute-pattern-chips"
/>
<Combobox options={availableStackOptions} value="" onValueChange={addStack} placeholder="Insert known stack name..." searchPlaceholder="Search stacks..." emptyText="No stacks found." />
</div>
<div className="space-y-2">
@@ -0,0 +1,134 @@
import { forwardRef, useImperativeHandle, useState, type KeyboardEvent } from 'react';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { X } from 'lucide-react';
import { validateStackPatternClient } from './stackPatternClient';
export type PrepareSaveResult =
| { ok: true; patterns: string[] }
| { ok: false };
export interface PatternChipsHandle {
/**
* Commit pending text if any and return the validated pattern list to serialize.
* Callers must use the returned `patterns` instead of a stale parent render.
*/
prepareSave: () => PrepareSaveResult;
}
interface PatternChipsProps {
patterns: string[];
onChange: (next: string[]) => void;
placeholder?: string;
'data-testid'?: string;
}
function appendPattern(base: string[], raw: string): { ok: true; patterns: string[] } | { ok: false; error: string } {
const trimmed = raw.trim();
if (!trimmed) return { ok: true, patterns: base };
const err = validateStackPatternClient(trimmed);
if (err) return { ok: false, error: err };
if (base.includes(trimmed)) return { ok: true, patterns: base };
return { ok: true, patterns: [...base, trimmed] };
}
export const PatternChips = forwardRef<PatternChipsHandle, PatternChipsProps>(function PatternChips(
{ patterns, onChange, placeholder = 'Type a pattern and press Enter', 'data-testid': testId },
ref,
) {
const [pending, setPending] = useState('');
const [error, setError] = useState<string | null>(null);
const commitAgainst = (base: string[], raw: string): string[] | null => {
const result = appendPattern(base, raw);
if (!result.ok) {
setError(result.error);
return null;
}
setError(null);
return result.patterns;
};
useImperativeHandle(ref, () => ({
prepareSave: () => {
let next = patterns;
if (pending.trim()) {
const committed = commitAgainst(patterns, pending);
if (!committed) return { ok: false };
next = committed;
}
for (const p of next) {
const err = validateStackPatternClient(p);
if (err) {
setError(err);
return { ok: false };
}
}
if (next !== patterns) onChange(next);
setPending('');
setError(null);
return { ok: true, patterns: next };
},
}));
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const next = commitAgainst(patterns, pending);
if (!next) return;
if (next !== patterns) onChange(next);
setPending('');
}
};
const onChangePending = (value: string) => {
if (value.includes(',')) {
const parts = value.split(',');
const last = parts.pop() ?? '';
let next = patterns;
for (const part of parts) {
const committed = commitAgainst(next, part);
if (!committed) return;
next = committed;
}
if (next !== patterns) onChange(next);
setPending(last);
return;
}
setPending(value);
if (error) setError(null);
};
return (
<div className="space-y-1.5" data-testid={testId}>
<Input
value={pending}
onChange={(e) => onChangePending(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
aria-invalid={error != null}
/>
{error && <p className="text-xs text-destructive">{error}</p>}
{patterns.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{patterns.map((s) => (
<Badge key={s} variant="secondary" className="font-mono text-xs gap-1 pr-1">
{s}
<button
type="button"
onClick={() => onChange(patterns.filter((p) => p !== s))}
className="ml-0.5 rounded-full hover:bg-foreground/10 p-0.5"
aria-label={`Remove ${s}`}
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
Use * as a wildcard (for example prod-*). Press Enter or comma to add.
</p>
</div>
);
});
@@ -44,6 +44,7 @@ const APPRISE_ROUTE = {
stack_patterns: ['app'],
label_ids: null,
categories: null,
levels: null,
channel_type: 'apprise',
channel_url: 'http://apprise.local/notify/<redacted>',
config: {
@@ -226,4 +227,159 @@ describe('NotificationRoutingSection', () => {
expect(body).not.toHaveProperty('config');
});
it('shows Error badge and not Matches all alerts for a severity-only route', async () => {
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (url === '/notification-routes' && !opts?.method) {
return {
ok: true,
json: async () => [{
...APPRISE_ROUTE,
id: 7,
name: 'Errors only',
stack_patterns: [],
levels: ['error'],
channel_type: 'discord',
channel_url: 'https://discord.com/api/webhooks/1/x',
config: null,
}],
};
}
if (url === '/stacks') return { ok: true, json: async () => ['app'] };
if (url === '/labels') return { ok: true, json: async () => [] };
return { ok: true, json: async () => ([]) };
});
render(<NotificationRoutingSection />);
await waitFor(() => expect(screen.getByText('Errors only')).toBeInTheDocument());
expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.queryByText('Matches all alerts')).not.toBeInTheDocument();
expect(screen.queryByText('Matches all alerts on this node')).not.toBeInTheDocument();
});
it('shows node-scoped match-all for a node-only route', async () => {
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (url === '/notification-routes' && !opts?.method) {
return {
ok: true,
json: async () => [{
...APPRISE_ROUTE,
id: 8,
name: 'Local only',
node_id: 1,
stack_patterns: [],
levels: null,
channel_type: 'discord',
channel_url: 'https://discord.com/api/webhooks/1/x',
config: null,
}],
};
}
if (url === '/stacks') return { ok: true, json: async () => [] };
if (url === '/labels') return { ok: true, json: async () => [] };
return { ok: true, json: async () => ([]) };
});
render(<NotificationRoutingSection />);
await waitFor(() => expect(screen.getByText('Local only')).toBeInTheDocument());
expect(screen.getByText('Local')).toBeInTheDocument();
expect(screen.getByText('Matches all alerts on this node')).toBeInTheDocument();
expect(screen.queryByText(/^Matches all alerts$/)).not.toBeInTheDocument();
});
it('commits pattern chips and null severity into create JSON', async () => {
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (url === '/notification-routes' && !opts?.method) {
return { ok: true, json: async () => [] };
}
if (url === '/stacks') return { ok: true, json: async () => ['known-stack'] };
if (url === '/labels') return { ok: true, json: async () => [] };
if (url === '/notification-routes' && opts?.method === 'POST') {
return { ok: true, json: async () => ({ id: 99 }) };
}
return { ok: true, json: async () => ([]) };
});
render(<NotificationRoutingSection />);
await waitFor(() => expect(screen.getByRole('button', { name: /Add route/i })).toBeInTheDocument());
await userEvent.click(screen.getByRole('button', { name: /Add route/i }));
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), 'prod-*{Enter}');
const nameInput = screen.getByPlaceholderText(/Production alerts/i);
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'Chip route');
await userEvent.type(screen.getByPlaceholderText(/discord/i), 'https://discord.com/api/webhooks/1/token');
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => {
const post = mockedFetch.mock.calls.find(
([url, opts]) => url === '/notification-routes' && (opts as { method?: string })?.method === 'POST',
);
expect(post).toBeTruthy();
const body = JSON.parse((post![1] as { body: string }).body);
expect(body.stack_patterns).toEqual(['prod-*']);
expect(body.levels).toBeNull();
});
});
it('includes a pending pattern that was never committed with Enter', async () => {
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (url === '/notification-routes' && !opts?.method) {
return { ok: true, json: async () => [] };
}
if (url === '/stacks') return { ok: true, json: async () => ['known-stack'] };
if (url === '/labels') return { ok: true, json: async () => [] };
if (url === '/notification-routes' && opts?.method === 'POST') {
return { ok: true, json: async () => ({ id: 99 }) };
}
return { ok: true, json: async () => ([]) };
});
render(<NotificationRoutingSection />);
await waitFor(() => expect(screen.getByRole('button', { name: /Add route/i })).toBeInTheDocument());
await userEvent.click(screen.getByRole('button', { name: /Add route/i }));
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), 'prod-*');
const nameInput = screen.getByPlaceholderText(/Production alerts/i);
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'Pending route');
await userEvent.type(screen.getByPlaceholderText(/discord/i), 'https://discord.com/api/webhooks/1/token');
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => {
const post = mockedFetch.mock.calls.find(
([url, opts]) => url === '/notification-routes' && (opts as { method?: string })?.method === 'POST',
);
expect(post).toBeTruthy();
const body = JSON.parse((post![1] as { body: string }).body);
expect(body.stack_patterns).toEqual(['prod-*']);
});
});
it('blocks create when a stack pattern is invalid', async () => {
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (url === '/notification-routes' && !opts?.method) {
return { ok: true, json: async () => [] };
}
if (url === '/stacks') return { ok: true, json: async () => ['known-stack'] };
if (url === '/labels') return { ok: true, json: async () => [] };
if (url === '/notification-routes' && opts?.method === 'POST') {
return { ok: true, json: async () => ({ id: 99 }) };
}
return { ok: true, json: async () => ([]) };
});
render(<NotificationRoutingSection />);
await waitFor(() => expect(screen.getByRole('button', { name: /Add route/i })).toBeInTheDocument());
await userEvent.click(screen.getByRole('button', { name: /Add route/i }));
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), '****');
await userEvent.type(screen.getByPlaceholderText(/Production alerts/i), 'Bad');
await userEvent.type(screen.getByPlaceholderText(/discord/i), 'https://discord.com/api/webhooks/1/token');
await userEvent.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => {
const posts = mockedFetch.mock.calls.filter(
([url, opts]) => url === '/notification-routes' && (opts as { method?: string })?.method === 'POST',
);
expect(posts).toHaveLength(0);
});
});
});
@@ -0,0 +1,132 @@
/**
* NotificationSuppressionSection stack pattern chips.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
loading: vi.fn(),
dismiss: vi.fn(),
},
}));
vi.mock('@/context/NodeContext', () => ({
useNodes: () => ({
nodes: [{ id: 1, type: 'local', name: 'Local' }],
hasCapability: () => true,
activeNode: { id: 1, type: 'local', name: 'Local' },
activeNodeMeta: { version: '1.0.0' },
}),
}));
vi.mock('@/components/CapabilityGate', () => ({
CapabilityGate: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock('../MastheadStatsContext', () => ({
useMastheadStats: () => {},
}));
vi.mock('@/hooks/useMuteRulesRefresh', () => ({
useMuteRulesRefresh: () => {},
}));
vi.mock('@/lib/muteRules', () => ({
emitMuteRulesChanged: vi.fn(),
}));
import { apiFetch } from '@/lib/api';
import { NotificationSuppressionSection } from '../NotificationSuppressionSection';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
async function openMuteForm() {
render(<NotificationSuppressionSection />);
await waitFor(() => expect(screen.getByRole('button', { name: /Add mute rule|Add rule/i })).toBeInTheDocument());
await userEvent.click(screen.getByRole('button', { name: /Add mute rule|Add rule/i }));
await waitFor(() => expect(screen.getByRole('dialog', { name: /New mute rule/i })).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 () => ([]) };
});
});
it('posts normalized stack patterns and null levels', async () => {
await openMuteForm();
const nameInput = screen.getByPlaceholderText(/Mute staging/i);
await userEvent.type(nameInput, 'Mute prod');
await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), 'prod-*,prod-*{Enter}');
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.stack_patterns).toEqual(['prod-*']);
expect(body.levels).toBeNull();
});
});
it('includes a pending pattern that was never committed with Enter', async () => {
await openMuteForm();
await userEvent.type(screen.getByPlaceholderText(/Mute staging/i), 'Pending mute');
await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), 'prod-*');
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.stack_patterns).toEqual(['prod-*']);
});
});
it('accumulates multi-pattern comma input into the create body', async () => {
await openMuteForm();
await userEvent.type(screen.getByPlaceholderText(/Mute staging/i), 'Paste mute');
await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), 'alpha-*,beta-*');
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.stack_patterns).toEqual(['alpha-*', 'beta-*']);
});
});
it('blocks create when a stack pattern is invalid', async () => {
await openMuteForm();
await userEvent.type(screen.getByPlaceholderText(/Mute staging/i), 'Bad mute');
await userEvent.type(screen.getByPlaceholderText(/Type a pattern/i), '****');
await userEvent.click(screen.getByRole('button', { name: /Create|Update/i }));
await waitFor(() => {
const posts = mockedFetch.mock.calls.filter(
([url, opts]) => url === '/notification-suppression-rules' && (opts as { method?: string })?.method === 'POST',
);
expect(posts).toHaveLength(0);
});
});
});
@@ -0,0 +1,8 @@
/** Client mirror of backend validateStackPatternForRedos. */
export function validateStackPatternClient(pattern: string): string | null {
if (pattern.length > 200) return 'Pattern is too long (max 200 characters)';
const stars = (pattern.match(/\*/g) ?? []).length;
if (stars > 8) return 'Pattern has too many wildcards (max 8)';
if (/\*{4,}/.test(pattern)) return 'Pattern must not contain 4+ consecutive wildcards';
return null;
}