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
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:
1 parent
9f024dc6ac
commit
62de3e3a8b
7 files changed
+401
No files matched your search
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ import { captchaConfig, verifyCaptcha } from "./captcha";
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("verifyCaptcha", () => {
|
describe("verifyCaptcha", () => {
|
||||||
@@ -74,3 +75,63 @@ describe("captchaConfig", () => {
|
|||||||
expect(cfg.provider).toBe("none");
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user