mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
ec49115e55
* feat(user-restriction): make the pending-review mute type a parameter
Adds an optional `type` to `applyPendingReviewMute`, defaulting to
'generation' so both existing callers are byte-for-byte unchanged, and
teaches the moderator queue to show a second type. This is the enabling
seam for a bot-account detector that must file into the SAME review
queue rather than a new board; no detector logic ships here.
`UserRestriction.type` is free text with a [type, status] index, so a new
type needs no migration.
Dedupe is now scoped per type. Scoped to the user alone, the first queue
to mute an account would permanently silence every other queue for it:
a later finding of a different kind returns deduped against a row about
something else and files nothing a moderator can see.
Notifications are an opt-in per-type map, with null meaning "say
nothing". createNotification validates its type against nothing, so an
unregistered value is persisted and increments the unread badge while
the bell drops it at render, leaving a phantom count with no click
target; and reusing 'generation-muted' would tell a user their
generation access was restricted for something unrelated to generation.
A new type therefore stays silent until a processor is registered for it.
Verdicts are still generation-shaped, so the moderator resolve and ban
actions refuse a row of any other type rather than sending a misleading
notice. Parameterising that path is deliberately left out of scope.
* test(user-restriction): close two tests that passed against base code
Both were found by running the new suites against pre-change source, and
both were green there for a reason unrelated to what they claim to test.
- The moderator ban refusal asserted only status 400. An invalid ban
payload is also 400 with setBanned untouched, and the fixture used a
reasonCode outside BAN_REASONS — so the schema rejected it before the
type check could run. Uses a valid payload now, asserts the refusal
message, and adds the generation-row positive control that proves the
refusal is not simply rejecting every ban.
- The SELECT-list assertion matched the whole statement, so it was
satisfied by the `"ur"."type" = $1` in the WHERE that every version
emits. Sliced to the select list.
Also fixes a comment naming a function that does not exist.
* fix(user-restriction): refuse an unrulable type inside the verdict path itself
The type refusal added with the queue lived at ONE of the ruling surfaces. Five
callers reach `resolveUserRestriction` — the tRPC router, `/api/mod/restriction/
resolve` (which is what both moderator-app ruling surfaces post through: the
audit queue and the retool User Lookup panel), and `overturnPendingReviewMute` —
and only the audit queue checked. Reaching the verdict path with a non-generation
row would send a "your generation access has been restored" notice and an email,
and call `resetProhibitedRequestCount`, wiping the account's real prompt-violation
counter over an unrelated case.
Moved down rather than replicated at a third route: a predicate open-coded at N
sites is wrong at N-1 of them. `RULINGS_WIRED_FOR` and `unwiredRulingReason` now
live beside the type vocabulary, and the refusal happens in the service, before
any write and before the already-resolved check.
Also validates `type` at runtime in `applyPendingReviewMute`. That seam exists to
accept a caller-supplied type across an HTTP boundary and a JSON body, where the
compiler's word is worth nothing; an out-of-vocabulary value used to mute the
account, file a row no queue can select, and send no notification.
Latent today — one writer, both callers pass no type, so no non-generation row
can exist. Both are closed before a detector ships.
Tests, red at the PR head and green here:
- refuses Overturn and Uphold on a bot-account row, with no write, no
notification, no subscription change and no counter reset
- refuses before it argues about the row's status
- rejects three out-of-vocabulary types with nothing muted and nothing filed
- the seam test pins the moderator app's copy of the wired-for list AND the
refusal wording to this one, in both directions
The fake's `userRestriction.findUnique` now honours `select` for `type` alone, so
a service that refuses non-generation rows but forgets to select the column reads
`undefined` and fails the positive controls instead of passing vacuously.
* fix(moderator): stop the lookup panel hiding an open case, and stop offering rulings that cannot land
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.
* fix(user-restriction): make the seam guard read the moderator vocabulary by executing it
Round-2 audit F1. The guard that pins the two apps' restriction-type lists to each
other read the moderator app's module as TEXT, and it passed green over a real
divergence. Its regex captured to the FIRST closing bracket after the '=', so a
comment naming an index truncated the capture, and it extracted single-quoted
strings only, so a differently-quoted entry vanished. Measured here: with the
moderator list written as
export const RULINGS_WIRED_FOR: readonly RestrictionType[] = [
'generation', // matches RESTRICTION_TYPES[0]
'bot-account',
];
the seam file reported 8 passed / 0 failed while the two lists genuinely
disagreed. The length > 0 positive control could not see it either, because the
first entry survives the truncation. Same green for a mixed-quote entry and for a
list assembled with a spread.
That is not cosmetic. If the moderator app believes a type is rulable, its audit
queue bans the account and posts the ruling afterwards -- a ruling the main app
refuses -- leaving a banned account with a Pending row nobody can close.
Not fixed by a wider regex: a guard that pins source text by PATTERN is walkable
by rewriting the text, and the rewrites that walk it are ordinary (a Prettier
reflow, a comment, a quote style). The reader now IMPORTS AND EXECUTES the module
and compares values, so formatting cannot be the difference between agreeing and
disagreeing. This is available because the moderator vocabulary module has no
imports of its own; a comment there says so and says to keep it that way.
- moderator-restriction-vocabulary.harness.ts: the reader, plus runtime shape
validation that throws (naming the export) rather than returning an empty list.
- moderator-restriction-vocabulary.test.ts: 20 tests. Five fixtures, each a REAL
divergence written in a different shape -- multi-line, a comment containing a
closing bracket, double-quoted entries, a trailing comma, and values assembled
at runtime -- plus seven refusal cases for the shape validation.
- The refusal SENTENCE is now called rather than parsed out of a template
literal, so a message built from constants is compared on what it produces.
- apps/moderator restriction-types.test.ts: pin RULINGS_WIRED_FOR BY VALUE. The
old unwired.length > 0 caught a widened list only by accident -- bot-account
being the only unwired type. Measured: with a third filed type present, the
length check passes over a wrongly-wired bot-account and the value pin is the
only thing that fails.
- .prettierignore: the fixtures' formatting IS the fixture; Prettier would
normalise four distinct cases into one.
Measured, old reader vs new, both run against the same mutated moderator module:
comment containing ']' old 8/8 GREEN new RED (2 failures)
mixed quote style old 8/8 GREEN new RED (2 failures)
spread / computed list old 8/8 GREEN new RED (2 failures)
all-double-quoted old RED (empty-list control) new RED
multi-line, no comment old RED new RED
single-line trailing , old RED new RED
And with the reader reverted to the old text parser, 6 of the 20 new tests fail
(the comment, double-quote and computed fixtures, on both their list and message
cases); the multi-line and trailing-comma fixtures pass under both and are
labelled as declared coverage rather than regression coverage.
* fix(user-restriction): let the ruling refusal survive as a 400, and make two claims true
Round-2 audit F3, F4 and F5.
F4 (behaviour). resolveUserRestriction threw a plain Error, so handleEndpointError
fell to its non-TRPCError branch and the refusal reached the wire as
500 "An unexpected error occurred" -- the moderator's panel rendered
"Restriction ruling: An unexpected error occurred." and the reason was destroyed.
Reachable today from the retool User Lookup panel, which has no local guard.
Now throwBadRequestError, which keeps the status and the message.
Scope decision on that one: the two neighbouring guards in the same function had
the identical defect, and I fixed them in the same change rather than leaving one
of three converted. "Restriction record not found" is now a 404 and "Restriction
has already been resolved" a 400; both are facts about the request, neither is a
server fault, and leaving two of three as opaque 500s would have recreated the
same predicate spelled two ways one line apart. All three are covered.
Covered by three tests that drive the REAL handleEndpointError over the REAL
thrown value, not by asserting the message alone -- a message assertion stays
green through exactly the 500 this fixes. Watched red at ff97751d20:
"expected 500 to be 400", "expected 500 to be 404", "expected 500 to be 400".
F5 (latent). The User Lookup panel's ORDER BY (ur.status = 'Pending') DESC is
correct only while status is NOT NULL: Postgres defaults DESC to NULLS FIRST, so
a NULL would outrank a genuinely Pending row and hide the open case -- the exact
failure the preference exists to prevent, arriving through the column's
nullability. DESC NULLS LAST makes it independent of that. The column is a
NOT NULL enum today, so this is an unstated precondition made explicit, not a
live defect. Two tests red at ff97751d20 on the compiled SQL text.
F3 (comment truth). The runtime type guard's comment claimed the values reaching
it "cross an HTTP boundary and a JSON body". Nothing does: neither production
caller passes a type, and mute-user-pending-review.ts's zod schema has no type
key, so no request body can supply one. The guard stays -- what it is actually
for is the shape of the NEXT caller (this seam exists so a detector can file into
the queue, and the obvious wiring is a route forwarding a JSON field) and the
callers TypeScript cannot vouch for today (an `as` cast, a value read back off
the free-text column, a JS caller). Corrected in the service and in the test
file's docblock, which carried the same false sentence.
* test(user-restriction): close the vocabulary guard's environment blind spot
Round-4 delta audit, F-1/F-3/F-4.
F-1. The execute-based reader resolves the moderator app's vocabulary IN THE
MAIN APP'S TEST PROCESS, so an environment-conditional list is read under
Vitest and never under the moderator app's production build. Reproduced: with
export const RULINGS_WIRED_FOR: readonly RestrictionType[] = import.meta.env.DEV
? ['generation']
: ['generation', 'bot-account'];
the seam + vocabulary suites report 28 passed / 0 failed while the shipped
build carries both types — the ban-then-strand hazard, reached with every
pinning guard green. The base commit's TEXT parser goes RED on that same
module, so the reader that replaced it was not strictly stronger; it traded a
formatting blind spot for a runtime-environment one.
Keeps a TEXT assertion alongside the execute check rather than replacing it:
the module's source may contain no import.meta and no process.env. The two
mechanisms are complementary. With it, the mutant above fails the seam suite at
its beforeAll and the vocabulary suite's real-module case, both naming the
constant.
Comments are stripped before the scan. Without that the guard is matched by its
own documentation — the module has to be able to name the shapes it refuses,
and a raw-text scan fires on the sentence forbidding the thing rather than on
the thing. Covered by a control asserting a commented mention does not trip it.
The moderator module's precondition comment said only 'keep this module
import-free'. import.meta.env and process.env need no import statement, so that
sentence never covered this; it now states both constraints and says they are
separate.
F-3. All five vocabulary fixtures wrote 'return RULINGS_WIRED_FOR.includes(type)'
while the real module writes '(RULINGS_WIRED_FOR as readonly string[])'. The old
parser's message regex requires 'return (RULINGS_WIRED_FOR', so it could not read
the message out of ANY fixture and M8 was measuring the old reader against a
shape the module does not have. Fixtures now carry the cast. Re-measured, M8 is
6 of 21 — the three fixture pairs the body names (comment-with-bracket,
double-quoted, computed), on both their list and message cases. The audit's 8
was the drift; the multi-line-array and trailing-comma fixtures are read
correctly by the old parser again, messages included.
computed.ts's comment now distinguishes 'assembled from constants declared in
this file' — the same value everywhere, which is what the execute reader can
certify — from 'assembled from the environment', which is refused. It was the
fixture that made the hazardous shape look sanctioned.
F-4. The harness docblock now names the cross-app build coupling: resolving the
moderator path makes Vite load apps/moderator/tsconfig.json, which extends the
gitignored generated .svelte-kit/tsconfig.json, so without svelte-kit sync both
suites fail with a TSConfckParseError naming a tsconfig rather than the seam.
CI is unaffected; a fresh clone or worktree is not.
* fix(moderator): read the refusal out of the envelope the endpoint actually sends
Round-4 delta audit, F-2.
handleEndpointError's 4xx pass-through emits { message } and no error key,
while every other refusal from defineModeratorEndpoint emits
{ error, message, code }. The moderator app's readError read body.error and
nothing else, so all three refusals added last round came back null and the
operator saw 'Restriction ruling returned 400.' — the reason destroyed again,
one layer further out than the opaque 500 that change removed.
Fixed at the CONSUMER, not the emitter, and the choice is measured rather than
assumed. handleEndpointError is the shared chokepoint for 36 REST route files;
its 4xx and 503 pass-through bodies are pinned toStrictEqual({ message }) by
endpoint-helpers-error-envelope.test.ts, the 503 case as an explicit documented
carve-out; and restErrorBody needs a RestErrorCode that is not derivable at that
point without a new status-to-code map, which would then have to be reconciled
with the closed key ledger rest-error-envelope-ledger.test.ts enforces. Widening
the reader costs one expression and makes every endpoint's 4xx legible to this
app; widening the emitter changes the wire format of 36 routes and needs its own
PR.
The rule moves to apps/moderator/src/lib/server/rest-error-reason.ts, kept
import-free so the main app's suite can load it by filesystem path — the same
mechanism as the vocabulary harness. That is what lets the new test drive the
REAL emitter into the REAL reader in one process, over all three refusals.
Two suites each mocking the other side is precisely the arrangement that cannot
see a disagreement about a field name, which is why the previous round's tests
were green: they drove the real helper but asserted body.message, which the
consumer never looked at.
Red before the fix with 'expected null not to be null'; the reader's own
null-capability is pinned as a positive control so the three not-null
assertions cannot hold against a function that always answers.
* docs(test): scope the environment guard's docblock to what it actually refuses
Round 4 of the audit ladder found the guard's own documentation claimed more
than the regex delivers. A guard description that reads as coverage while
providing less is worse than none, because it stops the next reader looking.
Two sentences, no behaviour change:
- The scope note now says this refuses two spellings and NOT the class, and
names the three measured escapes (an aliased global, a computed member
access, and a regex literal containing a double slash on the same line as
the read, which the comment stripper truncates). It also records what IS
covered, so the note does not read as an indictment: the spellings a
maintainer would plausibly reach for are caught, and the $app/environment
and $env imports break loudly as a missing module.
- assertEnvironmentIndependent now documents that string literals are
deliberately NOT stripped, so the scanned module may not mention these
tokens in a string either. That fails safe -- red with the guard's own
message, never silently green -- but it is a real constraint, and a
comment is the supported way to write one.
Verified: the three affected suites are 99 passed / 3 files, unchanged.
1205 lines
45 KiB
TypeScript
1205 lines
45 KiB
TypeScript
import path from 'node:path';
|
|
import type { NextApiResponse } from 'next';
|
|
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type * as NotificationService from '~/server/services/notification.service';
|
|
import type * as SessionInvalidation from '~/server/auth/session-invalidation';
|
|
import type * as ModeratorService from '~/server/services/moderator.service';
|
|
import type * as PromClient from '~/server/prom/client';
|
|
import type * as EmailTemplates from '~/server/email/templates';
|
|
import type * as PromptAuditing from '~/server/services/orchestrator/promptAuditing';
|
|
|
|
type UserRow = {
|
|
id: number;
|
|
isModerator: boolean;
|
|
muted: boolean;
|
|
mutedAt: Date | null;
|
|
bannedAt: Date | null;
|
|
deletedAt: Date | null;
|
|
email: string | null;
|
|
username: string;
|
|
};
|
|
type RestrictionRow = {
|
|
id: number;
|
|
userId: number;
|
|
type: string;
|
|
status: string;
|
|
triggers: unknown;
|
|
createdAt: Date;
|
|
resolvedBy?: number;
|
|
resolvedMessage?: string;
|
|
};
|
|
|
|
const {
|
|
store,
|
|
dbWrite,
|
|
cancelSubscription,
|
|
reinstateSubscription,
|
|
cancelSubscriptionPlan,
|
|
resetProhibitedRequestCount,
|
|
lastQuery,
|
|
trackModActivity,
|
|
trackUserActivity,
|
|
userUpdateCounterInc,
|
|
refreshSession,
|
|
createNotification,
|
|
} = vi.hoisted(() => {
|
|
const store = {
|
|
users: new Map<number, UserRow>(),
|
|
restrictions: [] as RestrictionRow[],
|
|
jobDate: new Date(0),
|
|
};
|
|
const lastQuery = { sql: '' };
|
|
|
|
const findRestriction = (predicate: (r: RestrictionRow) => boolean) =>
|
|
[...store.restrictions].sort((a, b) => +b.createdAt - +a.createdAt).find(predicate) ?? null;
|
|
|
|
const dbWrite = {
|
|
user: {
|
|
findUnique: vi.fn(async ({ where }: { where: { id: number } }) => {
|
|
const user = store.users.get(where.id);
|
|
return user ? { ...user } : null;
|
|
}),
|
|
findFirst: vi.fn(async () => null),
|
|
update: vi.fn(
|
|
async ({ where, data }: { where: { id: number }; data: Record<string, unknown> }) => {
|
|
const user = store.users.get(where.id);
|
|
if (!user) throw new Error(`no user ${where.id}`);
|
|
if ('muted' in data) user.muted = data.muted as boolean;
|
|
if ('mutedAt' in data) user.mutedAt = (data.mutedAt as Date | null) ?? null;
|
|
return { ...user };
|
|
}
|
|
),
|
|
},
|
|
userRestriction: {
|
|
create: vi.fn(
|
|
async ({ data }: { data: Omit<RestrictionRow, 'id' | 'status' | 'createdAt'> }) => {
|
|
const row: RestrictionRow = {
|
|
id: store.restrictions.length + 1,
|
|
status: 'Pending',
|
|
createdAt: new Date(),
|
|
...data,
|
|
};
|
|
store.restrictions.push(row);
|
|
return { id: row.id };
|
|
}
|
|
),
|
|
findFirst: vi.fn(
|
|
async ({ where }: { where: { userId: number; type: string; status: string } }) => {
|
|
const row = findRestriction(
|
|
(r) => r.userId === where.userId && r.type === where.type && r.status === where.status
|
|
);
|
|
return row ? { id: row.id } : null;
|
|
}
|
|
),
|
|
// 🔴 Honours `select` for `type` alone, unlike every other read in this fake. The ruling-scope
|
|
// suite turns on the service having asked for it: a version that refuses non-generation rows but
|
|
// forgets `type: true` in its `select` reads `undefined` from the real Prisma client and either
|
|
// refuses everything or nothing. A fake that answered with the column regardless would hide that
|
|
// entirely — the test would pass against code that cannot work.
|
|
findUnique: vi.fn(
|
|
async ({ where, select }: { where: { id: number }; select?: Record<string, unknown> }) => {
|
|
const row = store.restrictions.find((r) => r.id === where.id);
|
|
if (!row) return null;
|
|
const user = store.users.get(row.userId);
|
|
return {
|
|
id: row.id,
|
|
userId: row.userId,
|
|
status: row.status,
|
|
...(select?.type ? { type: row.type } : {}),
|
|
user: user ? { email: user.email, username: user.username } : null,
|
|
};
|
|
}
|
|
),
|
|
update: vi.fn(
|
|
async ({ where, data }: { where: { id: number }; data: Record<string, unknown> }) => {
|
|
const row = store.restrictions.find((r) => r.id === where.id);
|
|
if (!row) throw new Error(`no restriction ${where.id}`);
|
|
Object.assign(row, data);
|
|
return { ...row };
|
|
}
|
|
),
|
|
},
|
|
keyValue: {
|
|
findUnique: vi.fn(async () => ({ value: store.jobDate.getTime() })),
|
|
upsert: vi.fn(async () => undefined),
|
|
},
|
|
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
|
|
// confirm-mutes' only query. Mirrors `WHERE "muted" AND "mutedAt" > $lastRan`;
|
|
// the equality assertion below is what keeps this in step with the real SQL.
|
|
$queryRaw: vi.fn(async (strings: TemplateStringsArray, lastRan: Date) => {
|
|
lastQuery.sql = strings.join('?');
|
|
return [...store.users.values()]
|
|
.filter((u) => u.muted && u.mutedAt && u.mutedAt > lastRan)
|
|
.map((u) => ({ id: u.id }));
|
|
}),
|
|
};
|
|
|
|
return {
|
|
store,
|
|
dbWrite,
|
|
cancelSubscription: vi.fn(async () => undefined),
|
|
reinstateSubscription: vi.fn(async () => undefined),
|
|
cancelSubscriptionPlan: vi.fn(async () => undefined),
|
|
resetProhibitedRequestCount: vi.fn(async () => undefined),
|
|
lastQuery,
|
|
trackModActivity: vi.fn(async () => undefined),
|
|
trackUserActivity: vi.fn(async () => undefined),
|
|
userUpdateCounterInc: vi.fn(),
|
|
refreshSession: vi.fn(async () => undefined),
|
|
createNotification: vi.fn(async () => undefined),
|
|
};
|
|
});
|
|
|
|
vi.mock('~/server/db/client', () => ({ dbRead: dbWrite, dbWrite, dbKV: dbWrite }));
|
|
vi.mock('~/server/services/stripe.service', () => ({ cancelSubscription, reinstateSubscription }));
|
|
vi.mock('~/server/services/paddle.service', () => ({ cancelSubscriptionPlan }));
|
|
vi.mock('~/server/auth/session-invalidation', async (importOriginal) => ({
|
|
...(await importOriginal<typeof SessionInvalidation>()),
|
|
refreshSession,
|
|
invalidateSession: vi.fn(async () => undefined),
|
|
}));
|
|
vi.mock('~/server/services/notification.service', async (importOriginal) => ({
|
|
...(await importOriginal<typeof NotificationService>()),
|
|
createNotification,
|
|
}));
|
|
vi.mock('~/server/services/moderator.service', async (importOriginal) => ({
|
|
...(await importOriginal<typeof ModeratorService>()),
|
|
trackModActivity,
|
|
}));
|
|
vi.mock('~/server/prom/client', async (importOriginal) => ({
|
|
...(await importOriginal<typeof PromClient>()),
|
|
userUpdateCounter: { inc: userUpdateCounterInc },
|
|
}));
|
|
vi.mock('~/server/email/templates', async (importOriginal) => ({
|
|
...(await importOriginal<typeof EmailTemplates>()),
|
|
moderationActionEmail: { send: vi.fn(async () => undefined) },
|
|
}));
|
|
vi.mock('~/server/services/orchestrator/promptAuditing', async (importOriginal) => ({
|
|
...(await importOriginal<typeof PromptAuditing>()),
|
|
resetProhibitedRequestCount,
|
|
}));
|
|
vi.mock('~/server/clickhouse/client', () => ({
|
|
Tracker: class {
|
|
userActivity = trackUserActivity;
|
|
},
|
|
}));
|
|
|
|
import muteHandler from '~/pages/api/mod/mute-user-pending-review';
|
|
import overturnHandler from '~/pages/api/mod/overturn-user-mute';
|
|
import { confirmMutes } from '~/server/jobs/confirm-mutes';
|
|
import { constants } from '~/server/common/constants';
|
|
import { env } from '~/env/server';
|
|
import { setUserMuted } from '~/server/services/user.service';
|
|
import {
|
|
applyPendingReviewMute,
|
|
buildManualMuteTriggers,
|
|
PENDING_REVIEW_MUTE_NOTIFICATION,
|
|
RULINGS_WIRED_FOR,
|
|
USER_RESTRICTION_TYPES,
|
|
unwiredRulingReason,
|
|
type UserRestrictionType,
|
|
} from '~/server/services/user-restriction.service';
|
|
import {
|
|
overturnPendingReviewMute,
|
|
resolveUserRestriction,
|
|
} from '~/server/services/user-restriction-resolve.service';
|
|
import { handleEndpointError } from '~/server/utils/endpoint-helpers';
|
|
import { UserRestrictionStatus } from '~/shared/utils/prisma/enums';
|
|
|
|
const USER_ID = 101;
|
|
const MOD_ID = 102;
|
|
const BANNED_ID = 103;
|
|
const DELETED_ID = 104;
|
|
const REASON = 'csam-block strike threshold';
|
|
|
|
function makeUser(id: number, over: Partial<UserRow> = {}): UserRow {
|
|
return {
|
|
id,
|
|
isModerator: false,
|
|
muted: false,
|
|
mutedAt: null,
|
|
bannedAt: null,
|
|
deletedAt: null,
|
|
email: `u${id}@example.com`,
|
|
username: `user${id}`,
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function seed() {
|
|
store.users.clear();
|
|
store.restrictions.length = 0;
|
|
store.jobDate = new Date(Date.now() - 60 * 60 * 1000);
|
|
store.users.set(USER_ID, makeUser(USER_ID));
|
|
store.users.set(MOD_ID, makeUser(MOD_ID, { isModerator: true }));
|
|
store.users.set(BANNED_ID, makeUser(BANNED_ID, { bannedAt: new Date() }));
|
|
store.users.set(DELETED_ID, makeUser(DELETED_ID, { deletedAt: new Date() }));
|
|
store.users.set(constants.system.officialUserId, makeUser(constants.system.officialUserId));
|
|
vi.clearAllMocks();
|
|
}
|
|
|
|
async function runConfirmMutes() {
|
|
const { result } = confirmMutes.run({});
|
|
await result;
|
|
}
|
|
|
|
function createRes() {
|
|
const state: { status: number; body: unknown } = { status: 0, body: undefined };
|
|
const res = {
|
|
status(code: number) {
|
|
state.status = code;
|
|
return res;
|
|
},
|
|
json(body: unknown) {
|
|
state.body = body;
|
|
return res;
|
|
},
|
|
setHeader: () => undefined,
|
|
on: () => undefined,
|
|
state,
|
|
};
|
|
return res;
|
|
}
|
|
|
|
type Handler = (req: unknown, res: unknown) => Promise<unknown>;
|
|
|
|
// `token: null` means OMIT it. A `token?: string` default would silently send the
|
|
// valid token for an explicit `undefined`, making the no-token test vacuous.
|
|
async function call(
|
|
handler: unknown,
|
|
opts: { method?: string; token?: string | null; body?: unknown } = {}
|
|
) {
|
|
const { method = 'POST', body = {} } = opts;
|
|
const token = 'token' in opts ? opts.token : env.WEBHOOK_TOKEN;
|
|
const res = createRes();
|
|
const query: Record<string, unknown> = {};
|
|
if (token !== null) query.token = token;
|
|
await (handler as Handler)(
|
|
{ method, query, body, headers: {}, url: '/api/mod/test', socket: {} },
|
|
res
|
|
);
|
|
return res.state;
|
|
}
|
|
|
|
const triggers = buildManualMuteTriggers({ reason: REASON, source: 'test' });
|
|
|
|
describe('pending-review mute', () => {
|
|
beforeEach(seed);
|
|
|
|
it('mutes without writing mutedAt', async () => {
|
|
await applyPendingReviewMute({ userId: USER_ID, triggers, updateSource: 'test' });
|
|
|
|
const dataArgs = dbWrite.user.update.mock.calls.map(([arg]) => arg.data);
|
|
expect(dataArgs).toEqual([{ muted: true }]);
|
|
expect(store.users.get(USER_ID)).toMatchObject({ muted: true, mutedAt: null });
|
|
});
|
|
|
|
it('files a Pending generation restriction so the review queue sees it', async () => {
|
|
await applyPendingReviewMute({ userId: USER_ID, triggers, updateSource: 'test' });
|
|
|
|
expect(store.restrictions).toHaveLength(1);
|
|
expect(store.restrictions[0]).toMatchObject({
|
|
userId: USER_ID,
|
|
type: 'generation',
|
|
status: 'Pending',
|
|
});
|
|
expect(store.restrictions[0].triggers).toEqual(triggers);
|
|
});
|
|
|
|
it('writes the mute and the restriction in one transaction', async () => {
|
|
await applyPendingReviewMute({ userId: USER_ID, triggers, updateSource: 'test' });
|
|
|
|
expect(dbWrite.$transaction).toHaveBeenCalledOnce();
|
|
expect(dbWrite.$transaction.mock.calls[0][0]).toHaveLength(2);
|
|
});
|
|
|
|
it('is not picked up by confirm-mutes, so no subscription is cancelled', async () => {
|
|
await applyPendingReviewMute({ userId: USER_ID, triggers, updateSource: 'test' });
|
|
|
|
await runConfirmMutes();
|
|
|
|
expect(cancelSubscription).not.toHaveBeenCalled();
|
|
expect(cancelSubscriptionPlan).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('is idempotent — a retry reuses the open restriction instead of filing another', async () => {
|
|
const first = await applyPendingReviewMute({ userId: USER_ID, triggers, updateSource: 'test' });
|
|
const second = await applyPendingReviewMute({
|
|
userId: USER_ID,
|
|
triggers,
|
|
updateSource: 'test',
|
|
});
|
|
|
|
expect(store.restrictions).toHaveLength(1);
|
|
expect(second).toEqual({
|
|
muted: true,
|
|
userRestrictionId: (first as { userRestrictionId: number }).userRestrictionId,
|
|
deduped: true,
|
|
});
|
|
});
|
|
|
|
it('repairs a Pending restriction left on an unmuted user', async () => {
|
|
store.restrictions.push({
|
|
id: 99,
|
|
userId: USER_ID,
|
|
type: 'generation',
|
|
status: 'Pending',
|
|
triggers: [],
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
const result = await applyPendingReviewMute({
|
|
userId: USER_ID,
|
|
triggers,
|
|
updateSource: 'test',
|
|
});
|
|
|
|
expect(result).toEqual({ muted: true, userRestrictionId: 99, deduped: true });
|
|
expect(store.users.get(USER_ID)).toMatchObject({ muted: true, mutedAt: null });
|
|
expect(store.restrictions).toHaveLength(1);
|
|
});
|
|
|
|
it.each([
|
|
['moderator', MOD_ID, 'moderator'],
|
|
['banned user', BANNED_ID, 'banned'],
|
|
['deleted user', DELETED_ID, 'deleted'],
|
|
['the official brand account', constants.system.officialUserId, 'protected'],
|
|
['the system actor', constants.system.user.id, 'protected'],
|
|
])('refuses to mute a %s', async (_label, userId, skipped) => {
|
|
const result = await applyPendingReviewMute({ userId, triggers, updateSource: 'test' });
|
|
|
|
expect(result).toEqual({ muted: false, skipped });
|
|
expect(store.restrictions).toHaveLength(0);
|
|
expect(store.users.get(userId)?.muted ?? false).toBe(false);
|
|
});
|
|
|
|
it('still reports success when the session refresh fails', async () => {
|
|
refreshSession.mockRejectedValueOnce(new Error('redis down'));
|
|
|
|
const result = await applyPendingReviewMute({
|
|
userId: USER_ID,
|
|
triggers,
|
|
updateSource: 'test',
|
|
});
|
|
|
|
expect(result).toMatchObject({ muted: true });
|
|
expect(store.users.get(USER_ID)).toMatchObject({ muted: true });
|
|
});
|
|
|
|
it('notifies the user that generation access is restricted', async () => {
|
|
const { userRestrictionId } = (await applyPendingReviewMute({
|
|
userId: USER_ID,
|
|
triggers,
|
|
updateSource: 'test',
|
|
})) as { userRestrictionId: number };
|
|
|
|
// Pinned whole rather than by `objectContaining`: the key is the notification service's dedupe
|
|
// handle, so a change to its shape re-notifies every already-notified user.
|
|
expect(createNotification).toHaveBeenCalledExactlyOnceWith({
|
|
type: 'generation-muted',
|
|
key: `generation-muted:${USER_ID}:${userRestrictionId}`,
|
|
category: 'System',
|
|
userId: USER_ID,
|
|
details: {},
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The seam a bot-account detector files through. Nothing raises a non-generation restriction yet — this
|
|
* is the parameter that lets one, and the properties below are what keep it from cannibalising the
|
|
* queue that already exists.
|
|
*/
|
|
describe('pending-review mute — restriction type', () => {
|
|
beforeEach(seed);
|
|
|
|
it('files a generation restriction when no type is given', async () => {
|
|
await applyPendingReviewMute({ userId: USER_ID, triggers, updateSource: 'test' });
|
|
|
|
expect(store.restrictions).toHaveLength(1);
|
|
expect(store.restrictions[0]).toMatchObject({ type: 'generation', status: 'Pending' });
|
|
});
|
|
|
|
it('files a restriction of the type it was given', async () => {
|
|
await applyPendingReviewMute({
|
|
userId: USER_ID,
|
|
triggers,
|
|
updateSource: 'test',
|
|
type: 'bot-account',
|
|
});
|
|
|
|
expect(store.restrictions).toHaveLength(1);
|
|
expect(store.restrictions[0]).toMatchObject({
|
|
userId: USER_ID,
|
|
type: 'bot-account',
|
|
status: 'Pending',
|
|
});
|
|
expect(store.restrictions[0].triggers).toEqual(triggers);
|
|
});
|
|
|
|
// 🔴 The pair below is the point of the whole change. Dedupe reads "this user already has an open
|
|
// case", and scoped to the user alone it means the FIRST queue to mute someone permanently silences
|
|
// every other queue for that account — a detector's findings would return `deduped: true` against a
|
|
// row about something else entirely, and file nothing a moderator could ever see.
|
|
it('does not let an open generation case swallow a bot-account mute', async () => {
|
|
const first = await applyPendingReviewMute({ userId: USER_ID, triggers, updateSource: 'test' });
|
|
const second = await applyPendingReviewMute({
|
|
userId: USER_ID,
|
|
triggers,
|
|
updateSource: 'test',
|
|
type: 'bot-account',
|
|
});
|
|
|
|
expect(second).toMatchObject({ muted: true, deduped: false });
|
|
expect((second as { userRestrictionId: number }).userRestrictionId).not.toBe(
|
|
(first as { userRestrictionId: number }).userRestrictionId
|
|
);
|
|
expect(store.restrictions.map((r) => r.type)).toEqual(['generation', 'bot-account']);
|
|
});
|
|
|
|
it('does not let an open bot-account case swallow a generation mute', async () => {
|
|
const first = await applyPendingReviewMute({
|
|
userId: USER_ID,
|
|
triggers,
|
|
updateSource: 'test',
|
|
type: 'bot-account',
|
|
});
|
|
const second = await applyPendingReviewMute({
|
|
userId: USER_ID,
|
|
triggers,
|
|
updateSource: 'test',
|
|
});
|
|
|
|
expect(second).toMatchObject({ muted: true, deduped: false });
|
|
expect((second as { userRestrictionId: number }).userRestrictionId).not.toBe(
|
|
(first as { userRestrictionId: number }).userRestrictionId
|
|
);
|
|
expect(store.restrictions.map((r) => r.type)).toEqual(['bot-account', 'generation']);
|
|
});
|
|
|
|
it('still dedupes within a type, so a retry files nothing new', async () => {
|
|
const first = await applyPendingReviewMute({
|
|
userId: USER_ID,
|
|
triggers,
|
|
updateSource: 'test',
|
|
type: 'bot-account',
|
|
});
|
|
const second = await applyPendingReviewMute({
|
|
userId: USER_ID,
|
|
triggers,
|
|
updateSource: 'test',
|
|
type: 'bot-account',
|
|
});
|
|
|
|
expect(store.restrictions).toHaveLength(1);
|
|
expect(second).toEqual({
|
|
muted: true,
|
|
userRestrictionId: (first as { userRestrictionId: number }).userRestrictionId,
|
|
deduped: true,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 🔴 `createNotification` validates `type` against NOTHING — `z.string()` at the schema, `text` at
|
|
* both tables, and the fan-out worker inserts it verbatim. An unregistered type is persisted and
|
|
* increments the user's unread badge, while the bell dropdown drops it at render, leaving a phantom
|
|
* count with no click target. And `generation-muted` reads "your generation access has been
|
|
* restricted", which is a lie about a bot-account mute. So a type with no notification of its own
|
|
* sends none until someone registers one.
|
|
*/
|
|
it('sends no notification for a type that has none mapped', async () => {
|
|
await applyPendingReviewMute({
|
|
userId: USER_ID,
|
|
triggers,
|
|
updateSource: 'test',
|
|
type: 'bot-account',
|
|
});
|
|
|
|
expect(PENDING_REVIEW_MUTE_NOTIFICATION['bot-account']).toBeNull();
|
|
expect(createNotification).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('mutes the account and refreshes the session for a non-generation type all the same', async () => {
|
|
const result = await applyPendingReviewMute({
|
|
userId: USER_ID,
|
|
triggers,
|
|
updateSource: 'test',
|
|
type: 'bot-account',
|
|
});
|
|
|
|
expect(result).toMatchObject({ muted: true });
|
|
expect(store.users.get(USER_ID)).toMatchObject({ muted: true });
|
|
expect(refreshSession).toHaveBeenCalledWith(USER_ID, { caller: 'moderation' });
|
|
});
|
|
|
|
it('writes the mute and a typed restriction in one transaction', async () => {
|
|
await applyPendingReviewMute({
|
|
userId: USER_ID,
|
|
triggers,
|
|
updateSource: 'test',
|
|
type: 'bot-account',
|
|
});
|
|
|
|
expect(dbWrite.$transaction).toHaveBeenCalledOnce();
|
|
expect(dbWrite.$transaction.mock.calls[0][0]).toHaveLength(2);
|
|
});
|
|
|
|
it.each(['generation', 'bot-account'] as const)(
|
|
'never writes mutedAt for a %s restriction',
|
|
async (type) => {
|
|
await applyPendingReviewMute({ userId: USER_ID, triggers, updateSource: 'test', type });
|
|
|
|
const dataArgs = dbWrite.user.update.mock.calls.map(([arg]) => arg.data);
|
|
expect(dataArgs).toEqual([{ muted: true }]);
|
|
expect(store.users.get(USER_ID)).toMatchObject({ muted: true, mutedAt: null });
|
|
}
|
|
);
|
|
|
|
it.each([
|
|
['moderator', MOD_ID, 'moderator'],
|
|
['banned user', BANNED_ID, 'banned'],
|
|
['deleted user', DELETED_ID, 'deleted'],
|
|
['the official brand account', constants.system.officialUserId, 'protected'],
|
|
['the system actor', constants.system.user.id, 'protected'],
|
|
])('refuses to file a bot-account restriction against a %s', async (_label, userId, skipped) => {
|
|
const result = await applyPendingReviewMute({
|
|
userId,
|
|
triggers,
|
|
updateSource: 'test',
|
|
type: 'bot-account',
|
|
});
|
|
|
|
expect(result).toEqual({ muted: false, skipped });
|
|
expect(store.restrictions).toHaveLength(0);
|
|
expect(store.users.get(userId)?.muted ?? false).toBe(false);
|
|
expect(createNotification).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('repairs an unmuted user holding an open case of the SAME type only', async () => {
|
|
store.restrictions.push({
|
|
id: 99,
|
|
userId: USER_ID,
|
|
type: 'bot-account',
|
|
status: 'Pending',
|
|
triggers: [],
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
const result = await applyPendingReviewMute({
|
|
userId: USER_ID,
|
|
triggers,
|
|
updateSource: 'test',
|
|
type: 'bot-account',
|
|
});
|
|
|
|
expect(result).toEqual({ muted: true, userRestrictionId: 99, deduped: true });
|
|
expect(store.users.get(USER_ID)).toMatchObject({ muted: true, mutedAt: null });
|
|
expect(store.restrictions).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 🔴 The runtime guard, and what it is actually for. No HTTP boundary supplies this parameter today:
|
|
* neither production caller passes a `type`, and `mute-user-pending-review.ts`'s zod schema has no
|
|
* `type` key, so no request body can reach it. The guard is there for the shape of the NEXT caller —
|
|
* this seam exists so a detector can file into the queue, and the obvious wiring is a route
|
|
* forwarding a JSON field — and for the callers TypeScript already cannot vouch for: an `as` cast, a
|
|
* value read back off the free-text `UserRestriction.type` column, a JS caller.
|
|
*/
|
|
describe('pending-review mute — type is validated at runtime', () => {
|
|
beforeEach(seed);
|
|
|
|
it.each([
|
|
['a near miss', 'bot-acount'],
|
|
['a plausible-looking new kind', 'spam-account'],
|
|
['an empty string', ''],
|
|
])('refuses %s and mutes nobody', async (_label, type) => {
|
|
await expect(
|
|
applyPendingReviewMute({
|
|
userId: USER_ID,
|
|
triggers,
|
|
updateSource: 'test',
|
|
type: type as UserRestrictionType,
|
|
})
|
|
).rejects.toThrow(`Unknown user restriction type "${type}"`);
|
|
|
|
// The harm the throw prevents, spelled out: an out-of-vocabulary value used to MUTE the account,
|
|
// file a row the queue's `z.enum(...).catch(...)` can never select, and — the notification map
|
|
// returning `undefined` for it — tell the user nothing. A silently muted user, no reviewable case.
|
|
expect(store.users.get(USER_ID)).toMatchObject({ muted: false, mutedAt: null });
|
|
expect(store.restrictions).toHaveLength(0);
|
|
expect(dbWrite.$transaction).not.toHaveBeenCalled();
|
|
expect(createNotification).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// The positive control. Without it the guard above could be rejecting every type, and the suite
|
|
// would still be green — `it.each` over the real vocabulary is what makes the refusal specific.
|
|
it.each(USER_RESTRICTION_TYPES)('accepts %s', async (type) => {
|
|
const result = await applyPendingReviewMute({
|
|
userId: USER_ID,
|
|
triggers,
|
|
updateSource: 'test',
|
|
type,
|
|
});
|
|
|
|
expect(result).toMatchObject({ muted: true });
|
|
expect(store.restrictions).toHaveLength(1);
|
|
expect(store.restrictions[0].type).toBe(type);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 🔴 Finding 1 of the adversarial audit on #4609, closed one level BELOW the routes.
|
|
*
|
|
* `resolveUserRestriction` is the single write path for a verdict, and everything it does is
|
|
* generation-shaped: the `generation-restriction-upheld` / `-overturned` notification types, a
|
|
* `moderator:generationRestriction*` update source, a generation-worded email, and — on an overturn —
|
|
* `resetProhibitedRequestCount`, which wipes the account's real PROMPT-violation counter.
|
|
*
|
|
* Five callers reach it: the tRPC router, `/api/mod/restriction/resolve` (which is what BOTH moderator
|
|
* ruling surfaces post through — the audit queue AND the retool User Lookup panel), and
|
|
* `overturnPendingReviewMute`. Only the audit queue checked the type, so three of those five would have
|
|
* run the whole generation-shaped sequence against a bot-account row. The check lives here now, which
|
|
* is why these tests address the SERVICE rather than any one route.
|
|
*/
|
|
describe('resolveUserRestriction — ruling scope', () => {
|
|
beforeEach(seed);
|
|
|
|
const fileRestriction = (type: string, status = 'Pending') => {
|
|
store.restrictions.push({
|
|
id: 1,
|
|
userId: USER_ID,
|
|
type,
|
|
status,
|
|
triggers: [],
|
|
createdAt: new Date(),
|
|
});
|
|
return 1;
|
|
};
|
|
|
|
it.each([UserRestrictionStatus.Overturned, UserRestrictionStatus.Upheld] as const)(
|
|
'refuses to %s a restriction whose type has no verdict path',
|
|
async (status) => {
|
|
const id = fileRestriction('bot-account');
|
|
|
|
await expect(
|
|
resolveUserRestriction({ userRestrictionId: id, status, moderatorId: MOD_ID })
|
|
).rejects.toThrow('Rulings are not yet available for "bot-account" restrictions');
|
|
|
|
// Nothing at all happened — checked rather than assumed, because the refusal is only worth
|
|
// anything if it lands BEFORE the first write.
|
|
expect(dbWrite.userRestriction.update).not.toHaveBeenCalled();
|
|
expect(store.restrictions[0].status).toBe('Pending');
|
|
expect(dbWrite.user.update).not.toHaveBeenCalled();
|
|
expect(createNotification).not.toHaveBeenCalled();
|
|
expect(cancelSubscription).not.toHaveBeenCalled();
|
|
expect(reinstateSubscription).not.toHaveBeenCalled();
|
|
// The one with a lasting cost: this counter is the account's real prompt-violation history, and
|
|
// an overturn on an unrelated case used to reset it to zero.
|
|
expect(resetProhibitedRequestCount).not.toHaveBeenCalled();
|
|
}
|
|
);
|
|
|
|
/**
|
|
* The positive control for the pair above, and it is doing more work than it looks: the fixture rows
|
|
* differ ONLY in `type`. Without it the refusal could be rejecting every ruling — which is exactly
|
|
* what happens if the service stops selecting `type` and reads `undefined`.
|
|
*/
|
|
it('still overturns a generation restriction, with every side effect intact', async () => {
|
|
const id = fileRestriction('generation');
|
|
|
|
const result = await resolveUserRestriction({
|
|
userRestrictionId: id,
|
|
status: UserRestrictionStatus.Overturned,
|
|
moderatorId: MOD_ID,
|
|
});
|
|
|
|
expect(result).toEqual({ userId: USER_ID });
|
|
expect(store.restrictions[0]).toMatchObject({ status: 'Overturned', resolvedBy: MOD_ID });
|
|
expect(reinstateSubscription).toHaveBeenCalledWith({ userId: USER_ID });
|
|
expect(resetProhibitedRequestCount).toHaveBeenCalledWith(USER_ID);
|
|
expect(createNotification).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: 'generation-restriction-overturned' })
|
|
);
|
|
});
|
|
|
|
it('still upholds a generation restriction, with every side effect intact', async () => {
|
|
const id = fileRestriction('generation');
|
|
|
|
await resolveUserRestriction({
|
|
userRestrictionId: id,
|
|
status: UserRestrictionStatus.Upheld,
|
|
moderatorId: MOD_ID,
|
|
});
|
|
|
|
expect(store.restrictions[0]).toMatchObject({ status: 'Upheld' });
|
|
expect(store.users.get(USER_ID)?.mutedAt).toBeInstanceOf(Date);
|
|
expect(cancelSubscription).toHaveBeenCalledWith({ userId: USER_ID, atPeriodEnd: true });
|
|
expect(createNotification).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: 'generation-restriction-upheld' })
|
|
);
|
|
});
|
|
|
|
// The refusal precedes the already-resolved check, so a row this path cannot rule on reports the
|
|
// reason it cannot rather than an argument about its status.
|
|
it('refuses an unwired type before it argues about the status', async () => {
|
|
const id = fileRestriction('bot-account', 'Upheld');
|
|
|
|
await expect(
|
|
resolveUserRestriction({
|
|
userRestrictionId: id,
|
|
status: UserRestrictionStatus.Overturned,
|
|
moderatorId: MOD_ID,
|
|
})
|
|
).rejects.toThrow('Rulings are not yet available for "bot-account" restrictions');
|
|
});
|
|
|
|
/**
|
|
* 🔴 The SEAM between the refusal and what a moderator actually reads.
|
|
*
|
|
* Both ruling surfaces post through `/api/mod/restriction/resolve`, whose `defineModeratorEndpoint`
|
|
* wrapper hands a throw to `handleEndpointError`. A plain `Error` falls to that helper's catch-all
|
|
* branch and reaches the wire as **500 "An unexpected error occurred"** — the retool panel then
|
|
* renders "Restriction ruling: An unexpected error occurred." and the reason is destroyed. So the
|
|
* service throwing the right words is only half the behaviour; these drive the REAL helper over the
|
|
* REAL thrown value, because a test that asserted only the message would stay green through exactly
|
|
* that 500.
|
|
*/
|
|
describe('the refusal survives the REST envelope', () => {
|
|
/**
|
|
* The moderator app's REAL body reader, loaded across the app boundary by filesystem path. It is
|
|
* import-free for exactly this reason — see `apps/moderator/src/lib/server/rest-error-reason.ts`
|
|
* and the note in `moderator-restriction-vocabulary.harness.ts` about the same coupling
|
|
* (resolving a path into `apps/moderator` needs `svelte-kit sync` to have run there).
|
|
*/
|
|
let restErrorReason: (body: unknown, status: number) => string | null;
|
|
|
|
beforeAll(async () => {
|
|
const file = path.resolve(
|
|
__dirname,
|
|
'../../..',
|
|
'apps/moderator/src/lib/server/rest-error-reason.ts'
|
|
);
|
|
({ restErrorReason } = await import(/* @vite-ignore */ file));
|
|
// The import resolving is not the same as it being the thing we meant to load.
|
|
expect(typeof restErrorReason).toBe('function');
|
|
});
|
|
|
|
const throughRest = async (fn: () => Promise<unknown>) => {
|
|
const res = createRes();
|
|
let threw = false;
|
|
try {
|
|
await fn();
|
|
} catch (e) {
|
|
threw = true;
|
|
handleEndpointError(res as unknown as NextApiResponse, e);
|
|
}
|
|
// Positive control: a call that did NOT throw would leave `state` at its zero value and every
|
|
// assertion below would be about the fake rather than about the error.
|
|
expect(threw).toBe(true);
|
|
return res.state as { status: number; body: Record<string, unknown> & { message?: string } };
|
|
};
|
|
|
|
it('reaches REST as a 400 naming the type, not an opaque 500', async () => {
|
|
const id = fileRestriction('bot-account');
|
|
|
|
const { status, body } = await throughRest(() =>
|
|
resolveUserRestriction({
|
|
userRestrictionId: id,
|
|
status: UserRestrictionStatus.Overturned,
|
|
moderatorId: MOD_ID,
|
|
})
|
|
);
|
|
|
|
expect(status).toBe(400);
|
|
expect(body.message).toContain(
|
|
'Rulings are not yet available for "bot-account" restrictions'
|
|
);
|
|
// The exact sentence the moderator used to get instead. Pinned by value: it is the observable
|
|
// that says the reason was destroyed rather than merely reworded.
|
|
expect(body.message).not.toContain('An unexpected error occurred');
|
|
});
|
|
|
|
it('reports a missing row as a 404 rather than a server fault', async () => {
|
|
const { status, body } = await throughRest(() =>
|
|
resolveUserRestriction({
|
|
userRestrictionId: 4242,
|
|
status: UserRestrictionStatus.Upheld,
|
|
moderatorId: MOD_ID,
|
|
})
|
|
);
|
|
|
|
expect(status).toBe(404);
|
|
expect(body.message).toBe('Restriction record not found');
|
|
});
|
|
|
|
it('reports an already-ruled row as a 400 rather than a server fault', async () => {
|
|
const id = fileRestriction('generation', 'Upheld');
|
|
|
|
const { status, body } = await throughRest(() =>
|
|
resolveUserRestriction({
|
|
userRestrictionId: id,
|
|
status: UserRestrictionStatus.Overturned,
|
|
moderatorId: MOD_ID,
|
|
})
|
|
);
|
|
|
|
expect(status).toBe(400);
|
|
expect(body.message).toBe('Restriction has already been resolved');
|
|
});
|
|
|
|
/**
|
|
* 🔴 The assertions above are about the WIRE. This one is about what the only in-repo consumer
|
|
* gets out of it, and the two are NOT the same claim — which is how the gap below survived a
|
|
* green suite for a whole round.
|
|
*
|
|
* `handleEndpointError`'s 4xx pass-through emits `{ message }` and no `error` key, while every
|
|
* other refusal from `defineModeratorEndpoint` emits `{ error, message, code }`. The moderator
|
|
* app's `readError` read `body.error` and nothing else, so all three refusals above came back
|
|
* `null` and the operator saw `"Restriction ruling returned 400."` — the reason destroyed again,
|
|
* one layer further out than the opaque 500 this endpoint change removed.
|
|
*
|
|
* So this drives the REAL emitter into the REAL reader in one process. Both halves mocked
|
|
* separately is exactly the arrangement that cannot see a disagreement about the field name:
|
|
* `restErrorReason` is loaded across the app boundary by filesystem path, the same mechanism (and
|
|
* the same import-free precondition) as the vocabulary harness.
|
|
*/
|
|
it('hands the moderator app a reason it can read, not just a status', async () => {
|
|
const cases: [string, () => Promise<unknown>, number, string][] = [
|
|
[
|
|
'an unwired type',
|
|
() =>
|
|
resolveUserRestriction({
|
|
userRestrictionId: fileRestriction('bot-account'),
|
|
status: UserRestrictionStatus.Overturned,
|
|
moderatorId: MOD_ID,
|
|
}),
|
|
400,
|
|
'Rulings are not yet available for "bot-account" restrictions',
|
|
],
|
|
[
|
|
'a missing row',
|
|
() =>
|
|
resolveUserRestriction({
|
|
userRestrictionId: 4242,
|
|
status: UserRestrictionStatus.Upheld,
|
|
moderatorId: MOD_ID,
|
|
}),
|
|
404,
|
|
'Restriction record not found',
|
|
],
|
|
[
|
|
'an already-ruled row',
|
|
() =>
|
|
resolveUserRestriction({
|
|
userRestrictionId: fileRestriction('generation', 'Upheld'),
|
|
status: UserRestrictionStatus.Overturned,
|
|
moderatorId: MOD_ID,
|
|
}),
|
|
400,
|
|
'Restriction has already been resolved',
|
|
],
|
|
];
|
|
|
|
for (const [label, call, expectedStatus, expectedReason] of cases) {
|
|
// `fileRestriction` hardcodes id 1, so stacked rows would all answer to the same lookup and
|
|
// every case after the first would rule on the previous case's row.
|
|
store.restrictions.length = 0;
|
|
|
|
const { status, body } = await throughRest(call);
|
|
expect(status, label).toBe(expectedStatus);
|
|
|
|
const reason = restErrorReason(body, status);
|
|
// The discriminating assertion: reading `error` alone returns null here, and null is what
|
|
// collapses the operator's message back to "<label> returned <status>."
|
|
expect(reason, label).not.toBeNull();
|
|
expect(reason, label).toContain(expectedReason);
|
|
}
|
|
|
|
// Positive control on the reader itself — it must be capable of returning null, or the three
|
|
// `not.toBeNull()` assertions above would hold against a function that always answers.
|
|
expect(restErrorReason({ nothingReadable: true }, 400)).toBeNull();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* An INVARIANT GUARD, not regression coverage — it passes against pre-change code too. Recorded
|
|
* because it is the reason the service-facing overturn was never the reachable half of this hazard,
|
|
* and a later "simplification" that drops the predicate would make it one.
|
|
*/
|
|
it('overturnPendingReviewMute cannot reach a non-generation row at all', async () => {
|
|
store.users.set(USER_ID, makeUser(USER_ID, { muted: true }));
|
|
fileRestriction('bot-account');
|
|
|
|
const result = await overturnPendingReviewMute({ userId: USER_ID, moderatorId: MOD_ID });
|
|
|
|
expect(result).toEqual({ unmuted: false, skipped: 'no-pending-restriction' });
|
|
expect(store.restrictions[0].status).toBe('Pending');
|
|
});
|
|
|
|
describe('the wired-for list itself', () => {
|
|
it('is a subset of the types that can be filed', () => {
|
|
// A verdict path for a type nothing can file is dead code; the reverse — a filed type with no
|
|
// verdict path — is the deliberate state this whole guard exists for.
|
|
for (const type of RULINGS_WIRED_FOR) expect(USER_RESTRICTION_TYPES).toContain(type);
|
|
});
|
|
|
|
it('names generation and refuses everything else', () => {
|
|
expect([...RULINGS_WIRED_FOR]).toEqual(['generation']);
|
|
expect(unwiredRulingReason('generation')).toBeNull();
|
|
for (const type of USER_RESTRICTION_TYPES.filter((t) => !RULINGS_WIRED_FOR.includes(t)))
|
|
expect(unwiredRulingReason(type)).toContain(`"${type}"`);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('POST /api/mod/mute-user-pending-review', () => {
|
|
beforeEach(seed);
|
|
|
|
it.each([
|
|
['no token', null],
|
|
['a wrong token', 'not-the-webhook-token'],
|
|
])('rejects a request with %s and mutes nobody', async (_label, token) => {
|
|
const { status } = await call(muteHandler, {
|
|
token,
|
|
body: { userId: USER_ID, reason: REASON },
|
|
});
|
|
|
|
expect(status).toBe(401);
|
|
expect(store.users.get(USER_ID)).toMatchObject({ muted: false });
|
|
expect(store.restrictions).toHaveLength(0);
|
|
});
|
|
|
|
it('rejects a GET even with a valid token, so the token cannot ride in a URL', async () => {
|
|
const { status } = await call(muteHandler, {
|
|
method: 'GET',
|
|
body: { userId: USER_ID, reason: REASON },
|
|
});
|
|
|
|
expect(status).toBe(405);
|
|
expect(store.users.get(USER_ID)).toMatchObject({ muted: false });
|
|
});
|
|
|
|
it('mutes pending review and leaves mutedAt unset', async () => {
|
|
const { status, body } = await call(muteHandler, { body: { userId: USER_ID, reason: REASON } });
|
|
|
|
expect(status).toBe(200);
|
|
expect(body).toMatchObject({ userId: USER_ID, muted: true, deduped: false });
|
|
expect(store.users.get(USER_ID)).toMatchObject({ muted: true, mutedAt: null });
|
|
});
|
|
|
|
it('records the reason on the restriction the moderator reviews', async () => {
|
|
await call(muteHandler, {
|
|
body: { userId: USER_ID, reason: REASON, prompts: ['bad prompt'] },
|
|
});
|
|
|
|
expect(store.restrictions).toHaveLength(1);
|
|
expect(store.restrictions[0].triggers).toEqual([
|
|
expect.objectContaining({
|
|
prompt: 'bad prompt',
|
|
matchedWord: REASON,
|
|
source: 'orchestrator',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('attributes the mute to the system actor', async () => {
|
|
await call(muteHandler, { body: { userId: USER_ID, reason: REASON } });
|
|
|
|
expect(trackModActivity).toHaveBeenCalledWith(-1, {
|
|
entityType: 'user',
|
|
entityId: USER_ID,
|
|
activity: 'mutePendingReview',
|
|
});
|
|
expect(trackUserActivity).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: 'Muted', targetUserId: USER_ID })
|
|
);
|
|
});
|
|
|
|
it('audits the mute even when the session refresh fails', async () => {
|
|
refreshSession.mockRejectedValueOnce(new Error('redis down'));
|
|
|
|
const { status } = await call(muteHandler, { body: { userId: USER_ID, reason: REASON } });
|
|
|
|
expect(status).toBe(200);
|
|
expect(trackModActivity).toHaveBeenCalledOnce();
|
|
expect(trackUserActivity).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('tags the user write with a fixed, caller-independent updateSource', async () => {
|
|
await call(muteHandler, {
|
|
body: { userId: USER_ID, reason: REASON, source: 'anything-the-caller-likes' },
|
|
});
|
|
|
|
expect(userUpdateCounterInc).toHaveBeenCalledWith({
|
|
location: 'user-restriction.service:webhook:mutePendingReview',
|
|
});
|
|
});
|
|
|
|
it('rejects a payload with no reason and mutes nobody', async () => {
|
|
const { status } = await call(muteHandler, { body: { userId: USER_ID } });
|
|
|
|
expect(status).toBe(400);
|
|
expect(store.users.get(USER_ID)).toMatchObject({ muted: false });
|
|
expect(trackModActivity).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('POST /api/mod/overturn-user-mute', () => {
|
|
beforeEach(seed);
|
|
|
|
it.each([
|
|
['no token', null],
|
|
['a wrong token', 'not-the-webhook-token'],
|
|
])('rejects a request with %s', async (_label, token) => {
|
|
await applyPendingReviewMute({ userId: USER_ID, triggers, updateSource: 'test' });
|
|
|
|
const { status } = await call(overturnHandler, {
|
|
token,
|
|
body: { userId: USER_ID, reason: 'mistake' },
|
|
});
|
|
|
|
expect(status).toBe(401);
|
|
expect(store.restrictions[0].status).toBe('Pending');
|
|
expect(store.users.get(USER_ID)).toMatchObject({ muted: true });
|
|
});
|
|
|
|
it('rejects a GET even with a valid token', async () => {
|
|
const { status } = await call(overturnHandler, {
|
|
method: 'GET',
|
|
body: { userId: USER_ID, reason: 'mistake' },
|
|
});
|
|
|
|
expect(status).toBe(405);
|
|
});
|
|
|
|
it('unmutes and overturns the open restriction so the queue is cleared', async () => {
|
|
await applyPendingReviewMute({ userId: USER_ID, triggers, updateSource: 'test' });
|
|
|
|
const { status, body } = await call(overturnHandler, {
|
|
body: { userId: USER_ID, reason: 'mistake' },
|
|
});
|
|
|
|
expect(status).toBe(200);
|
|
expect(body).toMatchObject({ userId: USER_ID, unmuted: true });
|
|
expect(store.users.get(USER_ID)).toMatchObject({ muted: false });
|
|
expect(store.restrictions[0]).toMatchObject({ status: 'Overturned', resolvedBy: -1 });
|
|
});
|
|
|
|
it('reinstates the subscription and resets the violation count', async () => {
|
|
await applyPendingReviewMute({ userId: USER_ID, triggers, updateSource: 'test' });
|
|
|
|
await call(overturnHandler, { body: { userId: USER_ID, reason: 'mistake' } });
|
|
|
|
expect(reinstateSubscription).toHaveBeenCalledWith({ userId: USER_ID });
|
|
expect(resetProhibitedRequestCount).toHaveBeenCalledWith(USER_ID);
|
|
});
|
|
|
|
it('audits the overturn', async () => {
|
|
await applyPendingReviewMute({ userId: USER_ID, triggers, updateSource: 'test' });
|
|
|
|
await call(overturnHandler, { body: { userId: USER_ID, reason: 'mistake' } });
|
|
|
|
expect(trackModActivity).toHaveBeenCalledWith(-1, {
|
|
entityType: 'user',
|
|
entityId: USER_ID,
|
|
activity: 'overturnPendingReviewMute',
|
|
});
|
|
expect(trackUserActivity).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: 'Unmuted', targetUserId: USER_ID })
|
|
);
|
|
});
|
|
|
|
it('audits the overturn even when the notification fails', async () => {
|
|
await applyPendingReviewMute({ userId: USER_ID, triggers, updateSource: 'test' });
|
|
createNotification.mockRejectedValueOnce(new Error('notification service down'));
|
|
|
|
const { status, body } = await call(overturnHandler, {
|
|
body: { userId: USER_ID, reason: 'mistake' },
|
|
});
|
|
|
|
expect(status).toBe(200);
|
|
expect(body).toMatchObject({ userId: USER_ID, unmuted: true });
|
|
expect(trackModActivity).toHaveBeenCalledOnce();
|
|
expect(trackUserActivity).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it.each([
|
|
['the system actor', constants.system.user.id, 'protected'],
|
|
['the official brand account', constants.system.officialUserId, 'protected'],
|
|
['a moderator', MOD_ID, 'moderator'],
|
|
])('refuses to overturn %s', async (_label, userId, skipped) => {
|
|
store.users.set(userId, makeUser(userId, { isModerator: userId === MOD_ID, muted: true }));
|
|
store.restrictions.push({
|
|
id: 1,
|
|
userId,
|
|
type: 'generation',
|
|
status: 'Pending',
|
|
triggers: [],
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
const result = await overturnPendingReviewMute({ userId, moderatorId: -1 });
|
|
|
|
expect(result).toEqual({ unmuted: false, skipped });
|
|
expect(store.restrictions[0].status).toBe('Pending');
|
|
expect(store.users.get(userId)).toMatchObject({ muted: true });
|
|
});
|
|
|
|
it('refuses to overturn a mute a moderator made by hand, which mutedAt marks', async () => {
|
|
await setUserMuted({ userId: USER_ID, muted: true });
|
|
store.restrictions.push({
|
|
id: 1,
|
|
userId: USER_ID,
|
|
type: 'generation',
|
|
status: 'Pending',
|
|
triggers: [],
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
const result = await overturnPendingReviewMute({ userId: USER_ID, moderatorId: -1 });
|
|
|
|
expect(result).toEqual({ unmuted: false, skipped: 'manually-muted' });
|
|
expect(store.restrictions[0].status).toBe('Pending');
|
|
expect(store.users.get(USER_ID)).toMatchObject({ muted: true });
|
|
expect(reinstateSubscription).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('is a no-op when there is nothing open to overturn', async () => {
|
|
const result = await overturnPendingReviewMute({ userId: USER_ID, moderatorId: -1 });
|
|
|
|
expect(result).toEqual({ unmuted: false, skipped: 'no-pending-restriction' });
|
|
expect(reinstateSubscription).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('confirmed mute', () => {
|
|
beforeEach(seed);
|
|
|
|
it('writes mutedAt', async () => {
|
|
await setUserMuted({ userId: USER_ID, muted: true });
|
|
|
|
expect(store.users.get(USER_ID)?.mutedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it('is picked up by confirm-mutes, which cancels the subscription', async () => {
|
|
await setUserMuted({ userId: USER_ID, muted: true });
|
|
|
|
await runConfirmMutes();
|
|
|
|
expect(cancelSubscriptionPlan).toHaveBeenCalledWith({ userId: USER_ID });
|
|
expect(cancelSubscription).toHaveBeenCalledWith({ userId: USER_ID, atPeriodEnd: true });
|
|
});
|
|
|
|
// The confirm-mutes tests above read through a hand-written stand-in for the
|
|
// job's raw SQL. Pin the predicate EXACTLY: `toContain` would still pass if the
|
|
// job were widened to `OR "mutedAt" IS NULL`, which is the regression that
|
|
// would start cancelling pending-review-muted users' subscriptions.
|
|
it('selects on muted AND a mutedAt newer than the last run, and nothing else', async () => {
|
|
await runConfirmMutes();
|
|
|
|
const sql = lastQuery.sql.replace(/\s+/g, ' ').trim();
|
|
expect(sql).toBe('SELECT id FROM "User" WHERE "muted" AND "mutedAt" > ?');
|
|
});
|
|
});
|