Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions app/(dashboard)/settings/suppressions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("")
Expand All @@ -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

View check run for this annotation

Autter.dev / autter/review-gate

🟠 Medium · [P2] Ignore stale filter fetch responses

Changing the reason filter starts a new request, but the effect does not cancel or ignore requests already in flight. If a user selects one reason and then another before the first response arrives, the older response can resolve last and overwrite the table and total while the select shows the newer reason, presenting suppressions that do not match the active filter. Guard each request with an AbortController or request ID and only commit the latest response. Suggested fix: Make filter fetches safe against out-of-order completion: abort the previous request or track a request ID, and only update data, total, and loading state for the latest active request; clean up on effect teardown.

Copy link
Copy Markdown

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.

⚠ Downstream affected β€” if this fails, it cascades to the usage that depends on this file:

  • Dependent files: app/(dashboard)/settings/suppressions/page.tsx, app/api/internal/suppressions/route.ts
πŸ›  AI fix prompt (copy & paste into your coding agent)
Prevent stale list requests from updating state: abort the previous request or track a request/query identity and only apply data, total, and loading updates for the latest active query.

Flagged by Autter security & observability checks.

const res = await fetch(`/api/internal/suppressions?${params.toString()}`)
if (!res.ok) throw new Error()
const json = (await res.json()) as ListResponse
Expand All @@ -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

View check run for this annotation

Autter.dev / autter/review-gate

πŸ’‘ Suggestion Β· Broken invariant

Changing the reason filter, search, or page starts a fetch, but earlier requests are neither cancelled nor ignored when they resolve late. A slower response for an old selection can overwrite both the table rows and total for the current selection. Consider aborting superseded requests or ignoring responses that are no longer current. Suggested fix: Handle the concurrent update case at app/(dashboard)/settings/suppressions/page.tsx:112. the user picks reason=bounce, then quickly switches to reason=complaint, so two fetchData calls are in flight with no abort or sequence guard currently if the bounce response resolves after the complaint response, setData stores the stale bounce rows while the select and the total show complaint. The table contradicts the active filter until the next interaction. β€” add the guard / idempotency / transaction / bound that makes this case safe, then confirm the assumption "the suppressions list always shows results for the currently selected filters" holds. Blast radius β€” this counterexample can fail the downstream usage that depends on this file: functions `reasonBadgeClass`, `SuppressionsPage`, `GET`; endpoints `GET /api/internal/suppressions`, `GET search`, `GET limit`, `GET page`, `GET /api/v1/suppressions`; scopes `app`; dependent files `react`, `next/link`, `@/components/ui/table`, `@/components/ui/button`, `@/components/ui/input`, `@/components/ui/label`, `@/components/ui/badge`, `@/components/ui/dialog`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.

⚠ Downstream affected β€” if this fails, it cascades to the usage that depends on this file:

  • Dependent files: app/(dashboard)/settings/suppressions/page.tsx
πŸ›  AI fix prompt (copy & paste into your coding agent)
Make list fetches latest-request-wins: use an AbortController with cleanup or an incrementing request ID/ignore flag so obsolete requests cannot update data, total, or loading state.

Flagged by Autter security & observability checks.


useEffect(() => { fetchData() }, [fetchData])

