test: cover captcha env fallback, cache TTLs and public e2e flows
CI / check (push) Successful in 39s
CI / tests-imaging-browsers (push) Successful in 1m46s
CI / tests-unit (push) Successful in 2m16s
CI / tests-integration (push) Successful in 2m12s
CI / tests-ui (push) Failing after 3m45s
CI / preflight (push) Skipped
CI / deploy (push) Skipped

- unit: captcha env fallback/precedence (DB wins, explicit "none") and
  perf-ttls defaults, overrides and invalid values
- e2e: public page render + image health, register form and Turnstile
  widget consistency, anonymous auth-guard redirects, plus warm latency
  budgets and cache-control contracts
This commit is contained in:
openhands committed 2026-10-15 16:00:08 +02:00
1 parent 9f024dc6ac
commit 62de3e3a8b
7 files changed
+401

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
import { expect, test } from "@playwright/test";
import { expectNoApplicationError } from "./helpers";
// Unauthenticated requests to private routes must bounce to the login form —
// never render private content and never 500. Two guard paths exist: the edge
// proxy rewrites `/admin/*` to `/login?from=…` (`src/proxy.ts`), while the
// member pages and the mod console redirect from their layouts with a bare
// `/login`.
// Routes where the proxy preserves the requested destination.
const PROXY_ROUTES = ["/admin", "/admin/settings"] as const;
// Routes guarded by a page/layout `redirect("/login")` with no destination.
const LAYOUT_ROUTES = [
"/settings",
"/me",
"/messages",
"/notifications",
"/friends",
"/redeem",
"/mod",
"/client",
] as const;
for (const route of PROXY_ROUTES) {
test(`${route} redirects to login and keeps the return path`, async ({
page,
}) => {
const response = await page.goto(route);
expect(response?.ok(), `GET ${route} -> ${response?.status()}`).toBe(true);
await expect(page).toHaveURL(/\/login/);
await expect(page.locator('input[type="password"]').first()).toBeVisible();
await expectNoApplicationError(page);
const from = new URL(page.url()).searchParams.get("from");
expect(from, "login lost the return destination").toBeTruthy();
expect(new URL(from as string, "http://localhost").pathname).toBe(route);
});
}
for (const route of LAYOUT_ROUTES) {
test(`${route} redirects an anonymous visitor to login`, async ({ page }) => {
const response = await page.goto(route);
expect(response?.ok(), `GET ${route} -> ${response?.status()}`).toBe(true);
await expect(page).toHaveURL(/\/login/);
await expect(page.locator('input[type="password"]').first()).toBeVisible();
await expectNoApplicationError(page);
});
}
+62
View File
@@ -0,0 +1,62 @@
import { expect, type Page } from "@playwright/test";
/**
* Shared assertions for the read-only public e2e suite. These specs run against
* a live CMS (see `playwright.config.ts`) and must never mutate data — they
* guard the "everything loads, nothing is broken" contract that no unit test
* can see.
*/
/** Fails if Next rendered a runtime/server error boundary into the page. */
export async function expectNoApplicationError(page: Page) {
await expect(page.locator("body")).not.toContainText("Application error");
await expect(page.locator("body")).not.toContainText("Internal Server Error");
}
/**
* Every <img> that finished loading must have decoded pixels. A failed request
* (404, broken CMS media, bad avatar figure) reports `complete === true` with
* `naturalWidth === 0`, which is what this catches. Intentionally lazy images
* that are still pending (`complete === false`) are ignored.
*/
export async function expectNoBrokenImages(page: Page) {
const broken = await page.evaluate(() => {
return Array.from(document.images)
.filter((img) => {
const src = img.currentSrc || img.src;
if (!src || src.startsWith("data:")) return false;
return img.complete && img.naturalWidth === 0;
})
.map((img) => img.currentSrc || img.src);
});
expect(broken, `broken images:\n${broken.join("\n")}`).toEqual([]);
}
/** No horizontal scrollbar — the classic mobile layout regression. */
export async function expectNoHorizontalOverflow(page: Page) {
const overflowing = await page.evaluate(() => {
const doc = document.documentElement;
return doc.scrollWidth > window.innerWidth + 1;
});
expect(overflowing, "page scrolls horizontally").toBe(false);
}
/**
* Walks the page top-to-bottom so `loading="lazy"` images below the fold get a
* chance to start before the broken-image check runs.
*/
export async function scrollThroughPage(page: Page) {
await page.evaluate(async () => {
const step = window.innerHeight;
for (let y = 0; y < document.body.scrollHeight; y += step) {
window.scrollTo(0, y);
await new Promise((resolve) => setTimeout(resolve, 80));
}
window.scrollTo(0, 0);
});
}
/** Skips a spec when the CMS answered with a maintenance redirect. */
export function isMaintenanceRedirect(url: string) {
return /\/maintenance(?:$|[/?])/.test(url);
}
+47
View File
@@ -0,0 +1,47 @@
import { expect, test } from "@playwright/test";
// Latency + caching contract for the anonymous pages. The perf work relies on
// the edge/shared cache (`src/proxy.ts`): public content pages advertise a
// short `s-maxage`, while session-dependent pages (login, register, settings)
// must stay `private, no-store`. Budgets are deliberately loose so a cold
// cache or a slow CI hop does not flap the gate.
const BUDGET_MS = Number(process.env.E2E_LOAD_BUDGET_MS || 4000);
const WARM_ROUTES = ["/", "/news", "/staff", "/shop", "/login", "/register"];
for (const route of WARM_ROUTES) {
test(`${route} responds within the warm budget`, async ({ request }) => {
await request.get(route); // warm the render + data caches
const started = Date.now();
const response = await request.get(route);
const elapsed = Date.now() - started;
expect([200, 304]).toContain(response.status());
expect(
elapsed,
`GET ${route} took ${elapsed}ms (budget ${BUDGET_MS}ms)`,
).toBeLessThan(BUDGET_MS);
});
}
test("anonymous content pages advertise a shared cache", async ({
request,
}) => {
for (const route of ["/news", "/staff", "/shop"]) {
const response = await request.get(route);
const cacheControl = response.headers()["cache-control"] ?? "";
expect(cacheControl, `GET ${route} cache-control`).toContain("s-maxage");
}
});
test("session-dependent pages stay private and uncached", async ({
request,
}) => {
for (const route of ["/login", "/register"]) {
const response = await request.get(route);
const cacheControl = response.headers()["cache-control"] ?? "";
expect(cacheControl, `GET ${route} cache-control`).toContain("no-store");
expect(cacheControl, `GET ${route} must not be shared`).not.toContain(
"s-maxage",
);
}
});
+46
View File
@@ -0,0 +1,46 @@
import { expect, test } from "@playwright/test";
import {
expectNoApplicationError,
expectNoBrokenImages,
expectNoHorizontalOverflow,
scrollThroughPage,
} from "./helpers";
// Read-only smoke coverage for the anonymous-facing pages: each must return a
// real document, render without an error boundary, and load every image. This
// is where a missing upload, a bad avatar figure or a broken theme asset shows
// up — none of which the unit suite can catch.
const PUBLIC_ROUTES = [
"/",
"/news",
"/staff",
"/shop",
"/search",
"/leaderboard",
"/help",
"/radio",
] as const;
for (const route of PUBLIC_ROUTES) {
test(`${route} renders, loads its images and does not overflow`, async ({
page,
}) => {
const response = await page.goto(route);
expect(response?.ok(), `GET ${route} -> ${response?.status()}`).toBe(true);
await expect(page.locator("main").first()).toBeVisible();
await expectNoApplicationError(page);
await scrollThroughPage(page);
await expectNoBrokenImages(page);
await expectNoHorizontalOverflow(page);
});
}
test("the homepage stays usable on a narrow viewport", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const response = await page.goto("/");
expect(response?.ok()).toBe(true);
await expectNoApplicationError(page);
await expectNoHorizontalOverflow(page);
});
+83
View File
@@ -0,0 +1,83 @@
import { expect, test } from "@playwright/test";
import {
expectNoApplicationError,
expectNoBrokenImages,
expectNoHorizontalOverflow,
isMaintenanceRedirect,
scrollThroughPage,
} from "./helpers";
// The register form is the gate for every new player, so it gets a deeper
// read-only pass than the generic public-page spec. It also pins the captcha
// contract that is configured through `CAPTCHA_PROVIDER` / `TURNSTILE_*` env
// vars (see `src/lib/services/captcha.ts`): whatever provider the running CMS
// resolves, the DOM must expose exactly one widget whose sitekey is non-empty.
const CAPTCHA_SELECTOR = ".cf-turnstile, .g-recaptcha, .h-captcha";
// Optional explicit expectation for a deploy, e.g. `E2E_EXPECT_CAPTCHA=turnstile`
// or `E2E_EXPECT_CAPTCHA=none`. Left unset, the spec only checks consistency.
const EXPECT = process.env.E2E_EXPECT_CAPTCHA?.trim();
test.describe("register", () => {
test.beforeEach(async ({ page }) => {
const response = await page.goto("/register");
test.skip(
isMaintenanceRedirect(page.url()) || response?.status() === 404,
"register is not reachable on this deploy",
);
});
test("renders every field a new player has to fill in", async ({ page }) => {
await expect(page.locator('input[name="username"]')).toBeVisible();
await expect(page.locator('input[name="mail"]')).toBeVisible();
await expect(page.locator('input[name="password"]').first()).toBeVisible();
await expect(
page.locator('input[name="password_confirmation"]'),
).toBeVisible();
await expect(page.locator('input[name="terms"]')).toBeVisible();
await expect(page.locator('button[type="submit"]').first()).toBeVisible();
await expectNoApplicationError(page);
});
test("loads its theme/avatar images and fits the viewport", async ({
page,
}) => {
await scrollThroughPage(page);
await expectNoBrokenImages(page);
await expectNoHorizontalOverflow(page);
});
test("exposes exactly one captcha widget with a site key", async ({
page,
}) => {
const widgets = page.locator(CAPTCHA_SELECTOR);
if (EXPECT === "none") {
await expect(widgets).toHaveCount(0);
return;
}
if (EXPECT === "turnstile") {
await expect(page.locator(".cf-turnstile")).toHaveCount(1);
}
const count = await widgets.count();
expect(
count,
`expected at most one captcha provider, found ${count}`,
).toBeLessThanOrEqual(1);
for (let index = 0; index < count; index += 1) {
const siteKey = await widgets.nth(index).getAttribute("data-sitekey");
expect(siteKey, "captcha widget without data-sitekey").toBeTruthy();
expect(siteKey?.trim().length).toBeGreaterThan(0);
}
if (count > 0 && (await page.locator(".cf-turnstile").count()) > 0) {
const script = page.locator(
'script[src*="challenges.cloudflare.com/turnstile"]',
);
await expect(script).toHaveCount(1);
}
});
});
+54
View File
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it, vi } from "vitest";
/**
* perf-ttls reads the CMS_* env vars at module load, so each case re-imports
* with a freshly stubbed process.env (vitest sets SKIP_ENV_VALIDATION=1, so
* @/env is the raw process.env object and vi.stubEnv is visible to it).
*/
async function loadTtls() {
return await import("@/lib/perf-ttls");
}
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
describe("perf-ttls", () => {
it("uses the documented defaults when the env is unset", async () => {
const mod = await loadTtls();
expect(mod.HOME_PAYLOAD_TTL_MS).toBe(15_000);
expect(mod.NEWS_LIST_TTL_MS).toBe(300_000);
expect(mod.NEWS_ARCHIVE_TTL_MS).toBe(60_000);
expect(mod.PUBLIC_SEARCH_TTL_MS).toBe(30_000);
expect(mod.ONLINE_COUNT_TTL_MS).toBe(10_000);
expect(mod.COUNTERS_TTL_MS).toBe(300_000);
expect(mod.SITE_SETTINGS_TTL_MS).toBe(300_000);
expect(mod.SITE_SETTINGS_MEMORY_TTL_MS).toBe(60_000);
expect(mod.CACHE_WARMUP_ENABLED).toBe(true);
});
it("honours env overrides (seconds → ms)", async () => {
vi.stubEnv("CMS_HOME_PAYLOAD_TTL_SEC", "99");
vi.stubEnv("CMS_ONLINE_COUNT_TTL_SEC", "3");
vi.stubEnv("CMS_WARMUP_ENABLED", "false");
const mod = await loadTtls();
expect(mod.HOME_PAYLOAD_TTL_MS).toBe(99_000);
expect(mod.ONLINE_COUNT_TTL_MS).toBe(3_000);
expect(mod.CACHE_WARMUP_ENABLED).toBe(false);
// Untouched keys keep their defaults.
expect(mod.NEWS_LIST_TTL_MS).toBe(300_000);
});
it("ignores invalid numbers and keeps the default", async () => {
vi.stubEnv("CMS_NEWS_ARCHIVE_TTL_SEC", "not-a-number");
const mod = await loadTtls();
expect(mod.NEWS_ARCHIVE_TTL_MS).toBe(60_000);
});
it("treats zero and negative values as unset", async () => {
vi.stubEnv("CMS_SEARCH_TTL_SEC", "0");
const mod = await loadTtls();
expect(mod.PUBLIC_SEARCH_TTL_MS).toBe(30_000);
});
});
+61
View File
@@ -12,6 +12,7 @@ import { captchaConfig, verifyCaptcha } from "./captcha";
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
describe("verifyCaptcha", () => {
@@ -74,3 +75,63 @@ describe("captchaConfig", () => {
expect(cfg.provider).toBe("none");
});
});
describe("env fallback", () => {
it("enables Turnstile from env when the DB is unconfigured", async () => {
mockGet.mockResolvedValue("");
vi.stubEnv("CAPTCHA_PROVIDER", "turnstile");
vi.stubEnv("TURNSTILE_SITE_KEY", "env-site-key");
const cfg = await captchaConfig();
expect(cfg.provider).toBe("turnstile");
expect(cfg.siteKey).toBe("env-site-key");
expect(cfg.field).toBe("cf-turnstile-response");
});
it("lets an explicit DB value — including none — win over env", async () => {
mockGet.mockImplementation(async (key: string) => {
if (key === "captcha_provider") return "none";
return "";
});
vi.stubEnv("CAPTCHA_PROVIDER", "turnstile");
vi.stubEnv("TURNSTILE_SITE_KEY", "env-site-key");
const cfg = await captchaConfig();
expect(cfg.provider).toBe("none");
});
it("prefers the DB site key over the env one", async () => {
mockGet.mockImplementation(async (key: string) => {
if (key === "captcha_provider") return "turnstile";
if (key === "turnstile_site_key") return "db-site-key";
return "";
});
vi.stubEnv("TURNSTILE_SITE_KEY", "env-site-key");
const cfg = await captchaConfig();
expect(cfg.siteKey).toBe("db-site-key");
});
it("verifies against the env secret when the DB secret is unset", async () => {
mockGet.mockResolvedValue("");
vi.stubEnv("CAPTCHA_PROVIDER", "turnstile");
vi.stubEnv("TURNSTILE_SITE_KEY", "env-site-key");
vi.stubEnv("TURNSTILE_SECRET_KEY", "env-secret");
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
vi.stubGlobal("fetch", fetchMock);
expect(await verifyCaptcha("tok", "1.2.3.4")).toBe(true);
expect(fetchMock).toHaveBeenCalledOnce();
const [, init] = fetchMock.mock.calls[0];
expect((init.body as URLSearchParams).get("secret")).toBe("env-secret");
expect(fetchMock.mock.calls[0][0]).toBe(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
);
});
it("fails closed when the provider comes from env but no secret exists", async () => {
mockGet.mockResolvedValue("");
vi.stubEnv("CAPTCHA_PROVIDER", "turnstile");
vi.stubEnv("TURNSTILE_SITE_KEY", "env-site-key");
expect(await verifyCaptcha("tok")).toBe(false);
});
});