Files
property-management-network/tests/unit/secrets.spec.ts
T
Leon SerfatyandClaude Opus 5 1d02598786 chore: sync in-progress work across marketing, admin, API and tests
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>
2026-09-05 16:27:16 -04:00

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)
})
})