Expand Down Expand Up @@ -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>
Expand All @@ -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>
) : (
<>
Expand Down
14 changes: 6 additions & 8 deletions app/api/internal/suppressions/route.ts
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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 GET /api/internal/suppressions returns every suppressed email address with its reason, source, and metadata. Unlike the other admin-only internal GET handlers and the admin-only settings page, it does not require an admin role, so any signed-in member can read the full list.

The GET handler at app/api/internal/suppressions/route.ts:11 parses the query and runs db.select().from(suppressions) with no authorization check. The POST handler in the same file calls requireAdmin() at line 36, and the file imports it at line 9. Sibling internal GET handlers guard the same way, for example app/api/internal/api-keys/route.ts:10-12 and app/api/internal/team/members/route.ts:7-9, both of which call requireAdmin() before reading data.

The dashboard middleware (middleware.ts) only enforces a session for /api/internal/*, because that prefix is not in PUBLIC_PATHS. Any signed-in user passes that check. The session role is member for non-admin accounts (lib/auth.ts, role: user.role === 'admin' ? 'admin' : 'member'). The UI hides the Suppressions settings entry from non-admins (components/dashboard/sidebar.tsx, adminOnly: true), so the restriction exists in the UI and not on the API.

This weakness predates the changed revision (it is in the original GET at the base commit). The diff rewrites this handler to use parseSuppressionListQuery and suppressionListWhere but keeps the missing guard, so the gap remains live on the modified handler.

  1. Sign in to the dashboard with a non-admin (member) account.
  2. Send an authenticated request to GET /api/internal/suppressions?limit=200&page=1 using that session.
  3. Observe HTTP 200 with the data array containing suppressed email addresses, reasons, and sources.
  4. For comparison, send the same request to GET /api/internal/api-keys with the same session and observe HTTP 403 Admin access required, confirming that the admin-only pattern is enforced on sibling endpoints.

Evidence:
Static trace, complete from route to data:

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 requireAdmin() call exists in the GET path. The POST path at line 36 calls it. The sibling GET handlers at app/api/internal/api-keys/route.ts:10-12 and app/api/internal/team/members/route.ts:7-9 return 401 or 403 for non-admins.

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. middleware.ts only enforces session presence for /api/internal/*, not role. No route-level or wrapper guard exists in the GET path. The sidebar hides the page for non-admins, but that is UI-only and does not protect the API. Possible intent that members can view suppressions is contradicted by the admin-only settings nav entry and the admin guard on the POST path of the same file. Not executed against a live instance, so a live 200 response for a member session is not yet observed; this is the main residual uncertainty. Data exposed is recipient emails and suppression metadata, which limits this to C:L and no integrity or availability impact.

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://&lt;dashboard-host&gt;/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://&lt;dashboard-host&gt;/api/internal/api-keys"
πŸ›  AI fix prompt (copy & paste into your coding agent)
Add the same admin guard used by the other admin-only internal handlers to the start of the `GET` handler in `app/api/internal/suppressions/route.ts`. Keep the existing query parsing and response shape unchanged. Add a regression test that a non-admin session receives 403 from the list endpoint. Review the other `app/api/internal/*` GET handlers that lack `requireAdmin()` and confirm each is meant to be readable by members.

Impact to account for:
Any authenticated dashboard user with the default `member` role can read the complete suppression list, which contains recipient email addresses (personal data) and suppression metadata, across the whole workspace. The issue is read-only: the request cannot create, modify, or delete suppressions. Confidentiality impact is limited to suppression-list contents.

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

View check run for this annotation

Autter.dev / autter/review-gate

🟑 Low · HTTP status code changed

GET /api/internal/suppressions now returns HTTP 400 for query values that previously fell back to defaults with a 200 response. The only consumer indexed here is the internal frontend, so impact is limited, but any caller or page that passes unvalidated params (for example a stale link with a bad limit) will now show an error instead of data. Blast radius β€” if this contract change ships, the following consumers break: functions `GET`, `SuppressionsPage`; endpoints `GET /api/internal/suppressions`, `GET search`, `GET limit`, `GET page`, `GET /api/v1/suppressions`; scopes `app`; dependent files `next/server`, `@/lib/db`, `@/lib/db/schema`, `drizzle-orm`, `@/lib/validations/suppressions`, `@/lib/suppressions`, `@/lib/audit`, `@/lib/auth-helpers`. **References:** - https://www.rfc-editor.org/rfc/rfc9110#name-status-codes β€” The normative meaning of each status class; clients branch on these, so changing them is breaking. - https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status β€” Reference for which status code fits each success and error case. Suggested fix: In app/api/internal/suppressions/route.ts, verify that the settings suppressions page only sends valid page, limit, and search values, so the new 400 branch is not hit during normal use. If the frontend could send edge-case values, either keep the previous defaulting behavior or add handling in the page for the 400 response. Add a test for the malformed-parameter path. Blast radius β€” if this contract change ships, the following consumers break: functions `GET`, `SuppressionsPage`; endpoints `GET /api/internal/suppressions`, `GET search`, `GET limit`, `GET page`, `GET /api/v1/suppressions`; scopes `app`; dependent files `next/server`, `@/lib/db`, `@/lib/db/schema`, `drizzle-orm`, `@/lib/validations/suppressions`, `@/lib/suppressions`, `@/lib/audit`, `@/lib/auth-helpers`.
const { page, limit } = parsed.query

Check warning on line 14 in app/api/internal/suppressions/route.ts

View check run for this annotation

Autter.dev / autter/review-gate

🟠 Medium · Missing admin authorization on suppression list endpoint exposes all suppressed emails t

The internal `GET /api/internal/suppressions` endpoint returns the full global suppression list (email addresses, reason, source, and webhook metadata) to any authenticated session, including `member` role users. The sibling `POST` handler on the same route requires the admin role, so the access check is inconsistent within the route. The UI hides this page from members, but the API does not enforce that. The `GET` handler in `app/api/internal/suppressions/route.ts` parses the query with `parseSuppressionListQuery`, builds a filter with `suppressionListWhere`, and returns `db.select().from(suppressions)` rows directly. It never calls `requireAdmin()`. Authentication is enforced only by the global `withAuth` middleware in `middleware.ts`, which checks that a session exists and does not check the role. `requireAdmin()` in `lib/auth-helpers.ts` returns 403 for any role other than `admin`, and the `POST` handler on the same file calls it. Commit `7ef25d1` added the guard to `POST` only. The `suppressions` table has no tenant or organization column, so the response is not scoped to any organization. The application is single-tenant, so this issue is vertical access (member to admin data), not cross-tenant. 1. Sign in to the dashboard as a user with the `member` role. 2. Send `GET /api/internal/suppressions?limit=200` using that session cookie. 3. Observe HTTP 200 with the full list of suppressed emails, reasons, sources, and metadata. 4. Send `POST /api/internal/suppressions` with the same session and a JSON body containing an email. Observe HTTP 403 `Admin access required`, which confirms the role model the POST enforces. Evidence: Local validation against the dev server with a seeded admin and member account. ``` == anonymous GET [307] -&gt; redirected to /login by middleware == member GET (before fix) {"data":[{"email":"victim-one@​example.net","reason":"bounce","source":"webhook",...}],"meta":{...}} [200] == member POST (control) {"error":"Admin access required"} [403] == member GET (after fix) {"error":"Admin access required"} [403] == admin GET (after fix) {"data":[{"email":"victim-one@​example.net","reason":"bounce",...}],"meta":{...}} [200] ``` The UI page `app/(dashboard)/settings/suppressions/page.tsx` is hidden from members by `components/dashboard/sidebar.tsx` (`adminOnly: true`), but that is UI-only and does not block the API. Middleware `middleware.ts` enforces only that a session exists, so it does not help. No tenant column exists, so no cross-tenant isolation is expected. The `lib/suppressions/list-query.ts` helpers do not add any role check. I verified dynamically that a member session receives the full list. Impact is limited to read access: the emails are already sent-to addresses in the workspace, so exposure is confined to the workspace's own members. Severity is therefore moderate rather than high. Proof of concept: ```bash curl -s -b member-session.jar "https://&lt;host&gt;/api/internal/suppressions?limit=200" # Expected for a member: 403. Observed: 200 with the full suppression list. curl -s -b member-session.jar -X POST -H 'Content-Type: application/json' \ -d '{"email":"probe@​example.com"}' "https://&lt;host&gt;/api/internal/suppressions" # Observed: 403 {"error":"Admin access required"} ``` Suggested fix: Add the same admin guard to the `GET` handler in `app/api/internal/suppressions/route.ts` that `POST` already uses, so that non-admin sessions receive 403 before any query runs. Also confirm that the `/api/v1/suppressions` route and the suppression upload routes apply the intended role policy, since the docs describe GET as session-only. Impact to account for: Any authenticated member can read every suppressed email address in the workspace, along with the reason, source, and metadata for each row, through the internal API. This is an unauthorized read of restricted data. The member cannot modify the list, because POST still returns 403.
const where = suppressionListWhere(parsed.query)

const dataQuery = db
.select()
Expand Down
14 changes: 6 additions & 8 deletions app/api/v1/suppressions/route.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
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 { withApiAuth } from '@/lib/api-auth'
import { createSuppressionSchema } from '@/lib/validations/suppressions'
import { suppressEmail, normalizeEmail } from '@/lib/suppressions'
import { auditFromApiKey, logAudit } from '@/lib/audit'

export async function GET(req: NextRequest) {
return withApiAuth(req, async () => {
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, data: null, meta: {} }, { status: 400 })
const { page, limit } = parsed.query
const where = suppressionListWhere(parsed.query)

const dataQuery = db
.select()
Expand Down
43 changes: 43 additions & 0 deletions lib/suppressions/list-query.ts
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

View check run for this annotation

Autter.dev / autter/review-gate

πŸ’‘ Suggestion Β· PR mixes refactor and behavior change

The PR moves page, limit and search parsing out of both route handlers into a shared module (a no-behaviour refactor) in the same change that adds the reason filter and its 400 behaviour. `lib/suppressions/list-query.ts` and `app/api/internal/suppressions/route.ts` are the files contributing most to the mix. Land the extraction of parseSuppressionListQuery and suppressionListWhere first as a behaviour-preserving refactor with its existing tests, then add the reason parameter, the 400 response and the UI select as a follow-up. Blast radius β€” if the refactor introduces a regression the behavior change masks it on: endpoints `GET /api/internal/suppressions`, `GET search`, `GET limit`, `GET page`, `GET /api/v1/suppressions`. **References:** - https://trunkbaseddevelopment.com/ β€” Ship small changes to trunk often; separate refactors, migrations and behavior changes. - https://martinfowler.com/articles/branching-patterns.html β€” Frequent integration of small changes keeps merges and reviews cheap. Suggested fix: Split this PR so refactors land separately from behavior changes. Pure-refactor PRs should preserve behavior (no test changes beyond renames). Behavior-change PRs should focus on a single new capability. Start by extracting `lib/suppressions/list-query.ts`'s refactor portion (or its behavior portion, whichever is smaller) into its own PR. References: https://trunkbaseddevelopment.com/ https://martinfowler.com/articles/branching-patterns.html
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

View check run for this annotation

Autter.dev / autter/review-gate

πŸ’‘ Suggestion Β· Unbounded page can produce an invalid database offset

`page` is only clamped to a minimum, so a sufficiently large numeric value produces an offset beyond PostgreSQL’s bigint range and the query can fail. The route does not catch that failure and return a JSON error response. Consider rejecting or capping oversized page values. Suggested fix: Handle the boundary input case at lib/suppressions/list-query.ts:24. GET /api/internal/suppressions?page=99999999999999999999 (no upper bound on page, so the offset becomes roughly 5e21) currently parseInt yields about 1e20 and Math.max(1,...) does not cap it, so offset = (page-1)*limit exceeds the Postgres bigint range. The query fails and the route returns an unhandled 500 instead of a 400 JSON error. The clamp only enforces a lower bound. β€” add the guard / idempotency / transaction / bound that makes this case safe, then confirm the assumption "page and limit are clamped to a sane range, so the computed OFFSET is always a valid value for the database" holds. Blast radius β€” this counterexample can fail the downstream usage that depends on this file: endpoints `GET /api/internal/suppressions`, `GET search`, `GET limit`, `GET page`, `GET /api/v1/suppressions`.

Check notice on line 24 in lib/suppressions/list-query.ts

View check run for this annotation

Autter.dev / autter/review-gate

πŸ’‘ Suggestion Β· Biome: lint/style/useNumberNamespace

Use Number.parseInt instead of the equivalent global. Suggested fix: Fix the Biome `lint/style/useNumberNamespace` issue at lib/suppressions/list-query.ts:24: Use Number.parseInt instead of the equivalent global.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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)
Fix the Biome `lint/style/useNumberNamespace` issue at lib/suppressions/list-query.ts:24: Use Number.parseInt instead of the equivalent global.

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

View check run for this annotation

Autter.dev / autter/review-gate

πŸ’‘ Suggestion Β· Biome: lint/style/useNumberNamespace

Use Number.parseInt instead of the equivalent global. Suggested fix: Fix the Biome `lint/style/useNumberNamespace` issue at lib/suppressions/list-query.ts:25: Use Number.parseInt instead of the equivalent global.
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

View check run for this annotation

Autter.dev / autter/review-gate

πŸ’‘ Suggestion Β· Broken invariant

Assumes: the search filter matches emails containing the typed text literally. Counterexample (boundary input): search=_ (or %), which is passed into ilike without escaping LIKE wildcards β†’ the underscore matches any single character, so searching for a literal '_' returns every suppression whose email has at least one character. The result set and total are wrong for such queries. Blast radius β€” this counterexample can fail the downstream usage that depends on this file: endpoints `GET /api/internal/suppressions`, `GET search`, `GET limit`, `GET page`, `GET /api/v1/suppressions`. Suggested fix: Handle the boundary input case at lib/suppressions/list-query.ts:39. search=_ (or %), which is passed into ilike without escaping LIKE wildcards currently the underscore matches any single character, so searching for a literal '_' returns every suppression whose email has at least one character. The result set and total are wrong for such queries. β€” add the guard / idempotency / transaction / bound that makes this case safe, then confirm the assumption "the search filter matches emails containing the typed text literally" holds. Blast radius β€” this counterexample can fail the downstream usage that depends on this file: endpoints `GET /api/internal/suppressions`, `GET search`, `GET limit`, `GET page`, `GET /api/v1/suppressions`.
if (query.reason) conditions.push(eq(suppressions.reason, query.reason))
if (conditions.length === 0) return undefined
return conditions.length === 1 ? conditions[0] : and(...conditions)
}
29 changes: 29 additions & 0 deletions test/suppression-list-query.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, it } from 'node:test'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 test/suppression-list-query.test.ts, which covers code the change affects. They pass on the base commit e6eefa4.

Failing tests

  • test/suppression-list-query.test.ts β€” the tests failed before any test reported a result β€” The test suite expected the environment variable TEST_DATABASE_URL to be set, but it was missing during CI execution.

Reproduce: npm run test in .

πŸ›  AI fix prompt (copy & paste into your coding agent)
These tests in test/suppression-list-query.test.ts fail after this PR (they cover code the change affects): the tests failed before any test reported a result (test/suppression-list-query.test.ts): The test suite expected the environment variable TEST_DATABASE_URL to be set, but it was missing during CI execution.. Run `npm run test` in `.` and fix the code (or the test, if it is genuinely stale).

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 },
})
})
})
Loading