Files
civitai__civitai/tests/preview-auth-guard.spec.ts
briant 6e61e1821c test(preview): fail a stranded /moderator probe in the local suite, not only in a job nobody reads
#3573 migrated the moderator surfaces to the standalone app and four preview specs kept probing
paths that now 302 off-origin. `preview / smoke-tests` went red and STAYED red on every PR based
after it, and because that job is report-only nothing stopped: three PRs merged through it in the
four hours before someone looked, and an agent nearly attributed a genuine `main` breakage to their
own PR because it arrived inside an already-red set. #4179 fixed the four failures.

This is the recurrence guard. It scans the preview specs for `/moderator/*` literals and fails when
one has migrated or resolves to no page, so the next migration reddens the machine of whoever
performs it — `test:unit:run` is on the before-committing list — as one named test carrying the path
and the fix, rather than as browser assertions in a job whose red is ambient.

Be precise about what that buys, because the obvious reading is wrong: it does NOT make the fact
blocking. The `unit` job is `continue-on-error: true` and `main` has no required status checks, so
this is report-only too. What changes is where and how the failure appears.

The three specs that assert the redirect on purpose carry an inline `@migrated-route-probe` marker,
checked in both directions — a path that comes BACK to this app strands the assertion the same way.
A marked line may hold only one probe, or the marker would excuse the others silently.

Two properties the scan needs and did not get for free: the positive control asserts the ENFORCED
partition rather than the total, because `it.each([])` registers zero tests and exits 0 (measured),
so marking every line would empty the guard with nothing to show for it; and the capture stops
before `?`, since `/moderator/reports?status=Pending` is the natural shape of a queue probe and the
exact route the incident was about.

Documentation this turned up as stale: the convention-guard list named four of seven, `test:lint-
rules` is invoked by no workflow (those guards run because they match the `unit` project), the root
test-command list omitted the packages and apps suites entirely, and the SvelteKit standard had no
testing section at all despite all three apps having one.

