Snapshot of uncommitted work that had accumulated in the tree alongside the Turnstile changes: - marketing pages, SEO helpers (lib/seo.ts, lib/marketing/) and structured data - admin billing actions and a per-user portfolio view, plus an admin error boundary - rate limiting (lib/rate-limit.ts) applied across the /api/v1 surface - CSP and proxy adjustments, accounting/webhook lib updates - Playwright config and an e2e/unit test suite - next bumped to ^16.3.4 with the lockfile regenerated - generated AGENTS.md / CLAUDE.md Authored by other sessions working in this tree; committed here so the Turnstile work could be pushed without leaving the tree dirty. Typecheck passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
175 lines
6.8 KiB
TypeScript
175 lines
6.8 KiB
TypeScript
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(/<script[^>]*>/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)
|
|
})
|
|
})
|