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(() => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user