import { test, expect } from "@playwright/test" /** * Security-header and access-control smoke tests against a running build. * * Deliberately scoped to behaviour that does NOT require a seeded database, so * this suite is runnable against any deployed instance (set E2E_BASE_URL). * Checks that DO need one live in the "requires a database" block at the bottom * and are skipped unless E2E_DB=1. * * npm run build && E2E=1 npm run test:e2e * E2E_BASE_URL=https://staging.example.com npm run test:e2e */ const STATIC_PAGES = ["/terms", "/refund-policy", "/subprocessors", "/tenant-portal-info"] const APP_PAGES = ["/login", "/signup", "/forgot-password"] const PROTECTED = ["/dashboard", "/properties", "/tenants", "/rent", "/settings", "/admin"] test.describe("baseline security headers", () => { test("every response carries the standard hardening headers", async ({ request }) => { const res = await request.get("/login") const h = res.headers() expect(h["x-content-type-options"]).toBe("nosniff") expect(h["x-frame-options"]).toBe("DENY") expect(h["referrer-policy"]).toBe("no-referrer") expect(h["strict-transport-security"]).toContain("max-age=") }) test("Referrer-Policy is no-referrer so portal tokens cannot leak", async ({ request }) => { // Tenant-portal and calendar URLs carry their credential in the path, so a // Referer header would hand it to any third-party resource. const res = await request.get("/tenant-portal-info") expect(res.headers()["referrer-policy"]).toBe("no-referrer") }) test("CSP forbids framing and plugins everywhere", async ({ request }) => { for (const path of [...STATIC_PAGES, ...APP_PAGES]) { const csp = (await request.get(path)).headers()["content-security-policy"] ?? "" expect(csp, `${path} CSP`).toContain("frame-ancestors 'none'") expect(csp, `${path} CSP`).toContain("object-src 'none'") expect(csp, `${path} CSP`).toContain("base-uri 'self'") expect(csp, `${path} CSP`).toContain("form-action 'self'") } }) }) test.describe("CSP nonce scoping", () => { // The app surface gets a strict per-request nonce. Static pages keep // 'unsafe-inline' because they are prerendered at build time with no nonce on // their script tags — serving them a fresh nonce would block every script. for (const path of APP_PAGES) { test(`${path} uses a nonce and every inline script carries it`, async ({ request }) => { const res = await request.get(path) const csp = res.headers()["content-security-policy"] ?? "" const nonce = csp.match(/'nonce-([A-Za-z0-9+/=]+)'/)?.[1] expect(nonce, `${path} should have a nonce in script-src`).toBeTruthy() expect(csp).not.toContain("script-src 'self' 'unsafe-inline'") const html = await res.text() const scripts = html.match(/]*>/g) ?? [] const unnonced = scripts.filter((s) => !s.includes(`nonce="${nonce}"`)) expect(unnonced, `${path} has script tags without the nonce`).toEqual([]) }) } for (const path of STATIC_PAGES) { test(`${path} keeps the permissive policy so prerendered scripts still run`, async ({ request, }) => { const csp = (await request.get(path)).headers()["content-security-policy"] ?? "" expect(csp).toContain("script-src 'self' 'unsafe-inline'") expect(csp).not.toMatch(/'nonce-/) }) } test("each request gets a fresh nonce", async ({ request }) => { const nonceOf = async () => ((await request.get("/login")).headers()["content-security-policy"] ?? "").match( /'nonce-([A-Za-z0-9+/=]+)'/ )?.[1] const [a, b] = [await nonceOf(), await nonceOf()] expect(a).toBeTruthy() expect(a).not.toBe(b) }) }) test.describe("unauthenticated access control", () => { for (const path of PROTECTED) { test(`${path} redirects an anonymous visitor to /login`, async ({ page }) => { await page.goto(path) await expect(page).toHaveURL(/\/login/) }) } test("the public API rejects a request with no bearer token", async ({ request }) => { for (const path of [ "/api/v1/tenants", "/api/v1/properties", "/api/v1/maintenance", "/api/v1/payments", "/api/v1/webhooks", ]) { const res = await request.get(path) expect(res.status(), `${path} should be 401`).toBe(401) } }) test("cron endpoints reject an unauthenticated caller", async ({ request }) => { for (const path of ["/api/cron/daily", "/api/cron/late-fees", "/api/cron/gdpr"]) { const res = await request.get(path, { failOnStatusCode: false }) expect([401, 403], `${path} should refuse`).toContain(res.status()) } }) }) test.describe("public endpoints", () => { test("health responds without touching the database", async ({ request }) => { const res = await request.get("/api/health") expect(res.status()).toBe(200) expect((await res.json()).status).toBe("ok") }) test("static legal pages render", async ({ page }) => { for (const path of STATIC_PAGES) { const res = await page.goto(path) expect(res?.status(), `${path} should render`).toBe(200) } }) test("an unknown calendar token is not found and is never shared-cached", async ({ request, }) => { // The feed carries tenant names, rent amounts and addresses behind nothing // but the token in the URL, so it must never be stored by a shared cache. const res = await request.get("/api/calendar/definitely-not-a-real-token.ics", { failOnStatusCode: false, }) const cache = res.headers()["cache-control"] ?? "" expect(cache).not.toContain("public") }) }) test.describe("requires a database", () => { // A PRESENTED-but-invalid key must be looked up before it can be rejected, so // unlike the no-token case these cannot short-circuit. Set E2E_DB=1 when // DATABASE_URL points at a migrated database. test.skip( !process.env.E2E_DB, "Set E2E_DB=1 with a migrated DATABASE_URL to run database-backed checks." ) test("the public API rejects a malformed bearer token", async ({ request }) => { const res = await request.get("/api/v1/tenants", { headers: { authorization: "Bearer pmn_live_totally-made-up" }, failOnStatusCode: false, }) expect(res.status()).toBe(401) }) test("session-gated API routes reject an anonymous caller", async ({ request }) => { for (const path of ["/api/properties", "/api/tenants", "/api/rent", "/api/admin/users"]) { const res = await request.get(path, { failOnStatusCode: false }) expect([401, 403], `${path} should refuse`).toContain(res.status()) } }) test("an unknown tenant-portal token 404s rather than leaking", async ({ request }) => { const res = await request.get("/tenant-portal/definitely-not-a-real-token", { failOnStatusCode: false, }) expect(res.status()).toBe(404) }) })