Files
civitai__civitai/tests/preview-auth.setup.ts
Zachary Lowden 3cf6c7e879 fix(tests): unbreak preview smoke — moderator routes migrated, and two router procedures deleted (#4179)
`preview / smoke-tests` had been red on every PR based after #3573, which migrated the
moderator surfaces to the standalone app. Three PRs were merged through the red gate
in the four hours before this fix — the failure mode a permanently-red shared gate
always produces.

Four failures, from TWO causes, not one:

  * Two routing assertions still pointed at `/moderator/reports` and `/moderator/images`,
    which now 302 to the standalone app. Repointed to `/moderator/rewards` and
    `/moderator/suspicious-audit-matches`.
  * `mod can self-seed a post, report it, and action the report` is NOT a routing
    problem: #3573 also deleted `report.getAll` and `report.setStatus` from the
    main app's router. Keeps `post.create` -> `report.create` (both still main-app
    `guardedProcedure`) and drops the actioning legs.

Route screening targeted the property that defeated three earlier candidates — an
anchor that renders UNCONDITIONALLY. Both routes were checked on `origin/main` for:
file present; not matched by the real longest-prefix `migratedRouteKey` logic (run
with `reports`->`reports` and `images`->`images` as positive controls in the same
pass); `requireModerator: true`; no feature-flag gate; and no counterpart in
`apps/moderator/src/routes/`. Anchors are `<Meta title>` plus an unconditional
`<Title order={1}>` read via `getByRole('heading')` — structural rather than a text
search, and above the `isLoading ? … : empty ? … : rows` ternary, so they hold on a
full or empty prod-clone DB and even if the page's tRPC query errors.

`preview-auth-guard.spec.ts` keeps `MODERATOR_PATH` and asserts the 3xx with
`maxRedirects: 0`, checking the `Location` is neither of the guard's two bounces and
IS the migrated hop — the idiom that file already uses for the external `/login` hub.

Also fixes `tests/preview-auth.setup.ts`, which warmed `/moderator/reports` and
`/moderator/images` with the mod cookie. Those now 302 to `MODERATOR_APP_URL`, which
DEFAULTS TO PRODUCTION — so CI was warming the production moderator app and compiling
nothing on the preview.

⚠️ The dropped report-actioning coverage is LOST, NOT RELOCATED. `apps/moderator` has
zero test files (positive control: 81 under `packages/`), so there is no suite for it
to move to. The loss originates in #3573's deletion of the procedures, not here —
leaving the assertions would keep the gate red for a reason no main-app change can
fix. Tracked as a follow-up.

Verified on the real gate rather than inferred: `preview / smoke-tests` 66 passed /
0 flaky / 0 failed on pipeline `pr-preview-4179-s64z2`, against the broken baseline of
61 passed / 4 failed. Test-only — 3 files under `tests/`, no `src/` change.

Closes #4171
2026-08-20 00:04:24 -05:00

176 lines
8.3 KiB
TypeScript