The `unit` job's flip-to-blocking note now carries what was measured today rather than leaving it to
be rediscovered: 8 of 39 recent runs had a red `Unit tests` STEP across 8 distinct branches, which
reads as flake but was trunk-red from a ledger test failing on every PR (fixed by #4191); 5 of 5
green after it. Plus the two traps — the run-level `conclusion` says success while the step under it
failed, and this workflow is `pull_request`-only, so a trunk-red test shows up as every PR reddening
at once.

Refs 868kubuz6

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 13:18:32 -06:00

99 lines
5.7 KiB
TypeScript

import { expect, test } from '@playwright/test';
import { storageStatePath } from './preview-fixtures';
/**
* Auth-guard tests for a deployed PR preview — the behaviors the NextAuth -> hub cutover introduced that the
* existing smoke / moderation specs don't cover:
*
* 1. The _app moderator guard (server/auth/route-guard.ts, moved off the edge middleware) REDIRECTS a
* gate-passing NON-moderator away from /moderator. preview-moderation proves the surviving moderator
* surfaces render for a mod — neither proves a non-mod is bounced, which is the whole point of the new
* guard.
* 2. /login is now a pure server-side redirect to the centralized hub (buildHubLoginRedirect), threading the
* returnUrl. We assert the redirect target WITHOUT following it (the hop lands on the external hub).
*
* Only runs under playwright.preview.config.ts (needs PREVIEW_URL + the minted storage states).
*/
// #3573 migrated this path to the standalone moderator app (shared/constants/migrated-moderator-routes.ts:
// `reports` -> `reports`), so for a MODERATOR it now ends in a 3xx to that app. It is still the right probe
// for the guard: the guard runs in _app.getInitialProps, ahead of the catchall's getServerSideProps, so a
// non-mod is bounced before the migration redirect can matter (which is why the two bounce tests below are
// unaffected by the migration), and for a mod the Location tells us WHICH hop happened.
const MODERATOR_PATH = '/moderator/reports'; // @migrated-route-probe — asserting the hop IS the point
// tester (Flipt allowlist) + gold (allowlist + tier) both CLEAR the preview gate but lack isModerator, so the
// _app guard must bounce them from /moderator specifically.
const NON_MOD_ROLES = ['tester', 'gold'] as const;
for (const role of NON_MOD_ROLES) {
test.describe(`_app moderator guard bounces ${role}`, () => {
test.use({ storageState: storageStatePath(role) });
test(`${role} cannot reach ${MODERATOR_PATH}`, async ({ page }) => {
await page.goto(MODERATOR_PATH, { waitUntil: 'domcontentloaded' });
// The guard sends an authed non-mod to '/' (login can't grant the missing permission, so it would loop).
expect(page.url(), `${role} should be bounced off the moderator surface`).not.toContain(
'/moderator'
);
// A bounce to /login would mean the session didn't resolve (a different failure) — assert it did.
expect(page.url(), `${role} session should resolve, not bounce to login`).not.toContain(
'/login'
);
// And it isn't the preview-access gate firing instead (these roles are allowlisted).
expect(page.url(), `${role} should not hit preview-restricted`).not.toContain(
'/preview-restricted'
);
});
});
}
test.describe('_app moderator guard admits a moderator (control)', () => {
test.use({ storageState: storageStatePath('mod') });
test('mod is not bounced — the only hop is the moderator-app migration redirect', async ({
page,
}) => {
// Inspect the 3xx Location WITHOUT following it — same idiom (and same reason) as the /login test below:
// the hop leaves this origin for the standalone moderator app, which a preview cannot exercise
// (MODERATOR_APP_URL defaults to production — server-schema.ts). `page.request` shares the browser
// context's cookies (minted by preview-auth.setup.ts) and its baseURL, so this is the mod's session.
const res = await page.request.get(MODERATOR_PATH, { maxRedirects: 0 });
expect(res.status(), `HTTP status for ${MODERATOR_PATH}`).toBeGreaterThanOrEqual(300);
expect(res.status(), `HTTP status for ${MODERATOR_PATH}`).toBeLessThan(400);
const location = res.headers()['location'] ?? '';
expect(location, `${MODERATOR_PATH} should set a redirect Location`).not.toBe('');
// The GUARD has exactly two bounces (route-guard.ts): '/' for an authed non-moderator, and
// '/login?returnUrl=…' when the session didn't resolve. Neither may fire for a mod — that IS this
// control. (The login bounce percent-encodes its returnUrl, so it can't spell a literal '/reports'.)
expect(location, 'a mod must not be bounced home by the _app guard').not.toBe('/');
expect(location, 'a mod session should resolve, not bounce to login').not.toContain('/login');
// …so the hop is the migration one. Deploy-agnostic: MODERATOR_APP_URL is env-per-deploy, so pin the
// PATH the mapping produces, not the host.
expect(location, 'mod should be forwarded to the moderator app /reports').toMatch(/\/reports$/);
});
});
test.describe('/login forwards to the centralized hub', () => {
test('threads the returnUrl into the hub redirect', async ({ playwright, baseURL }) => {
// Inspect the 3xx Location WITHOUT following it — the target is the external hub. A fresh, cookieless
// request context is an anonymous /login hit (/login is exempt from the preview gate).
const ctx = await playwright.request.newContext({ baseURL });
try {
const res = await ctx.get('/login?returnUrl=%2Fmodels', { maxRedirects: 0 });
expect(res.status(), '/login should redirect').toBeGreaterThanOrEqual(300);
expect(res.status(), '/login should redirect').toBeLessThan(400);
const location = res.headers()['location'] ?? '';
expect(location, '/login should set a redirect Location').not.toBe('');
// Deploy-agnostic: don't pin the hub host (env-per-deploy). The hop targets a hub /login and the original
// dest survives it — only the '/' encodes (%2F / %252F …); the letters 'models' stay literal at any depth.
expect(location.toLowerCase(), 'should target the hub login').toContain('login');
expect(location, 'returnUrl dest should survive the hop').toContain('models');
} finally {
await ctx.dispose();
}
});
});