import "./setup-env.js"; import { describe, expect, it, beforeAll, afterAll, vi } from "vitest"; import { eq, inArray } from "drizzle-orm"; import { buildServer } from "../src/server.js"; import { loadConfig } from "../src/config.js"; import { createDatabase, closeDatabase, eraseUser, schema } from "@app/database"; describe("DELETE /v1/me — GDPR-radering", () => { const testDb = createDatabase(process.env.TEST_DATABASE_URL!); const config = loadConfig(); let app: Awaited>; async function cleanupUser(email: string) { const existing = await testDb.db .select({ id: schema.users.id }) .from(schema.users) .where(inArray(schema.users.email, [email, `deleted-${email}`])); for (const u of existing) { const corrections = await testDb.db .select({ id: schema.aiCorrections.id }) .from(schema.aiCorrections) .where(eq(schema.aiCorrections.userId, u.id)); if (corrections.length > 0) { await testDb.db.delete(schema.aiTrainingBank).where( inArray( schema.aiTrainingBank.correctionId, corrections.map((r) => r.id), ), ); } await testDb.db.delete(schema.aiCorrections).where(eq(schema.aiCorrections.userId, u.id)); await testDb.db.delete(schema.scanJobs).where(eq(schema.scanJobs.userId, u.id)); await testDb.db.delete(schema.userConsents).where(eq(schema.userConsents.userId, u.id)); await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id)); await testDb.db.delete(schema.userCredentials).where(eq(schema.userCredentials.userId, u.id)); await testDb.db.delete(schema.refreshTokens).where(eq(schema.refreshTokens.userId, u.id)); await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id)); } } async function registerUser(email: string) { const res = await app.inject({ method: "POST", url: "/v1/auth/register", payload: { email, password: "Password123!", displayName: "GDPR Delete Test" }, }); const body = JSON.parse(res.body) as { accessToken: string }; const token = body.accessToken; const userId = (JSON.parse(atob(token.split(".")[1]!)) as { sub: string }).sub; await app.inject({ method: "POST", url: "/v1/onboarding/quick-start", headers: { authorization: `Bearer ${token}` }, payload: { goals: ["less_waste"], precisionMode: "simple" }, }); return { token, userId }; } beforeAll(async () => { app = await buildServer(config); await app.ready(); }); afterAll(async () => { await closeDatabase(); await app.close(); }); it("raderar ai_corrections, ai_training_bank, scan_jobs och tillhörande S3-bilder", async () => { const email = "gdpr-full-delete@example.invalid"; await cleanupUser(email); const { token, userId } = await registerUser(email); for (const kind of ["personalization", "anonymized_improvement", "image_training"] as const) { await testDb.db .insert(schema.userConsents) .values({ userId, kind, status: "granted" as const }) .onConflictDoUpdate({ target: [schema.userConsents.userId, schema.userConsents.kind], set: { status: "granted" as const }, }); } const [scanJob] = await testDb.db .insert(schema.scanJobs) .values({ userId, scanType: "fridge", jobType: "ANALYZE_FRIDGE_IMAGE", status: "awaiting_confirmation", s3Keys: ["fridge-scans/scan-a.jpg", "fridge-scans/scan-b.jpg"], }) .returning(); const [correction] = await testDb.db .insert(schema.aiCorrections) .values({ scanJobId: scanJob!.id, userId, taskType: "ANALYZE_FRIDGE_IMAGE", aiOutput: { raw: {} }, userCorrection: { action: "accept" }, imageS3Key: "fridge-scans/correction-a.jpg", consentSnapshot: { image_training: "granted", anonymized_improvement: "granted" }, }) .returning(); await testDb.db.insert(schema.aiTrainingBank).values({ correctionId: correction!.id, scanJobId: scanJob!.id, taskType: "ANALYZE_FRIDGE_IMAGE", imageS3Key: "fridge-scans/training-a.jpg", proposal: { detectedName: "Mjölk" }, action: "accept", corrected: { displayName: "Mjölk" }, consentSnapshot: { image_training: "granted", anonymized_improvement: "granted" }, }); const deleteSpy = vi.spyOn(app.storage, "deleteObject").mockResolvedValue(undefined); const res = await app.inject({ method: "DELETE", url: "/v1/me", headers: { authorization: `Bearer ${token}` }, }); expect(res.statusCode).toBe(200); const deletedKeys = deleteSpy.mock.calls.map((c) => c[0]).sort(); expect(deletedKeys).toEqual( [ "fridge-scans/scan-a.jpg", "fridge-scans/scan-b.jpg", "fridge-scans/correction-a.jpg", "fridge-scans/training-a.jpg", ].sort(), ); const remainingCorrections = await testDb.db .select({ id: schema.aiCorrections.id }) .from(schema.aiCorrections) .where(eq(schema.aiCorrections.userId, userId)); expect(remainingCorrections).toHaveLength(0); const remainingBank = await testDb.db .select({ id: schema.aiTrainingBank.id }) .from(schema.aiTrainingBank) .innerJoin( schema.aiCorrections, eq(schema.aiTrainingBank.correctionId, schema.aiCorrections.id), ) .where(eq(schema.aiCorrections.userId, userId)); expect(remainingBank).toHaveLength(0); const remainingScans = await testDb.db .select({ id: schema.scanJobs.id }) .from(schema.scanJobs) .where(eq(schema.scanJobs.userId, userId)); expect(remainingScans).toHaveLength(0); const [user] = await testDb.db .select({ deletedAt: schema.users.deletedAt, email: schema.users.email }) .from(schema.users) .where(eq(schema.users.id, userId)) .limit(1); expect(user?.deletedAt).not.toBeNull(); expect(user?.email).toContain("anonymized.invalid"); deleteSpy.mockRestore(); await cleanupUser(email); }); it("fortsätter raderingen även om lagringen kastar för en bild", async () => { const email = "gdpr-storage-fail@example.invalid"; await cleanupUser(email); const { token, userId } = await registerUser(email); const [scanJob] = await testDb.db .insert(schema.scanJobs) .values({ userId, scanType: "pantry", jobType: "ANALYZE_PANTRY_IMAGE", status: "awaiting_confirmation", s3Keys: ["pantry-scans/fail.jpg"], }) .returning(); const deleteSpy = vi .spyOn(app.storage, "deleteObject") .mockRejectedValueOnce(new Error("S3 nere")) .mockResolvedValue(undefined); const res = await app.inject({ method: "DELETE", url: "/v1/me", headers: { authorization: `Bearer ${token}` }, }); expect(res.statusCode).toBe(200); expect(deleteSpy).toHaveBeenCalledWith("pantry-scans/fail.jpg"); const remainingScans = await testDb.db .select({ id: schema.scanJobs.id }) .from(schema.scanJobs) .where(eq(schema.scanJobs.userId, userId)); expect(remainingScans).toHaveLength(0); deleteSpy.mockRestore(); await cleanupUser(email); }); it("raderar memoryItems och tasteSignals vid DELETE /v1/me", async () => { const email = "gdpr-memory-signals@example.invalid"; await cleanupUser(email); const { token, userId } = await registerUser(email); await testDb.db.insert(schema.memoryItems).values({ userId, kind: "structured_fact", key: "favorite-cuisine-italian", summarySv: "Gillar italienskt", value: { favoriteCuisine: "italian" }, origin: "user_stated", confidence: 1, }); await testDb.db.insert(schema.tasteSignals).values({ userId, axis: "spice", direction: 1, strength: 0.8, origin: "user_stated", }); const res = await app.inject({ method: "DELETE", url: "/v1/me", headers: { authorization: `Bearer ${token}` }, }); expect(res.statusCode).toBe(200); const remainingMemory = await testDb.db .select({ id: schema.memoryItems.id }) .from(schema.memoryItems) .where(eq(schema.memoryItems.userId, userId)); expect(remainingMemory).toHaveLength(0); const remainingSignals = await testDb.db .select({ id: schema.tasteSignals.id }) .from(schema.tasteSignals) .where(eq(schema.tasteSignals.userId, userId)); expect(remainingSignals).toHaveLength(0); await cleanupUser(email); }); it("eraseUser raderar memoryItems och tasteSignals (GDPR-regression)", async () => { const email = "gdpr-erase-user-memory@example.invalid"; await cleanupUser(email); const { userId } = await registerUser(email); await testDb.db.insert(schema.memoryItems).values({ userId, kind: "structured_fact", key: "dislikes-broccoli", summarySv: "Ogillar broccoli", value: { dislikedIngredient: "broccoli" }, origin: "user_stated", confidence: 1, }); await testDb.db.insert(schema.tasteSignals).values({ userId, axis: "garlic", direction: -1, strength: 0.9, origin: "user_stated", }); await eraseUser(testDb.db, userId); const remainingMemory = await testDb.db .select({ id: schema.memoryItems.id }) .from(schema.memoryItems) .where(eq(schema.memoryItems.userId, userId)); expect(remainingMemory).toHaveLength(0); const remainingSignals = await testDb.db .select({ id: schema.tasteSignals.id }) .from(schema.tasteSignals) .where(eq(schema.tasteSignals.userId, userId)); expect(remainingSignals).toHaveLength(0); await cleanupUser(email); }); });