import { test as setup } from '@playwright/test';
import fs from 'fs';
import { EncryptJWT } from 'jose';
import { hkdfSync } from 'node:crypto';
import { v4 as uuid } from 'uuid';
import { PREVIEW_USERS, type PreviewRole, storageStatePath } from './preview-fixtures';
/**
* Preview-environment auth setup.
*
* A deployed PR preview (IS_PREVIEW=true) is gated by preview-auth.middleware:
* unauthenticated -> /login, moderators pass, other logged-in users pass only
* if the Flipt `preview-site-access` `testers` segment matches them. The local
* `/testing/testing-login` flow (tests/auth.setup.ts) is DEAD against a preview
* because previews run NODE_ENV=production, which disables both the
* `testing-login` credentials provider and the `/testing/*` route.
*
* Instead we mint the LEGACY next-auth session cookie directly with the preview's
* shared NEXTAUTH_SECRET — the same JWE the app's `decodeLegacySessionCookie`
* reads, so parity is guaranteed.
*
* IMPORTANT (post first-party-OAuth cutover): `getSessionUserById` now resolves a
* user from the shared session cache then the centralized hub (auth.civitai.com),
* which read the PRODUCTION identity store — they have NO row for these dev-clone-
* only smoke users, so a minted cookie would resolve to a null session and every
* authed request would loop to /login. `get-server-auth-session.ts` therefore has a
* PREVIEW-ONLY fallback (gated on IS_PREVIEW) that trusts the rich `user` embedded
* in this minted legacy cookie when the hub lookup misses — restoring the pre-cutover
* "gate reads token.user straight from the cookie" behaviour for previews only.
* So the minted `user` object below (id + isModerator + tier + the profile fields)
* IS the authoritative session user on a preview. The backing User rows (and the
* backing User rows (and the gold subscription) are seeded into cnpg-cluster-dev
* by the datapacket-talos `seed-smoke-test-users` CronJob; ci-smoke-tester /
* ci-smoke-gold are in the flipt `testers` allowlist so they pass the gate.
*
* Only runs in the preview Playwright config (playwright.preview.config.ts);
* the default config ignores `preview-*` files.
*/
const SECRET = process.env.NEXTAUTH_SECRET;
const PREVIEW_URL = process.env.PREVIEW_URL;
const COOKIE_NAME = '__Secure-civitai-token'; // libs/auth.ts — https preview => __Secure- prefix
const MAX_AGE_S = 30 * 24 * 60 * 60;
// Mint the LEGACY next-auth v4 session cookie (a `dir`/`A256GCM` JWE, HKDF-derived key) WITHOUT next-auth, which
// is now removed. The app still ACCEPTS it via @civitai/auth's decodeLegacySessionCookie during the cutover, so
// this mirrors that decoder's key derivation exactly.
const ENC_INFO = 'NextAuth.js Generated Encryption Key';
const derivedKey = (secret: string) => new Uint8Array(hkdfSync('sha256', secret, '', ENC_INFO, 32));
async function mintStorageState(role: PreviewRole): Promise<string> {
const u = PREVIEW_USERS[role];
// token.user shape (ExtendedUser, src/types/next-auth.d.ts). id + isModerator
// drive the gate; the rest matches the seeded DB row so SSR treats it as a
// real logged-in user. ci-smoke-mod is the only moderator.
const user = {
id: u.id,
username: u.username,
email: `${u.username}@civitai.test`,
isModerator: u.isModerator,
tier: u.tier,
showNsfw: true,
blurNsfw: false,
browsingLevel: 1,
onboarding: 15, // OnboardingComplete (TOS|Profile|BrowsingLevels|Buzz)
muted: false,
};
const token = { user, sub: String(u.id), id: uuid(), signedAt: Date.now() };
const value = await new EncryptJWT(token)
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.setIssuedAt()
.setExpirationTime(Math.floor(Date.now() / 1000) + MAX_AGE_S)
.encrypt(derivedKey(SECRET as string));
const { hostname } = new URL(PREVIEW_URL as string);
const storageState = {
cookies: [
{
name: COOKIE_NAME,
value,
domain: hostname,
path: '/',
expires: Math.floor(Date.now() / 1000) + MAX_AGE_S,
httpOnly: true,
secure: true,
sameSite: 'Lax' as const,
},
],
origins: [],
};
fs.mkdirSync('tests/auth', { recursive: true });
fs.writeFileSync(storageStatePath(role), JSON.stringify(storageState, null, 2));
return value;
}
setup('mint preview sessions', async ({ request }) => {
// This setup runs the cold-pod warm-up below: ~9 SEQUENTIAL heavy-SSR GETs (each
// capped at 60s) so one route compiles at a time (parallel heavy renders OOM the
// single-replica pod). On a genuinely cold pod the cumulative warm-up can exceed
// the suite's 90s per-test timeout — and a setup timeout SKIPS every dependent
// smoke test (worse than the flake we're fixing). So give just this setup a large
// ceiling. It's a CEILING, not the runtime: the setup still finishes as fast as
// the warm-ups actually take (~2s when the pod is already warm from verify-preview).
setup.setTimeout(480_000);
if (!SECRET) throw new Error('NEXTAUTH_SECRET is required to mint preview sessions');
if (!PREVIEW_URL) throw new Error('PREVIEW_URL is required for preview smoke tests');
const jwts: Partial<Record<PreviewRole, string>> = {};
for (const role of Object.keys(PREVIEW_USERS) as PreviewRole[]) {
jwts[role] = await mintStorageState(role);
}
// Warm the freshly-deployed preview before the suite so the first real test
// doesn't pay the full cold-SSR cost (Next warm-up + JIT + DB pools). Each route
// JIT-compiles on its first hit, so we warm EVERY heavy SSR page the suite then
// navigates — cold-page timeouts were the dominant smoke flake (a slow-window
// page.goto exceeding the nav budget, then passing on retry once warm). They must
// be warmed AUTHENTICATED: on a preview the gate 307s an UNauthenticated request
// to /login, so an anon GET wouldn't touch the real render path. Sequential (one
// concurrent heavy SSR at a time — the single-replica pod OOM'd under parallel
// heavy loads) + non-fatal (.catch): a slow warm-up GET still triggers the
// server-side compile even if the client times out, and the suite + retries
// cover any miss.
const gold = jwts.gold;
if (gold) {
const headers = { cookie: `${COOKIE_NAME}=${gold}` };
// gold (gate-passing paid member) reaches all non-mod heavy pages the suite hits.
for (const path of [
'/',
'/models',
'/images',
'/user/membership',
'/generate',
'/purchase/buzz',
'/pricing',
]) {
await request.get(path, { timeout: 60_000, headers }).catch(() => {});
}
}
const mod = jwts.mod;
if (mod) {
// /moderator/* render only for a moderator (gold would be bounced), so warm the
// moderation-spec pages with the mod cookie.
// These MUST be paths this app still serves. /moderator/reports + /moderator/images
// migrated to the standalone moderator app in #3573 and now 302 off-origin to
// MODERATOR_APP_URL — which defaults to PRODUCTION — so warming them warmed prod
// and compiled nothing here (civitai#4171). Keep this list in step with
// MODERATOR_SURFACES in preview-moderation.spec.ts.
const headers = { cookie: `${COOKIE_NAME}=${mod}` };
for (const path of ['/moderator/rewards', '/moderator/suspicious-audit-matches']) {
await request.get(path, { timeout: 60_000, headers }).catch(() => {});
}
}
// Best-effort search warm-up (NOT a blocking readiness gate). The image-search
// path (getAllImagesIndex -> the in-cluster feeds-proxy via METRICS_SEARCH_HOST)
// can be cold/overloaded; fire ONE GET to warm the connection. We deliberately do
// NOT poll-until-ready: the earlier 12x6s gate wasted up to ~72s when search was
// overloaded for the whole window, and it was never the load-bearing fix anyway —
// each search-dependent spec (whatIf, image-feed) wraps its query in retryFlaky
// (preview-retry.ts), which rides out a transient 408/5xx with backoff. So a single
// fire-and-forget warm-up is all that's useful here.
// (/moderator/images used to be warmed by the mod loop above; it migrated out of
// this app in #3573 and is no longer a search consumer here — civitai#4171.)
if (gold) {
await request
.get('/api/v1/images?sort=Most%20Reactions&limit=1', {
timeout: 20_000,
headers: { cookie: `${COOKIE_NAME}=${gold}` },
})
.catch(() => {});
}
});