Repository navigation
[walk test] Suppressions: filter by reason (API + settings page) #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1b7acb2
d2cfafb
3300654
f45fd5f
9988fa5
80f560b
11e7d34
67c8cfa
78fcf86
4ead929
84b0485
7ebe4df
8f7150d
21a01f7
0aaa845
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -81,6 +81,7 @@ | |
| const [page, setPage] = useState(1) | ||
| const [total, setTotal] = useState(0) | ||
| const [search, setSearch] = useState("") | ||
| const [reasonFilter, setReasonFilter] = useState<Reason | "all">("all") | ||
|
|
||
| const [addOpen, setAddOpen] = useState(false) | ||
| const [addEmail, setAddEmail] = useState("") | ||
|
|
@@ -97,6 +98,7 @@ | |
| limit: String(PAGE_SIZE), | ||
| }) | ||
| if (search) params.set("search", search) | ||
| if (reasonFilter !== "all") params.set("reason", reasonFilter) | ||
|
Check warning on line 101 in app/(dashboard)/settings/suppressions/page.tsx
|
||
| const res = await fetch(`/api/internal/suppressions?${params.toString()}`) | ||
| if (!res.ok) throw new Error() | ||
| const json = (await res.json()) as ListResponse | ||
|
|
@@ -107,7 +109,7 @@ | |
| } finally { | ||
| setLoading(false) | ||
| } | ||
| }, [page, search, toast]) | ||
| }, [page, search, reasonFilter, toast]) | ||
|
Check notice on line 112 in app/(dashboard)/settings/suppressions/page.tsx
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π‘ [ai] Out-of-order list fetches can display results for the wrong reason filter β Risk: 45/100 The reason filter now participates in the fetch callback dependencies, so changing it starts a new request through the effect, but the requests are neither aborted nor guarded against stale responses. If a user quickly switches filters (or changes the search/page while a request is pending), the earlier request can finish last and overwrite the rows and total for the currently selected filter. This can display suppressions that do not match the visible filter and mislead administrators reviewing or removing entries; an older request can also clear the loading state while the newer request remains pending.
π AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. |
||
|
|
||
| useEffect(() => { fetchData() }, [fetchData]) | ||
|
|
||
|
|
@@ -228,6 +230,25 @@ | |
| }} | ||
| className="max-w-sm" | ||
| /> | ||
| <Select | ||
| value={reasonFilter} | ||
| onValueChange={(v) => { | ||
| setReasonFilter(v as Reason | "all") | ||
| setPage(1) | ||
| }} | ||
| > | ||
| <SelectTrigger aria-label="Filter by reason" className="w-44"> | ||
| <SelectValue /> | ||
| </SelectTrigger> | ||
| <SelectContent> | ||
| <SelectItem value="all">All reasons</SelectItem> | ||
| {(Object.keys(REASON_LABEL) as Reason[]).map((r) => ( | ||
| <SelectItem key={r} value={r}> | ||
| {REASON_LABEL[r]} | ||
| </SelectItem> | ||
| ))} | ||
| </SelectContent> | ||
| </Select> | ||
| <p className="text-sm text-muted-foreground"> | ||
| {total.toLocaleString()} total | ||
| </p> | ||
|
|
@@ -237,7 +258,7 @@ | |
| <Skeleton className="h-64 w-full" /> | ||
| ) : data.length === 0 ? ( | ||
| <div className="border rounded-xl p-8 text-center text-muted-foreground"> | ||
| {search ? "No suppressions match your search." : "No suppressions yet."} | ||
| {search || reasonFilter !== "all" ? "No suppressions match your filters." : "No suppressions yet."} | ||
| </div> | ||
| ) : ( | ||
| <> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,20 +1,18 @@ | ||
| import { NextRequest, NextResponse } from 'next/server' | ||
| import { db } from '@/lib/db' | ||
| import { suppressions } from '@/lib/db/schema' | ||
| import { ilike, sql, desc } from 'drizzle-orm' | ||
| import { sql, desc } from 'drizzle-orm' | ||
| import { parseSuppressionListQuery, suppressionListWhere } from '@/lib/suppressions/list-query' | ||
| import { createSuppressionSchema } from '@/lib/validations/suppressions' | ||
| import { suppressEmail, normalizeEmail } from '@/lib/suppressions' | ||
| import { auditFromSession, logAudit } from '@/lib/audit' | ||
| import { requireAdmin } from '@/lib/auth-helpers' | ||
|
|
||
| export async function GET(req: NextRequest) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π [ai] Suppression list exposes all suppressed email addresses to any authenticated non-admin user β Risk: 55/100 The internal suppressions list endpoint The The dashboard middleware ( This weakness predates the changed revision (it is in the original
Evidence: export async function GET(req: NextRequest) {
const parsed = parseSuppressionListQuery(new URL(req.url).searchParams)
if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 })No The live request was not executed because the application could not be started in the sandbox (no installed dependencies and no database). Reachability depends on standard member sign-in, which the code allows. Checked for a guard that might run elsewhere. Proof of concept: # Replace SESSION with the next-auth session cookie of a non-admin member account.
curl -s -b "next-auth.session-token=$SESSION" \
"https://<dashboard-host>/api/internal/suppressions?limit=200&page=1" | head -c 600
curl -s -o /dev/null -w "%{http_code}\n" -b "next-auth.session-token=$SESSION" \
"https://<dashboard-host>/api/internal/api-keys"π AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. |
||
| const { searchParams } = new URL(req.url) | ||
| const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10) || 1) | ||
| const limitRaw = parseInt(searchParams.get('limit') ?? '50', 10) || 50 | ||
| const limit = Math.min(200, Math.max(1, limitRaw)) | ||
| const search = (searchParams.get('search') ?? '').trim() | ||
|
|
||
| const where = search ? ilike(suppressions.email, `%${search.toLowerCase()}%`) : undefined | ||
| const parsed = parseSuppressionListQuery(new URL(req.url).searchParams) | ||
| if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 }) | ||
|
Check notice on line 13 in app/api/internal/suppressions/route.ts
|
||
| const { page, limit } = parsed.query | ||
|
Check warning on line 14 in app/api/internal/suppressions/route.ts
|
||
| const where = suppressionListWhere(parsed.query) | ||
|
|
||
| const dataQuery = db | ||
| .select() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { and, eq, ilike, type SQL } from 'drizzle-orm' | ||
|
Check notice on line 1 in lib/suppressions/list-query.ts
|
||
| import { suppressions } from '@/lib/db/schema' | ||
| import { SUPPRESSION_REASONS } from '@/lib/validations/suppressions' | ||
|
|
||
| export type SuppressionReasonFilter = (typeof SUPPRESSION_REASONS)[number] | ||
|
|
||
| export interface SuppressionListQuery { | ||
| page: number | ||
| limit: number | ||
| search: string | ||
| reason: SuppressionReasonFilter | null | ||
| } | ||
|
|
||
| export type ParsedSuppressionListQuery = | ||
| | { ok: true; query: SuppressionListQuery } | ||
| | { ok: false; error: string } | ||
|
|
||
| /** | ||
| * Parse `page`, `limit`, `search` and `reason` from a suppressions list | ||
| * request. An unknown `reason` is an error rather than "no filter", so a | ||
| * typo never silently returns every suppression. | ||
| */ | ||
| export function parseSuppressionListQuery(searchParams: URLSearchParams): ParsedSuppressionListQuery { | ||
| const page = Math.max(1, parseInt(searchParams.get('page') ?? '1', 10) || 1) | ||
|
Check notice on line 24 in lib/suppressions/list-query.ts
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π [deterministic] Biome: lint/style/useNumberNamespace β Risk: 55/100 Use Number.parseInt instead of the equivalent global. π AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. |
||
| const limitRaw = parseInt(searchParams.get('limit') ?? '50', 10) || 50 | ||
|
Check notice on line 25 in lib/suppressions/list-query.ts
|
||
| const limit = Math.min(200, Math.max(1, limitRaw)) | ||
| const search = (searchParams.get('search') ?? '').trim() | ||
| const rawReason = (searchParams.get('reason') ?? '').trim().toLowerCase() | ||
| if (rawReason && rawReason !== 'all' && !(SUPPRESSION_REASONS as readonly string[]).includes(rawReason)) { | ||
| return { ok: false, error: `Unknown reason "${rawReason}". Use one of: ${SUPPRESSION_REASONS.join(', ')}.` } | ||
| } | ||
| const reason = rawReason && rawReason !== 'all' ? (rawReason as SuppressionReasonFilter) : null | ||
| return { ok: true, query: { page, limit, search, reason } } | ||
| } | ||
|
|
||
| /** The WHERE clause for a list query; undefined when nothing is filtered. */ | ||
| export function suppressionListWhere(query: Pick<SuppressionListQuery, 'search' | 'reason'>): SQL | undefined { | ||
| const conditions: SQL[] = [] | ||
| if (query.search) conditions.push(ilike(suppressions.email, `%${query.search.toLowerCase()}%`)) | ||
|
Check notice on line 39 in lib/suppressions/list-query.ts
|
||
| if (query.reason) conditions.push(eq(suppressions.reason, query.reason)) | ||
| if (conditions.length === 0) return undefined | ||
| return conditions.length === 1 ? conditions[0] : and(...conditions) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import { describe, it } from 'node:test' | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π [deterministic] Regression: the tests failed before any test reported a result β Risk: 75/100 This PR breaks tests in Failing tests
Reproduce: π AI fix prompt (copy & paste into your coding agent)Flagged by Autter security & observability checks. |
||
| import assert from 'node:assert/strict' | ||
| import { parseSuppressionListQuery } from '@/lib/suppressions/list-query' | ||
|
|
||
| const parse = (qs: string) => parseSuppressionListQuery(new URLSearchParams(qs)) | ||
|
|
||
| describe('parseSuppressionListQuery', () => { | ||
| it('defaults to the first page of 50 with no filters', () => { | ||
| assert.deepEqual(parse(''), { ok: true, query: { page: 1, limit: 50, search: '', reason: null } }) | ||
| }) | ||
|
|
||
| it('accepts a known reason, case-insensitively, and "all" as no filter', () => { | ||
| assert.deepEqual(parse('reason=Bounce'), { ok: true, query: { page: 1, limit: 50, search: '', reason: 'bounce' } }) | ||
| assert.deepEqual(parse('reason=all'), { ok: true, query: { page: 1, limit: 50, search: '', reason: null } }) | ||
| }) | ||
|
|
||
| it('rejects an unknown reason instead of returning everything', () => { | ||
| const parsed = parse('reason=bounced') | ||
| assert.equal(parsed.ok, false) | ||
| if (!parsed.ok) assert.match(parsed.error, /Unknown reason "bounced"/) | ||
| }) | ||
|
|
||
| it('clamps page and limit and trims the search', () => { | ||
| assert.deepEqual(parse('page=-3&limit=999&search=%20a%40b.com%20'), { | ||
| ok: true, | ||
| query: { page: 1, limit: 200, search: 'a@b.com', reason: null }, | ||
| }) | ||
| }) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π‘ [ai] Older filter responses can replace the active result set β Risk: 35/100
Changing the reason selector starts a new list request, but each response unconditionally writes its data into the page. If an earlier, broader request resolves after a newer reason-filtered request (for example, when the user changes the filter quickly on a slow connection), it replaces the visible rows and total while the selector still shows the newer reason; the displayed list can therefore contradict the active filter and expose the wrong Remove actions. Abort superseded requests or ignore responses that no longer match the latest query before committing state.
app/(dashboard)/settings/suppressions/page.tsx,app/api/internal/suppressions/route.tsπ AI fix prompt (copy & paste into your coding agent)
Flagged by Autter security & observability checks.