Files
Leon SerfatyandClaude Fable 5 5a555c715e Build GDPR compliance system: data export, account deletion, consent
- Data export (Art. 15/20): GET /api/gdpr/export serves a full JSON export
  of the user's data (credentials/tokens excluded, exclusions declared)
- Right to erasure (Art. 17): self-service deletion with 30-day grace
  period (Settings -> Privacy & Data), cancellable; daily /api/cron/gdpr
  drain cancels Stripe billing, purges Spaces files, cascade-deletes the
  account, anonymizes consent rows, and writes audit evidence
- Migration 0011: account_deletion_requests (partial unique index = one
  pending per user) + FK-less consent_log (survives erasure)
- Consent: terms/privacy acceptance logged at signup (email + Google);
  cookie banner with analytics opt-out (umami.disabled), choices logged
  server-side for signed-in users via POST /api/gdpr/consent
- Admin deleteUser upgraded to the same full purge (was leaving Spaces
  files and Stripe subscriptions orphaned)
- /gdpr legal page now points at the self-service tools
- scripts/verify-gdpr.ts: end-to-end verification vs live dev DB (22/22)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 06:03:27 -04:00

131 lines
7.3 KiB
TypeScript

/**
* Exercises the REAL GDPR data-export and account-deletion paths against the
* live (dev) DB to prove the system works end-to-end: seeds a throwaway user
* with portfolio data + a stored file, exports, then runs the deletion drain
* and asserts everything is gone (and the compliance evidence remains).
* Run: npx tsx scripts/verify-gdpr.ts
*/
import { config } from "dotenv"
config({ path: ".env.local" })
process.env.DATABASE_SSL = process.env.DATABASE_SSL ?? "disable"
const TEST_USER_ID = "gdpr-verify-user"
const TEST_EMAIL = "gdpr-verify@example.com"
let failures = 0
function check(label: string, ok: boolean, detail?: string) {
console.log(` ${ok ? "✓" : "✗"} ${label}${!ok && detail ? ` — ${detail}` : ""}`)
if (!ok) failures++
}
async function main() {
const { db, pool } = await import("../lib/db")
const s = await import("../lib/db/schema")
const { eq, and } = await import("drizzle-orm")
const storage = await import("../lib/storage")
const { buildUserDataExport } = await import("../lib/gdpr/export")
const { processDueDeletions } = await import("../lib/gdpr/delete")
// ── cleanup any prior run ──────────────────────────────────────────────────
await db.delete(s.user).where(eq(s.user.id, TEST_USER_ID))
await db.delete(s.consent_log).where(eq(s.consent_log.user_id, TEST_USER_ID))
await db.delete(s.account_deletion_requests).where(eq(s.account_deletion_requests.user_id, TEST_USER_ID))
await db.delete(s.admin_audit_log).where(eq(s.admin_audit_log.target_user_id, TEST_USER_ID))
// ── seed ───────────────────────────────────────────────────────────────────
console.log("── Seeding throwaway user + portfolio ───")
await db.insert(s.user).values({ id: TEST_USER_ID, name: "GDPR Verify", email: TEST_EMAIL })
await db.insert(s.profiles).values({ id: TEST_USER_ID, email: TEST_EMAIL, full_name: "GDPR Verify" })
const [prop] = await db
.insert(s.properties)
.values({ user_id: TEST_USER_ID, name: "Test House", address_line1: "1 Test St", city: "Testville" })
.returning()
const [tenant] = await db
.insert(s.tenants)
.values({ user_id: TEST_USER_ID, property_id: prop.id, first_name: "Tina", last_name: "Tenant", email: "tina@example.com" })
.returning()
await db.insert(s.rent_payments).values({
user_id: TEST_USER_ID, tenant_id: tenant.id, property_id: prop.id, amount: 1200, due_date: "2026-07-01",
})
await db.insert(s.api_keys).values({
user_id: TEST_USER_ID, name: "test key", key_hash: `hash-${Date.now()}`, key_prefix: "pmn_test",
})
await db.insert(s.consent_log).values([
{ user_id: TEST_USER_ID, email: TEST_EMAIL, kind: "terms", granted: true, source: "signup" },
{ user_id: TEST_USER_ID, email: TEST_EMAIL, kind: "privacy", granted: true, source: "signup" },
])
const { key: fileKey } = await storage.saveBuffer(Buffer.from("gdpr verify payload"), {
userId: TEST_USER_ID, scope: "verify", ext: "txt",
})
console.log(` seeded property=${prop.id.slice(0, 8)} tenant=${tenant.id.slice(0, 8)} file=${fileKey}`)
// ── export ─────────────────────────────────────────────────────────────────
console.log("── Data export (Articles 15/20) ─────────")
const exp = await buildUserDataExport(TEST_USER_ID)
check("subject user present", exp.data_subject.user?.email === TEST_EMAIL)
check("profile present", exp.data_subject.profile?.id === TEST_USER_ID)
check("property exported", exp.portfolio.properties.length === 1)
check("tenant exported", exp.portfolio.tenants.length === 1)
check("payment exported", exp.portfolio.rent_payments.length === 1)
check("consent history exported", exp.privacy.consent_log.length === 2)
check("api key exported WITHOUT hash",
exp.automation.api_keys.length === 1 && !("key_hash" in exp.automation.api_keys[0]))
const serialized = JSON.stringify(exp)
check("no key_hash anywhere in export", !serialized.includes("key_hash"))
check("no password field anywhere in export", !serialized.includes('"password"'))
// ── deletion drain ─────────────────────────────────────────────────────────
console.log("── Deletion (Article 17) ────────────────")
await db.insert(s.account_deletion_requests).values({
user_id: TEST_USER_ID,
email: null, // null → no completion email attempt from the drain
scheduled_for: new Date(Date.now() - 60_000).toISOString(), // due 1 min ago
})
const { processed, deleted } = await processDueDeletions(50)
check("drain picked up the request", processed >= 1)
check("drain deleted the account", deleted >= 1)
const userAfter = await db.query.user.findFirst({ where: eq(s.user.id, TEST_USER_ID) })
check("user row deleted", !userAfter)
const profileAfter = await db.query.profiles.findFirst({ where: eq(s.profiles.id, TEST_USER_ID) })
check("profile cascaded", !profileAfter)
const propsAfter = await db.query.properties.findMany({ where: eq(s.properties.user_id, TEST_USER_ID) })
check("properties cascaded", propsAfter.length === 0)
const tenantsAfter = await db.query.tenants.findMany({ where: eq(s.tenants.user_id, TEST_USER_ID) })
check("tenants cascaded", tenantsAfter.length === 0)
const keysAfter = await db.query.api_keys.findMany({ where: eq(s.api_keys.user_id, TEST_USER_ID) })
check("api keys cascaded", keysAfter.length === 0)
const request = await db.query.account_deletion_requests.findFirst({
where: eq(s.account_deletion_requests.user_id, TEST_USER_ID),
})
check("request marked completed", request?.status === "completed")
check("request email nulled", request?.email === null)
const consents = await db.query.consent_log.findMany({ where: eq(s.consent_log.user_id, TEST_USER_ID) })
check("consent facts retained", consents.length === 2)
check("consent PII anonymized", consents.every((c) => c.email === null && c.ip_address === null))
const audit = await db.query.admin_audit_log.findFirst({
where: and(eq(s.admin_audit_log.target_user_id, TEST_USER_ID), eq(s.admin_audit_log.action, "gdpr_delete_account")),
})
check("audit evidence written (system action, admin_id null)", !!audit && audit.admin_id === null)
const bytesAfter = await storage.getUserStorageBytes(TEST_USER_ID)
check("stored files purged", bytesAfter === 0, `${bytesAfter} bytes remain`)
// ── cleanup compliance rows from the test run ──────────────────────────────
await db.delete(s.consent_log).where(eq(s.consent_log.user_id, TEST_USER_ID))
await db.delete(s.account_deletion_requests).where(eq(s.account_deletion_requests.user_id, TEST_USER_ID))
await db.delete(s.admin_audit_log).where(eq(s.admin_audit_log.target_user_id, TEST_USER_ID))
console.log(failures === 0 ? "\nALL CHECKS PASSED ✓" : `\n${failures} CHECK(S) FAILED ✗`)
await pool.end()
process.exit(failures === 0 ? 0 : 1)
}
main().catch((e) => {
console.error(e)
process.exit(1)
})