/** * Customer personal data is encrypted at rest. * Crypto unit tests always run; the database tests need TEST_DATABASE_URL (migrations applied). */ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; import type { PrismaClient } from "@prisma/client"; import { createPrisma } from "../../db.server"; import { blindIndex, seal, unseal } from "../../services/crypto.server"; describe("crypto helpers", () => { it("seals and unseals, and reads legacy plain text unchanged", () => { const s = seal("Priya Raman")!; expect(s.startsWith("enc1:")).toBe(true); expect(s).not.toContain("Priya"); expect(unseal(s)).toBe("Priya Raman"); expect(unseal("plain old value")).toBe("plain old value"); expect(seal(null)).toBeNull(); expect(seal(s)).toBe(s); // never double-encrypts }); it("blind indexes normalise and differ by kind", () => { expect(blindIndex(" Priya@Example.com ", "email")).toBe(blindIndex("priya@example.com", "email")); expect(blindIndex("+1 (519) 555-0101", "phone")).toBe(blindIndex("+15195550101", "phone")); expect(blindIndex("x", "email")).not.toBe(blindIndex("x", "phone")); expect(blindIndex("", "email")).toBeNull(); }); }); const url = process.env.TEST_DATABASE_URL; const d = url ? describe : describe.skip; d("PII encryption at rest (Postgres)", () => { let db: PrismaClient; let shopId: string; const base = { method: "delivery", status: "confirmed", timezone: "America/Toronto", configVersion: 1, snapshot: {} }; const pii = { customerName: "Priya Raman", customerEmail: "Priya@Example.com", customerPhone: "+1 519 555 0101", address: { line1: "12 Guelph St", city: "Kitchener", postal: "N2H 5Z6" }, }; beforeAll(() => { db = createPrisma(url); }); afterAll(async () => db.$disconnect()); beforeEach(async () => { await db.shop.deleteMany({ where: { domain: "pii.myshopify.com" } }); shopId = (await db.shop.create({ data: { domain: "pii.myshopify.com", settings: {} } })).id; }); it("stores only ciphertext and reads back plain values", async () => { const b = await db.booking.create({ data: { shopId, orderId: "o1", ...base, ...pii } }); expect(b.customerName).toBe("Priya Raman"); expect(b.address).toEqual(pii.address); const [raw] = await db.$queryRaw<{ customerName: string; customerEmail: string; customerPhone: string; address: unknown }[]>` SELECT "customerName", "customerEmail", "customerPhone", "address" FROM "Booking" WHERE id = ${b.id}`; const dump = JSON.stringify(raw); for (const secret of ["Priya", "Example.com", "555", "Guelph", "N2H"]) expect(dump).not.toContain(secret); expect(raw.customerEmail.startsWith("enc1:")).toBe(true); const again = await db.booking.findUniqueOrThrow({ where: { id: b.id } }); expect([again.customerName, again.customerEmail, again.customerPhone]).toEqual([pii.customerName, pii.customerEmail, pii.customerPhone]); }); it("decrypts nested reads (reminder job → booking)", async () => { const b = await db.booking.create({ data: { shopId, orderId: "o2", ...base, ...pii } }); await db.reminderJob.create({ data: { shopId, bookingId: b.id, policyId: "p", channel: "email", sendAt: new Date(), body: "hi" } }); const j = await db.reminderJob.findFirstOrThrow({ where: { bookingId: b.id }, include: { booking: true } }); expect(j.booking.customerEmail).toBe(pii.customerEmail); }); it("finds by email/phone hash and redacts everything", async () => { await db.booking.create({ data: { shopId, orderId: "o3", ...base, ...pii } }); const hash = blindIndex("priya@example.com", "email")!; expect(await db.booking.count({ where: { shopId, customerEmailHash: hash } })).toBe(1); expect(await db.booking.count({ where: { shopId, customerPhoneHash: blindIndex("+15195550101", "phone")! } })).toBe(1); await db.booking.updateMany({ where: { shopId, customerEmailHash: hash }, data: { customerName: null, customerEmail: null, customerPhone: null, customerEmailHash: null, customerPhoneHash: null }, }); const r = await db.booking.findFirstOrThrow({ where: { shopId, orderId: "o3" } }); expect([r.customerName, r.customerEmail, r.customerPhone, r.customerEmailHash]).toEqual([null, null, null, null]); }); it("encrypts waitlist contacts and matches them by hash", async () => { await db.waitlistEntry.create({ data: { shopId, locationId: "kit", method: "delivery", date: "2026-11-07", slot: "*", email: "a@b.co", name: "Anu" } }); const [raw] = await db.$queryRaw<{ email: string; name: string }[]>`SELECT email, name FROM "WaitlistEntry" WHERE "shopId" = ${shopId}`; expect(raw.email).not.toContain("a@b.co"); const w = await db.waitlistEntry.findFirstOrThrow({ where: { shopId, emailHash: blindIndex("A@B.co", "email")! } }); expect([w.email, w.name]).toEqual(["a@b.co", "Anu"]); }); });