|
| 1 | +/** |
| 2 | + * Integration tests for GET /api/account/exports/[id]/download — GDPR export download |
| 3 | + * |
| 4 | + * Tests: |
| 5 | + * - 401 for unauthenticated requests |
| 6 | + * - 404 for non-existent export |
| 7 | + * - 400 for incomplete export |
| 8 | + * - 200 with JSON attachment for completed export |
| 9 | + * - Ownership check: user cannot download another user's export |
| 10 | + * |
| 11 | + * Constitution II (Test-First), III (Privacy & Data Protection) |
| 12 | + */ |
| 13 | + |
| 14 | +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; |
| 15 | +import { PGlite } from "@electric-sql/pglite"; |
| 16 | +import { setTestDb, clearTestDb } from "@/lib/db/client"; |
| 17 | +import fs from "fs"; |
| 18 | +import path from "path"; |
| 19 | + |
| 20 | +// Mock getServerSession |
| 21 | +vi.mock("@/lib/auth/session", () => ({ |
| 22 | + getServerSession: vi.fn(), |
| 23 | +})); |
| 24 | + |
| 25 | +// Mock GDPR export to avoid cross-module complications |
| 26 | +vi.mock("@/lib/gdpr/export", () => ({ |
| 27 | + exportUserData: vi.fn().mockResolvedValue({}), |
| 28 | +})); |
| 29 | + |
| 30 | +import { getServerSession } from "@/lib/auth/session"; |
| 31 | +const mockGetServerSession = vi.mocked(getServerSession); |
| 32 | + |
| 33 | +let pg: PGlite; |
| 34 | + |
| 35 | +async function applyMigrations(d: PGlite) { |
| 36 | + const migrationsDir = path.resolve(__dirname, "../../../src/db/migrations"); |
| 37 | + const files = fs |
| 38 | + .readdirSync(migrationsDir) |
| 39 | + .filter((f) => f.endsWith(".sql")) |
| 40 | + .sort(); |
| 41 | + for (const file of files) { |
| 42 | + const sql = fs.readFileSync(path.join(migrationsDir, file), "utf-8"); |
| 43 | + await d.exec(sql); |
| 44 | + } |
| 45 | +} |
| 46 | + |
| 47 | +async function createUser(d: PGlite, email: string): Promise<string> { |
| 48 | + const result = await d.query<{ id: string }>( |
| 49 | + "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id", |
| 50 | + [email, email.split("@")[0]], |
| 51 | + ); |
| 52 | + return result.rows[0].id; |
| 53 | +} |
| 54 | + |
| 55 | +describe("GET /api/account/exports/[id]/download", () => { |
| 56 | + let userId: string; |
| 57 | + let otherUserId: string; |
| 58 | + |
| 59 | + beforeEach(async () => { |
| 60 | + pg = new PGlite(); |
| 61 | + await applyMigrations(pg); |
| 62 | + setTestDb(pg); |
| 63 | + |
| 64 | + userId = await createUser(pg, "user@test.com"); |
| 65 | + otherUserId = await createUser(pg, "other@test.com"); |
| 66 | + }); |
| 67 | + |
| 68 | + afterEach(async () => { |
| 69 | + clearTestDb(); |
| 70 | + vi.resetAllMocks(); |
| 71 | + await pg.close(); |
| 72 | + }); |
| 73 | + |
| 74 | + async function callDownload( |
| 75 | + exportId: string, |
| 76 | + sessionOverride?: { userId: string } | null, |
| 77 | + ) { |
| 78 | + if (sessionOverride === null) { |
| 79 | + mockGetServerSession.mockResolvedValue(null); |
| 80 | + } else if (sessionOverride) { |
| 81 | + mockGetServerSession.mockResolvedValue(sessionOverride); |
| 82 | + } |
| 83 | + |
| 84 | + const { GET } = await import( |
| 85 | + "@/app/api/account/exports/[id]/download/route" |
| 86 | + ); |
| 87 | + const { NextRequest } = await import("next/server"); |
| 88 | + const request = new NextRequest( |
| 89 | + `http://localhost/api/account/exports/${exportId}/download`, |
| 90 | + ); |
| 91 | + return GET(request, { params: Promise.resolve({ id: exportId }) }); |
| 92 | + } |
| 93 | + |
| 94 | + it("returns 401 for unauthenticated request", async () => { |
| 95 | + const response = await callDownload("some-id", null); |
| 96 | + expect(response.status).toBe(401); |
| 97 | + }); |
| 98 | + |
| 99 | + it("returns 404 for non-existent export", async () => { |
| 100 | + const response = await callDownload( |
| 101 | + "00000000-0000-0000-0000-000000000000", |
| 102 | + { userId }, |
| 103 | + ); |
| 104 | + expect(response.status).toBe(404); |
| 105 | + |
| 106 | + const body = await response.json(); |
| 107 | + expect(body.error).toContain("not found"); |
| 108 | + }); |
| 109 | + |
| 110 | + it("returns 404 when accessing another user's export (ownership check)", async () => { |
| 111 | + // Create export for userId |
| 112 | + const exportResult = await pg.query<{ id: string }>( |
| 113 | + "INSERT INTO data_exports (user_id, status) VALUES ($1, 'completed') RETURNING id", |
| 114 | + [userId], |
| 115 | + ); |
| 116 | + const exportId = exportResult.rows[0].id; |
| 117 | + |
| 118 | + // Try to access as otherUser |
| 119 | + const response = await callDownload(exportId, { userId: otherUserId }); |
| 120 | + expect(response.status).toBe(404); |
| 121 | + }); |
| 122 | + |
| 123 | + it("returns 400 when export is not yet completed", async () => { |
| 124 | + const exportResult = await pg.query<{ id: string }>( |
| 125 | + "INSERT INTO data_exports (user_id, status) VALUES ($1, 'pending') RETURNING id", |
| 126 | + [userId], |
| 127 | + ); |
| 128 | + const exportId = exportResult.rows[0].id; |
| 129 | + |
| 130 | + const response = await callDownload(exportId, { userId }); |
| 131 | + expect(response.status).toBe(400); |
| 132 | + |
| 133 | + const body = await response.json(); |
| 134 | + expect(body.error).toContain("not yet completed"); |
| 135 | + }); |
| 136 | + |
| 137 | + it("returns 200 with JSON attachment for completed export", async () => { |
| 138 | + const exportResult = await pg.query<{ id: string }>( |
| 139 | + "INSERT INTO data_exports (user_id, status) VALUES ($1, 'completed') RETURNING id", |
| 140 | + [userId], |
| 141 | + ); |
| 142 | + const exportId = exportResult.rows[0].id; |
| 143 | + |
| 144 | + const response = await callDownload(exportId, { userId }); |
| 145 | + expect(response.status).toBe(200); |
| 146 | + |
| 147 | + expect(response.headers.get("Content-Type")).toBe("application/json"); |
| 148 | + expect(response.headers.get("Content-Disposition")).toContain( |
| 149 | + `data-export-${exportId}.json`, |
| 150 | + ); |
| 151 | + |
| 152 | + const text = await response.text(); |
| 153 | + const data = JSON.parse(text); |
| 154 | + // Should be a valid export schema with expected keys |
| 155 | + expect(data).toHaveProperty("rsvps"); |
| 156 | + expect(data).toHaveProperty("follows"); |
| 157 | + expect(data).toHaveProperty("blocks"); |
| 158 | + expect(data).toHaveProperty("mutes"); |
| 159 | + }); |
| 160 | +}); |
0 commit comments