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