- 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>
92 lines
4.8 KiB
TypeScript
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"]);
|
|
});
|
|
});
|