mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
3cf6c7e879
`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
176 lines
8.3 KiB
TypeScript
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(() => {});
|
|
}
|
|
});
|