From ff97751d205f15eacf8557eb1ead799c4d7e390f Mon Sep 17 00:00:00 2001 From: ZacxDev Date: Thu, 3 Sep 2026 18:11:52 -0500 Subject: [PATCH] fix(moderator): stop the lookup panel hiding an open case, and stop offering rulings that cannot land MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things, all from the audit on this PR. 1. User Lookup showed the newest restriction of ANY type — three correlated subqueries, `ORDER BY ur.id DESC LIMIT 1`, no type predicate. That was sound while a user could hold at most one open row. Per-type dedupe lets two coexist, so a Pending generation case sitting behind a later Upheld bot-account row rendered as no open restriction at all: the account stays muted, the ruling form is never drawn, and nobody can see the open case. Now `ORDER BY (ur.status = 'Pending') DESC, ur.id DESC` — a Pending case outranks a merely newer one; among several Pending rows, the newest wins; a resolved row is shown only when there is no open one. The panel still speaks for ONE row (a header chip and a single form; the audit queue is where a list belongs), and the ordering is now written once and called three times rather than copied per column, so the three cannot stop naming the same row. 2. `RULINGS_WIRED_FOR` / `unwiredRulingReason` move into `$lib/restriction-types` and are imported by the audit route instead of re-spelled there. The refusal that protects the account now lives in the main app's `resolveUserRestriction`; the route check is KEPT, not for defence in depth but for ordering — `ban` bans and THEN rules, so a refusal arriving inside the verdict call would leave the account banned against a restriction nobody can close. The seam test pins this copy to the main app's in both directions, list and wording. 3. The Bot account queue rendered live Uphold / Remove / Ban forms whose only possible outcome was a 400, and the retool panel offered Overturn / Uphold on a row the verdict path refuses. Both now disable those controls and say why. The server-side refusals are unchanged — they hold against a posted id, which nothing rendered can. Also: `restrictionById`'s comment claimed `unwiredRuling` governs which types an action can be handed; `flagSuspicious` calls it with `type: 'any'` and rules on nothing. The comment now says what the code does and why flagging is deliberately type-agnostic. And `capturingDb`'s `params` is `unknown[][]`, not a `readonly` contract cast away at its one use. Tests: new `user-lookup-restriction-row.test.ts` asserts the Pending-first ordering, the total tiebreak and the per-account scope on all three compiled subqueries; new `restriction-types.test.ts` covers the shared predicate the disabled controls read. The audit route gains an invariant guard that its refusal is the shared predicate's own output rather than a second copy. There is no component-test harness in this app, so the rendering half of (3) is not covered by a test. --- .../lib/__tests__/restriction-types.test.ts | 51 +++++++++ apps/moderator/src/lib/restriction-types.ts | 25 +++++ .../user-lookup-restriction-row.test.ts | 100 ++++++++++++++++++ .../src/lib/server/user-lookup.service.ts | 48 ++++++--- .../lib/server/user-restriction.service.ts | 2 + .../generator-restrictions/+page.server.ts | 29 +++-- .../RestrictionDetail.svelte | 21 +++- .../__tests__/type-queue.test.ts | 27 +++++ .../user-lookup/AccountActionsPanel.svelte | 26 ++++- apps/moderator/src/test/capture-sql.ts | 8 +- 10 files changed, 310 insertions(+), 27 deletions(-) create mode 100644 apps/moderator/src/lib/__tests__/restriction-types.test.ts create mode 100644 apps/moderator/src/lib/server/__tests__/user-lookup-restriction-row.test.ts diff --git a/apps/moderator/src/lib/__tests__/restriction-types.test.ts b/apps/moderator/src/lib/__tests__/restriction-types.test.ts new file mode 100644 index 0000000000..fba04f6609 --- /dev/null +++ b/apps/moderator/src/lib/__tests__/restriction-types.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { + RESTRICTION_TYPE, + RESTRICTION_TYPES, + RESTRICTION_TYPE_LABELS, + RULINGS_WIRED_FOR, + unwiredRulingReason, +} from '$lib/restriction-types'; + +/** + * The one predicate three surfaces read: the audit queue's `resolve`/`ban` refusal, the audit queue's + * disabled ruling buttons, and the retool User Lookup panel's disabled ruling form. It was open-coded + * in the route before #4609's audit; a predicate spelled at N sites is wrong at N−1 of them, and here + * the sites disagreeing means a live button whose only possible outcome is a rejected call. + * + * The refusal that MATTERS is enforced by the main app, inside `resolveUserRestriction` — this list is + * the same rule read forward so a form that cannot succeed is never offered. The two are pinned to + * each other by `src/server/services/__tests__/restriction-type-seam.test.ts`. + */ +describe('unwiredRulingReason', () => { + it('permits a ruling on every wired-for type', () => { + // Non-vacuous: there is at least one, and it is the queue's default. + expect(RULINGS_WIRED_FOR.length).toBeGreaterThan(0); + expect(RULINGS_WIRED_FOR).toContain(RESTRICTION_TYPE); + for (const type of RULINGS_WIRED_FOR) expect(unwiredRulingReason(type)).toBeNull(); + }); + + it('refuses every filed type that has no verdict path, naming it', () => { + const unwired = RESTRICTION_TYPES.filter((t) => !RULINGS_WIRED_FOR.includes(t)); + // The guard is only worth anything while some type is refused; today that is `bot-account`. + expect(unwired.length).toBeGreaterThan(0); + + for (const type of unwired) { + const reason = unwiredRulingReason(type); + // The type is named because a moderator has to be able to tell WHICH queue is review-only, and + // the message doubles as the audit route's `fail(400)` body. + expect(reason).toContain(`"${type}"`); + expect(reason).toContain('NOT resolved'); + } + }); + + it('refuses a type nobody has heard of, rather than defaulting it in', () => { + // The value reaching this can come off a database row, so it is not confined to the union. + for (const type of ['', 'GENERATION', 'generation ', 'nonsense']) + expect(unwiredRulingReason(type)).not.toBeNull(); + }); + + it('keeps a label for every filed type, so a refused queue can still be named on screen', () => { + expect(Object.keys(RESTRICTION_TYPE_LABELS).sort()).toEqual([...RESTRICTION_TYPES].sort()); + }); +}); diff --git a/apps/moderator/src/lib/restriction-types.ts b/apps/moderator/src/lib/restriction-types.ts index 743225f3f3..40edbcb9fc 100644 --- a/apps/moderator/src/lib/restriction-types.ts +++ b/apps/moderator/src/lib/restriction-types.ts @@ -25,3 +25,28 @@ export const RESTRICTION_TYPE_LABELS: Record = { generation: 'Generation', 'bot-account': 'Bot account', }; + +/** + * The types a VERDICT can be handed to — narrower than `RESTRICTION_TYPES`, which is what may be + * filed and reviewed. + * + * The refusal itself is enforced by the main app, one level below every ruling surface, in + * `resolveUserRestriction` — this app's ruling forms all post through `/api/mod/restriction/resolve`. + * This list is the same rule read forward instead of backward: it is what lets a form that cannot + * possibly succeed be disabled rather than merely rejected, and what lets the audit queue refuse a + * ban BEFORE it bans (that action bans and then rules, so a late refusal would leave the account + * banned against a restriction nobody can close). + * + * 🔴 Kept identical to the main app's `RULINGS_WIRED_FOR` by + * `src/server/services/__tests__/restriction-type-seam.test.ts`, which reads this file as text. The + * two apps are separate builds with no runtime import path between them, so a pinned copy is the + * strongest available form of "one rule, one place" — do not fork it by hand. + */ +export const RULINGS_WIRED_FOR: readonly RestrictionType[] = ['generation']; + +/** Why a ruling may not be handed to a row of this type, or `null` when it may. */ +export function unwiredRulingReason(type: string): string | null { + return (RULINGS_WIRED_FOR as readonly string[]).includes(type) + ? null + : `Rulings are not yet available for "${type}" restrictions — the verdict path still sends generation-specific notices. This restriction was NOT resolved.`; +} diff --git a/apps/moderator/src/lib/server/__tests__/user-lookup-restriction-row.test.ts b/apps/moderator/src/lib/server/__tests__/user-lookup-restriction-row.test.ts new file mode 100644 index 0000000000..195eb802a2 --- /dev/null +++ b/apps/moderator/src/lib/server/__tests__/user-lookup-restriction-row.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from 'vitest'; + +/** + * Which UserRestriction row the User Lookup header and its ruling form speak for. + * + * 🔴 The panel shows ONE row, and until #4609 it picked "newest of any type" — `ORDER BY ur.id DESC + * LIMIT 1`, no type predicate. That was sound only while a user could hold at most one open row. + * Restrictions now dedupe PER TYPE, so two open cases can coexist, and the old ordering had a silent + * failure mode: a Pending generation case sitting behind a LATER Upheld bot-account row rendered as + * *no open restriction at all* — the account stays muted, the ruling form is never drawn, and nobody + * looking at the account can see there is an open case. + * + * Asserted against the COMPILED SQL. There is no database in this tier, and the ordering is the whole + * behaviour — it decides which of several rows the moderator is shown, and it typechecks and lints + * identically either way. Same instrument the sibling `restriction-type-filter.test.ts` uses, for the + * same reason. + */ + +const captured = vi.hoisted(() => [] as string[]); + +// Built inside the factory, not in `vi.hoisted`: hoisted blocks run before this file's own imports. +vi.mock('$lib/server/db', async () => { + const { capturingDb } = await import('../../../test/capture-sql'); + // One canned row, so `executeTakeFirst` resolves and the chain does not stop early. + const db = capturingDb(captured, [{ id: 1 }]); + return { dbRead: db, dbWrite: db }; +}); + +// The identity query is the only thing under test here; its module's other exports reach a second +// database and an HTTP service, and none of them is on this path. +const { getIdentity } = await import('../user-lookup.service'); + +const identitySql = async (): Promise => { + captured.length = 0; + await getIdentity(42); + // 🔴 Count first. A chain that stopped early would leave `captured` empty while every assertion over + // its contents passed vacuously. + expect(captured).toHaveLength(1); + return captured[0].replace(/\s+/g, ' '); +}; + +/** + * The three correlated subqueries that read the account's restriction, sliced from the head of each + * `FROM "UserRestriction" ur` to its `LIMIT 1`. + */ +const restrictionSubqueries = (sql: string): string[] => + sql + .split('FROM "UserRestriction" ur') + .slice(1) + .map((chunk) => { + const end = chunk.indexOf('LIMIT 1'); + expect(end).toBeGreaterThan(-1); + return chunk.slice(0, end).trim(); + }); + +describe('user lookup — which restriction the panel speaks for', () => { + it('reads three restriction columns, which is what the panel needs to render a ruling form', async () => { + const sql = await identitySql(); + + // Positive control on the slicer, and the thing that makes every assertion below non-vacuous: a + // rename or a fourth column shows up here rather than silently reducing the loops to no-ops. + expect(restrictionSubqueries(sql)).toHaveLength(3); + for (const alias of ['restrictionStatus', 'restrictionType', 'restrictionId']) + expect(sql).toContain(`as "${alias}"`); + }); + + /** + * 🔴 The regression. A Pending row outranks a merely newer one, so an open case cannot be hidden + * behind a later resolved one of another type. + */ + it('prefers a Pending row over a newer one, in every restriction column', async () => { + const sql = await identitySql(); + + for (const sub of restrictionSubqueries(sql)) + expect(sub).toContain(`ORDER BY (ur.status = 'Pending') DESC, ur.id DESC`); + }); + + /** + * The tiebreak has to be TOTAL, or the three subqueries are free to resolve to different rows and + * the panel renders one row's status against another's id — a ruling form posting an id that does + * not belong to the case it is describing. + */ + it('breaks the tie on a unique column, so the three columns name one row', async () => { + const sql = await identitySql(); + const subs = restrictionSubqueries(sql); + + for (const sub of subs) expect(sub).toContain('ur.id DESC'); + // Identical apart from the column each selects — which is the only reason they agree. (An + // INVARIANT GUARD: true before this change too. Kept because the fix replaced three hand-written + // copies with one helper, and this is what stops them being hand-written again.) + const withoutSelectList = subs.map((s) => s.slice(s.indexOf('WHERE'))); + expect(new Set(withoutSelectList).size).toBe(1); + }); + + it('scopes each subquery to the account being looked up', async () => { + const sql = await identitySql(); + + for (const sub of restrictionSubqueries(sql)) expect(sub).toContain(`ur."userId" = u.id`); + }); +}); diff --git a/apps/moderator/src/lib/server/user-lookup.service.ts b/apps/moderator/src/lib/server/user-lookup.service.ts index e518940ef6..d49dc759aa 100644 --- a/apps/moderator/src/lib/server/user-lookup.service.ts +++ b/apps/moderator/src/lib/server/user-lookup.service.ts @@ -381,7 +381,38 @@ export async function getLeaderboardRanks(userId: number): Promise { +/** + * One field of the ONE restriction row this panel speaks for. + * + * 🔴 `ORDER BY (ur.status = 'Pending') DESC` first, `ur.id DESC` second — a PENDING case outranks a + * merely newer one. The old ordering was newest-of-any-type, which was sound only while a user could + * hold at most one open row. Restrictions now dedupe PER TYPE, so two open cases can coexist: a + * Pending generation case sitting behind a later Upheld bot-account row rendered as *no open + * restriction at all*, leaving the account muted, the ruling form unrendered, and nobody able to see + * the open case. Preferring Pending is what makes that impossible in either direction. + * + * The panel shows ONE row on purpose — it is a header chip plus a single ruling form, and the audit + * queue is where a list of cases belongs. So the one it shows is the one that can still be acted on; + * among several Pending rows it shows the newest, and a resolved row is only ever shown when there is + * no open one. + * + * Written once and called three times rather than spelled out per column: the three subqueries must + * name the SAME row, and three copies of an ordering rule is three places for it to stop agreeing. + * `ur.id DESC` makes the order total, so all three resolve to that same row deterministically. + * `expr` is a literal from this module, never caller input. + */ +function restrictionField(expr: string) { + return sql`( + SELECT ${sql.raw(expr)} FROM "UserRestriction" ur + WHERE ur."userId" = u.id + ORDER BY (ur.status = 'Pending') DESC, ur.id DESC + LIMIT 1 + )`; +} + +// Exported for the SQL-shape test — `getUserLookup` fans out over two databases, so reaching this +// query through it would be a test about the mocks rather than about the ordering above. +export async function getIdentity(userId: number): Promise { const row = await dbRead .selectFrom('User as u') .select([ @@ -418,18 +449,9 @@ async function getIdentity(userId: number): Promise { sql`(SELECT COUNT(*)::int FROM "CsamReport" cr WHERE cr."userId" = u.id)`.as( 'csamReportCount' ), - sql`( - SELECT ur.status::text FROM "UserRestriction" ur - WHERE ur."userId" = u.id ORDER BY ur.id DESC LIMIT 1 - )`.as('restrictionStatus'), - sql`( - SELECT ur.type FROM "UserRestriction" ur - WHERE ur."userId" = u.id ORDER BY ur.id DESC LIMIT 1 - )`.as('restrictionType'), - sql`( - SELECT ur.id FROM "UserRestriction" ur - WHERE ur."userId" = u.id ORDER BY ur.id DESC LIMIT 1 - )`.as('restrictionId'), + restrictionField('ur.status::text').as('restrictionStatus'), + restrictionField('ur.type').as('restrictionType'), + restrictionField('ur.id').as('restrictionId'), // The ticket asked for open reports against the account "very clearly at the top". A report // nobody has ruled on changes what every other panel means, and it was reachable only by // navigating to the Reports section and reading a list. diff --git a/apps/moderator/src/lib/server/user-restriction.service.ts b/apps/moderator/src/lib/server/user-restriction.service.ts index c5e5254a23..516ea51c77 100644 --- a/apps/moderator/src/lib/server/user-restriction.service.ts +++ b/apps/moderator/src/lib/server/user-restriction.service.ts @@ -11,6 +11,8 @@ export { RESTRICTION_TYPE, RESTRICTION_TYPES, RESTRICTION_TYPE_LABELS, + RULINGS_WIRED_FOR, + unwiredRulingReason, type RestrictionType, } from '$lib/restriction-types'; diff --git a/apps/moderator/src/routes/audit/generator-restrictions/+page.server.ts b/apps/moderator/src/routes/audit/generator-restrictions/+page.server.ts index ab39f72e78..33a38bf6a0 100644 --- a/apps/moderator/src/routes/audit/generator-restrictions/+page.server.ts +++ b/apps/moderator/src/routes/audit/generator-restrictions/+page.server.ts @@ -10,6 +10,7 @@ import { saveSuspiciousMatches, RESTRICTION_TYPE, RESTRICTION_TYPES, + unwiredRulingReason, type RestrictionRow, } from '$lib/server/user-restriction.service'; @@ -65,8 +66,13 @@ export const load: PageServerLoad = async ({ url }) => { // `type: 'any'` on purpose. A form posts to `?/resolve`, which REPLACES the query string — so an action // never sees the `type` the moderator was looking at, and defaulting the lookup would 404 every row // outside the default queue. The id is a primary key, so dropping the predicate cannot widen the -// result; what it changes is which types an action can be handed, and `unwiredRuling` below is what -// governs that. +// result; what it changes is which types an action can be handed. +// +// Each action decides that for itself, and they do NOT all decide the same way — so this helper deliberately +// makes no such decision. `resolve` and `ban` call `unwiredRuling` because they hand the row to a verdict +// path that is still generation-shaped. `flagSuspicious` does not, and should not: it copies selected +// triggers into the shared suspicious-match list, writes nothing to the account, and tells the user +// nothing. A prompt worth flagging is worth flagging whatever queue it was raised in. async function restrictionById(id: number): Promise { const { items } = await getGenerationRestrictions({ page: 1, @@ -88,13 +94,22 @@ async function restrictionById(id: number): Promise { * * This is a refusal rather than a hidden button because the check has to hold against a posted id, not * just against what the page chose to render. Lifting it means parameterising that verdict path first. + * (The buttons are disabled as well now — see `RestrictionDetail.svelte`. That is an addition to this + * check, never a substitute for it.) + * + * 🔴 KEPT even though the refusal is now enforced by `resolveUserRestriction` itself — which is what + * closes the surfaces this check could never reach: the retool User Lookup panel, the tRPC router and + * the REST endpoint were all unguarded while it lived only here. This is not defence in depth; it is + * ORDERING. The `ban` action bans and THEN rules, so a refusal arriving inside the verdict call would + * leave the account banned against a restriction nobody can close — the stranded Pending row that + * handler exists to avoid. It also renders as an inline message rather than a failed API call. + * + * The predicate is IMPORTED, never re-spelled: `unwiredRulingReason` is one function in + * `$lib/restriction-types`, pinned to the main app's `RULINGS_WIRED_FOR` by the seam test. A second + * spelling here is exactly how the two would drift. */ -const RULINGS_WIRED_FOR: readonly string[] = [RESTRICTION_TYPE]; - function unwiredRuling(row: RestrictionRow): string | null { - return RULINGS_WIRED_FOR.includes(row.type) - ? null - : `Rulings are not yet available for "${row.type}" restrictions — the verdict path still sends generation-specific notices. This restriction was NOT resolved.`; + return unwiredRulingReason(row.type); } export const actions: Actions = { diff --git a/apps/moderator/src/routes/audit/generator-restrictions/RestrictionDetail.svelte b/apps/moderator/src/routes/audit/generator-restrictions/RestrictionDetail.svelte index 0f0dea89bf..017634d0f9 100644 --- a/apps/moderator/src/routes/audit/generator-restrictions/RestrictionDetail.svelte +++ b/apps/moderator/src/routes/audit/generator-restrictions/RestrictionDetail.svelte @@ -9,6 +9,7 @@ import { LINK_CLASS, dateTime } from '$lib/format'; import { userLookupUrl } from '$lib/entity-url'; import type { RestrictionRow } from '$lib/server/user-restriction.service'; + import { unwiredRulingReason } from '$lib/restriction-types'; import UserWorkflowsPanel from '$lib/components/UserWorkflowsPanel.svelte'; import TriggerCard from './TriggerCard.svelte'; import StatusBadge from './StatusBadge.svelte'; @@ -34,6 +35,13 @@ const selected = new SvelteSet(); const toggle = (key: string) => (selected.has(key) ? selected.delete(key) : selected.add(key)); + // The SERVER refuses these rows too, and that refusal is the guard — it holds against a posted id, + // which nothing rendered here can. This only stops the moderator being INVITED to click a button + // that can never do anything: an Uphold form on a bot-account row is a live control whose only + // possible outcome is a 400. Flagging triggers stays available, because it writes nothing to the + // account and is not a verdict. + const unwiredReason = $derived(unwiredRulingReason(restriction.type)); + let banning = $state(false); // `onSubmit` picks the successor row while the current list still holds it — the reload that follows @@ -82,21 +90,28 @@ {/if} {#if restriction.status === 'Pending'} + {#if unwiredReason} +

