diff --git a/e2e/auth-guards.spec.ts b/e2e/auth-guards.spec.ts new file mode 100644 index 00000000..9e83b6ba --- /dev/null +++ b/e2e/auth-guards.spec.ts @@ -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); + }); +} diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 00000000..924d4d77 --- /dev/null +++ b/e2e/helpers.ts @@ -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 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); +} diff --git a/e2e/performance.spec.ts b/e2e/performance.spec.ts new file mode 100644 index 00000000..7ded0ed9 --- /dev/null +++ b/e2e/performance.spec.ts @@ -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", + ); + } +}); diff --git a/e2e/public-pages.spec.ts b/e2e/public-pages.spec.ts new file mode 100644 index 00000000..534a8c92 --- /dev/null +++ b/e2e/public-pages.spec.ts @@ -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); +}); diff --git a/e2e/register.spec.ts b/e2e/register.spec.ts new file mode 100644 index 00000000..eb9acb1b --- /dev/null +++ b/e2e/register.spec.ts @@ -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); + } + }); +}); diff --git a/src/lib/perf-ttls.test.ts b/src/lib/perf-ttls.test.ts new file mode 100644 index 00000000..79d7232e --- /dev/null +++ b/src/lib/perf-ttls.test.ts @@ -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); + }); +}); diff --git a/src/lib/services/captcha.test.ts b/src/lib/services/captcha.test.ts index 08cf45cb..ce753785 100644 --- a/src/lib/services/captcha.test.ts +++ b/src/lib/services/captcha.test.ts @@ -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); + }); +});