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>
137 lines
5.3 KiB
TypeScript
137 lines
5.3 KiB
TypeScript
import { test, expect } from "@playwright/test"
|
|
import { generateApiKey, hashApiKey } from "@/lib/api-auth"
|
|
import { encrypt, decrypt } from "@/lib/crypto"
|
|
import { checkLimit, getPlanLabel, PLAN_LIMITS } from "@/lib/stripe/plans"
|
|
import type { Plan } from "@/types"
|
|
|
|
// A dedicated key so the crypto tests never depend on BETTER_AUTH_SECRET being
|
|
// set. lib/crypto reads the env lazily inside getKey() rather than at module
|
|
// load, so this assignment lands before the first encrypt() call even though
|
|
// the import above is hoisted.
|
|
process.env.ACCOUNTING_ENCRYPTION_KEY = "test-encryption-key-do-not-use-in-production"
|
|
|
|
test.describe("API keys", () => {
|
|
test("issues a prefixed key with 48 hex characters of entropy", () => {
|
|
const { plaintext } = generateApiKey()
|
|
expect(plaintext).toMatch(/^pmn_live_[0-9a-f]{48}$/)
|
|
})
|
|
|
|
test("never repeats a key", () => {
|
|
const keys = new Set(Array.from({ length: 200 }, () => generateApiKey().plaintext))
|
|
expect(keys.size).toBe(200)
|
|
})
|
|
|
|
test("stores only a hash — the plaintext must not be recoverable from it", () => {
|
|
const { plaintext, hash } = generateApiKey()
|
|
expect(hash).toMatch(/^[0-9a-f]{64}$/) // sha256 hex
|
|
expect(hash).not.toContain(plaintext)
|
|
expect(plaintext).not.toContain(hash)
|
|
})
|
|
|
|
test("hashing is deterministic, so lookup by hash works", () => {
|
|
const { plaintext, hash } = generateApiKey()
|
|
expect(hashApiKey(plaintext)).toBe(hash)
|
|
expect(hashApiKey(plaintext)).toBe(hashApiKey(plaintext))
|
|
})
|
|
|
|
test("different keys hash differently", () => {
|
|
expect(hashApiKey("pmn_live_aaa")).not.toBe(hashApiKey("pmn_live_aab"))
|
|
})
|
|
|
|
test("the display prefix reveals only a short, non-secret fragment", () => {
|
|
const { plaintext, prefix } = generateApiKey()
|
|
expect(prefix.startsWith("pmn_live_")).toBe(true)
|
|
// 8 hex chars shown out of 48 — the rest must not leak.
|
|
const shown = prefix.replace("pmn_live_", "").replace("…", "")
|
|
expect(shown).toHaveLength(8)
|
|
expect(plaintext).toContain(shown)
|
|
expect(prefix.length).toBeLessThan(plaintext.length)
|
|
})
|
|
})
|
|
|
|
test.describe("secrets at rest (AES-256-GCM)", () => {
|
|
test("round-trips a value", async () => {
|
|
const secret = "oauth-refresh-token-abc123"
|
|
expect(decrypt(encrypt(secret))).toBe(secret)
|
|
})
|
|
|
|
test("round-trips unicode and empty strings", async () => {
|
|
for (const v of ["", "ünïcödé ✓ 日本語", "a".repeat(5000)]) {
|
|
expect(decrypt(encrypt(v))).toBe(v)
|
|
}
|
|
})
|
|
|
|
test("produces a different ciphertext each time (random IV)", async () => {
|
|
const a = encrypt("same-value")
|
|
const b = encrypt("same-value")
|
|
expect(a).not.toBe(b)
|
|
})
|
|
|
|
test("emits the documented iv:tag:ciphertext shape", async () => {
|
|
const parts = encrypt("x").split(":")
|
|
expect(parts).toHaveLength(3)
|
|
for (const p of parts) expect(p.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
test("rejects a tampered ciphertext instead of returning garbage", async () => {
|
|
const [iv, tag, data] = encrypt("sensitive").split(":")
|
|
|
|
// Flip a byte in the payload — GCM's auth tag must catch it.
|
|
const buf = Buffer.from(data, "base64")
|
|
buf[0] ^= 0xff
|
|
expect(() => decrypt([iv, tag, buf.toString("base64")].join(":"))).toThrow()
|
|
|
|
// A forged auth tag must also fail.
|
|
const tagBuf = Buffer.from(tag, "base64")
|
|
tagBuf[0] ^= 0xff
|
|
expect(() => decrypt([iv, tagBuf.toString("base64"), data].join(":"))).toThrow()
|
|
})
|
|
})
|
|
|
|
test.describe("plan limits", () => {
|
|
test("Starter is capped at 1 property and 3 tenants", () => {
|
|
expect(checkLimit("starter", "maxProperties", 0)).toBe(true)
|
|
expect(checkLimit("starter", "maxProperties", 1)).toBe(false)
|
|
expect(checkLimit("starter", "maxTenants", 2)).toBe(true)
|
|
expect(checkLimit("starter", "maxTenants", 3)).toBe(false)
|
|
})
|
|
|
|
test("Pro allows 10 properties", () => {
|
|
expect(checkLimit("pro", "maxProperties", 9)).toBe(true)
|
|
expect(checkLimit("pro", "maxProperties", 10)).toBe(false)
|
|
})
|
|
|
|
test("unlimited plans never cap", () => {
|
|
for (const plan of ["landlord", "lifetime"] as Plan[]) {
|
|
expect(checkLimit(plan, "maxProperties", 10_000)).toBe(true)
|
|
expect(checkLimit(plan, "maxTenants", 10_000)).toBe(true)
|
|
}
|
|
})
|
|
|
|
test("boolean entitlements are returned directly", () => {
|
|
expect(checkLimit("starter", "hasTeamAccess", 0)).toBe(false)
|
|
expect(checkLimit("landlord", "hasTeamAccess", 0)).toBe(true)
|
|
expect(checkLimit("starter", "hasWhiteLabel", 0)).toBe(false)
|
|
expect(checkLimit("lifetime", "hasWhiteLabel", 0)).toBe(true)
|
|
})
|
|
|
|
test("AI is gated off on Starter", () => {
|
|
expect(PLAN_LIMITS.starter.maxAiCalls).toBe(0)
|
|
expect(PLAN_LIMITS.pro.maxAiCalls).toBeGreaterThan(0)
|
|
})
|
|
|
|
test("every plan has a display label", () => {
|
|
for (const plan of ["starter", "pro", "landlord", "lifetime"] as Plan[]) {
|
|
expect(getPlanLabel(plan)).toBeTruthy()
|
|
}
|
|
})
|
|
|
|
test("limits are monotonic as plans get more expensive", () => {
|
|
// A cheaper plan must never allow more than a pricier one.
|
|
expect(PLAN_LIMITS.pro.maxProperties).toBeGreaterThan(PLAN_LIMITS.starter.maxProperties)
|
|
expect(PLAN_LIMITS.landlord.maxProperties).toBeGreaterThan(PLAN_LIMITS.pro.maxProperties)
|
|
expect(PLAN_LIMITS.pro.maxStorageMB).toBeGreaterThan(PLAN_LIMITS.starter.maxStorageMB)
|
|
expect(PLAN_LIMITS.landlord.maxStorageMB).toBeGreaterThan(PLAN_LIMITS.pro.maxStorageMB)
|
|
})
|
|
})
|