+ {unwiredReason} +

+ {/if}
- +
- +
{#if canBan} - + + {/if} {#if selected.size > 0} diff --git a/apps/moderator/src/routes/audit/generator-restrictions/__tests__/type-queue.test.ts b/apps/moderator/src/routes/audit/generator-restrictions/__tests__/type-queue.test.ts index dcb6927fbb..8dcf7c94a6 100644 --- a/apps/moderator/src/routes/audit/generator-restrictions/__tests__/type-queue.test.ts +++ b/apps/moderator/src/routes/audit/generator-restrictions/__tests__/type-queue.test.ts @@ -43,6 +43,9 @@ vi.mock('$lib/server/access', () => ({ })); const { load, actions } = await import('../+page.server'); +const { RESTRICTION_TYPES, RULINGS_WIRED_FOR, unwiredRulingReason } = await import( + '$lib/restriction-types' +); type LoadResult = { type: string; items: unknown[] }; const runLoad = (search = '') => @@ -203,6 +206,30 @@ describe('generator-restrictions actions — ruling scope', () => { ); }); + /** + * An INVARIANT GUARD — this passed before the route stopped spelling its own message, and is + * recorded as such rather than counted as regression coverage. + * + * What it pins is that the route READS the shared predicate rather than carrying a second copy. The + * refusal that actually protects the account now lives in the main app's `resolveUserRestriction` + * (this route posts through it), and the two are pinned to each other by the seam test — but only if + * this end of the chain is the shared list and not a private one that happens to agree today. + */ + it.each(RESTRICTION_TYPES.filter((t) => !RULINGS_WIRED_FOR.includes(t)))( + 'refuses %s with the shared predicate’s own words, not a second copy of them', + async (type) => { + getGenerationRestrictions.mockResolvedValue({ items: [row({ type })], totalCount: 1 }); + + const result = (await actions.resolve( + formEvent({ userRestrictionId: '5', status: 'Upheld' }) + )) as { status: number; data: { error: string } }; + + expect(result.data.error).toBe(unwiredRulingReason(type)); + // Non-vacuous: `toBe(null)` would also pass if the action had returned success. + expect(unwiredRulingReason(type)).not.toBeNull(); + } + ); + // A by-id lookup must not be filtered by the default type: a form posts to `?/resolve`, which // replaces the query string, so the action cannot know which queue the row came from. it('looks a restriction up across every type', async () => { diff --git a/apps/moderator/src/routes/retool/user-lookup/AccountActionsPanel.svelte b/apps/moderator/src/routes/retool/user-lookup/AccountActionsPanel.svelte index 4af35a3207..f7b078ff62 100644 --- a/apps/moderator/src/routes/retool/user-lookup/AccountActionsPanel.svelte +++ b/apps/moderator/src/routes/retool/user-lookup/AccountActionsPanel.svelte @@ -10,6 +10,7 @@ import { fetchSupport } from './user-support'; import { REWARDS_ELIGIBILITY } from './enforcement-options'; import { FormState } from '$lib/form-state.svelte'; + import { unwiredRulingReason } from '$lib/restriction-types'; import ErrorAlert from '$lib/components/ErrorAlert.svelte'; type Identity = NonNullable['identity']; @@ -42,6 +43,13 @@ const support = $derived(browser ? fetchSupport(identity.id, version) : null); const mutesUrl = $derived(userLookupUrl(identity.id, 'mutes')); + // A restriction of a type with no verdict path cannot be ruled on ANYWHERE — `resolveUserRestriction` + // in the main app refuses it, which is what this panel posts through. Without this the panel offered + // Overturn/Uphold on such a row, and clicking Overturn would have sent a "your generation access has + // been restored" notice and reset the account's prompt-violation counter over an unrelated case. + // Falls back to `generation` for a null type, matching the label below. + const unwiredReason = $derived(unwiredRulingReason(identity.restrictionType ?? 'generation')); + // `reload: true` because these writes change `identity`, which DOES come from `load` — unlike the // panels fed by `/api/*`, where reloading re-runs the reaction scan for nothing. // @@ -87,6 +95,14 @@ A {identity.restrictionType ?? 'generation'} restriction on this account is awaiting a ruling. Unmuting alone leaves it Pending — rule on it here instead.

+ {#if unwiredReason} +

+ {unwiredReason} Review it in the + + restriction queue + . +

+ {/if}
@@ -97,7 +113,13 @@ />
- diff --git a/apps/moderator/src/test/capture-sql.ts b/apps/moderator/src/test/capture-sql.ts index 86ab01c340..3a045b8808 100644 --- a/apps/moderator/src/test/capture-sql.ts +++ b/apps/moderator/src/test/capture-sql.ts @@ -32,11 +32,15 @@ import { * cannot see it: `where('ur.type','=',x)` compiles to `"ur"."type" = $1` for every `x`, so a mutant that * ignores its argument and hardcodes a literal emits byte-identical SQL and survives an assertion over * `sql`. `params[i]` holds the parameters of `sql[i]`. + * + * `params` is a MUTABLE `unknown[][]` and says so. Being written is the parameter's whole purpose, and + * a `readonly` annotation that has to be cast away at the one place it is used is a contract the code + * contradicts — it reads as a promise to callers that this function does not touch their array. */ export function capturingDb( sql: string[], rows: unknown[] = [], - params?: readonly unknown[][] + params?: unknown[][] ): Kysely { class CannedRowDriver extends DummyDriver { async acquireConnection(): Promise { @@ -62,7 +66,7 @@ export function capturingDb( if (e.level !== 'query') return; sql.push(e.query.sql); // Pushed in the same branch so the two arrays stay index-aligned by construction. - (params as unknown[][] | undefined)?.push([...e.query.parameters]); + params?.push([...e.query.parameters]); }, }); }