datewise/app/lib/__tests__/pii.server.test.ts
metatroncubeswdev 1d2d943d54 feat(security): encrypt customer personal data at rest
- Prisma extension seals booking name/email/phone/address and waitlist contacts with
  AES-256-GCM on write and opens them on read (incl. nested reads)
- keyed-hash columns for exact email/phone lookups (privacy webhooks, booking search,
  waitlist de-duplication); migration 0002
- tests prove only ciphertext is stored; privacy/terms/SECURITY.md updated to match

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:56:05 -04:00

92 lines
4.8 KiB
TypeScript

/**
* 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"]);
});
});