131 lines
7.3 KiB
TypeScript
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)
|
||
|
|
})
|