diff --git a/app/api/sandboxes/route.ts b/app/api/sandboxes/route.ts index ba07ce7f..7b3152d1 100644 --- a/app/api/sandboxes/route.ts +++ b/app/api/sandboxes/route.ts @@ -2,6 +2,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { createSandboxPostHandler } from "@/lib/sandbox/createSandboxPostHandler"; +import { deleteSandboxHandler } from "@/lib/sandbox/deleteSandboxHandler"; import { getSandboxesHandler } from "@/lib/sandbox/getSandboxesHandler"; import { updateSnapshotPatchHandler } from "@/lib/sandbox/updateSnapshotPatchHandler"; @@ -100,3 +101,31 @@ export async function GET(request: NextRequest): Promise { export async function PATCH(request: NextRequest): Promise { return updateSnapshotPatchHandler(request); } + +/** + * DELETE /api/sandboxes + * + * Deletes the GitHub repository and snapshot record for an account. + * For personal API keys, deletes the sandbox for the key owner's account. + * Organization API keys may specify account_id to target any account + * within their organization. + * + * Authentication: x-api-key header or Authorization Bearer token required. + * + * Request body: + * - account_id: string (optional) - UUID of the account to delete for (org keys only) + * + * Response (200): + * - status: "success" + * - deleted_snapshot: { account_id, snapshot_id, expires_at, github_repo, created_at } | null + * + * Error (400/401/403/500): + * - status: "error" + * - error: string + * + * @param request - The request object + * @returns A NextResponse with the deletion result or error + */ +export async function DELETE(request: NextRequest): Promise { + return deleteSandboxHandler(request); +} diff --git a/lib/github/__tests__/deleteAccountGithubRepos.test.ts b/lib/github/__tests__/deleteAccountGithubRepos.test.ts new file mode 100644 index 00000000..39af4ac1 --- /dev/null +++ b/lib/github/__tests__/deleteAccountGithubRepos.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/github/deleteGithubRepo", () => ({ + deleteGithubRepo: vi.fn(), +})); + +vi.mock("@/lib/github/findOrgReposByAccountId", () => ({ + findOrgReposByAccountId: vi.fn(), +})); + +import { deleteAccountGithubRepos } from "../deleteAccountGithubRepos"; +import { deleteGithubRepo } from "../deleteGithubRepo"; +import { findOrgReposByAccountId } from "../findOrgReposByAccountId"; + +describe("deleteAccountGithubRepos", () => { + const mockAccountId = "550e8400-e29b-41d4-a716-446655440000"; + const mockRepoUrl = "https://github.com/recoupable/test-repo"; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns true when no repos to delete", async () => { + vi.mocked(findOrgReposByAccountId).mockResolvedValue([]); + + const result = await deleteAccountGithubRepos(mockAccountId, null); + + expect(result).toBe(true); + expect(deleteGithubRepo).not.toHaveBeenCalled(); + }); + + it("deletes repo from snapshot github_repo", async () => { + vi.mocked(findOrgReposByAccountId).mockResolvedValue([]); + vi.mocked(deleteGithubRepo).mockResolvedValue(true); + + const result = await deleteAccountGithubRepos(mockAccountId, mockRepoUrl); + + expect(result).toBe(true); + expect(deleteGithubRepo).toHaveBeenCalledWith(mockRepoUrl); + }); + + it("deletes repos found by org search", async () => { + const orgRepoUrl = "https://github.com/recoupable/artist-550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(findOrgReposByAccountId).mockResolvedValue([orgRepoUrl]); + vi.mocked(deleteGithubRepo).mockResolvedValue(true); + + const result = await deleteAccountGithubRepos(mockAccountId, null); + + expect(result).toBe(true); + expect(deleteGithubRepo).toHaveBeenCalledWith(orgRepoUrl); + }); + + it("deduplicates repos from snapshot and org search", async () => { + vi.mocked(findOrgReposByAccountId).mockResolvedValue([mockRepoUrl]); + vi.mocked(deleteGithubRepo).mockResolvedValue(true); + + const result = await deleteAccountGithubRepos(mockAccountId, mockRepoUrl); + + expect(result).toBe(true); + expect(deleteGithubRepo).toHaveBeenCalledTimes(1); + }); + + it("returns false when any repo deletion fails", async () => { + vi.mocked(findOrgReposByAccountId).mockResolvedValue([]); + vi.mocked(deleteGithubRepo).mockResolvedValue(false); + + const result = await deleteAccountGithubRepos(mockAccountId, mockRepoUrl); + + expect(result).toBe(false); + }); +}); diff --git a/lib/github/__tests__/deleteGithubRepo.test.ts b/lib/github/__tests__/deleteGithubRepo.test.ts new file mode 100644 index 00000000..a0ceb6e5 --- /dev/null +++ b/lib/github/__tests__/deleteGithubRepo.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { deleteGithubRepo } from "../deleteGithubRepo"; +import { parseGitHubRepoUrl } from "../parseGitHubRepoUrl"; + +vi.mock("@/lib/github/parseGitHubRepoUrl", () => ({ + parseGitHubRepoUrl: vi.fn(), +})); + +describe("deleteGithubRepo", () => { + const mockRepoUrl = "https://github.com/recoupable/test-repo"; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("GITHUB_TOKEN", "test-token"); + global.fetch = vi.fn(); + }); + + it("returns true when repo is deleted successfully", async () => { + vi.mocked(parseGitHubRepoUrl).mockReturnValue({ + owner: "recoupable", + repo: "test-repo", + }); + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + status: 204, + } as Response); + + const result = await deleteGithubRepo(mockRepoUrl); + + expect(result).toBe(true); + expect(global.fetch).toHaveBeenCalledWith( + "https://api.github.com/repos/recoupable/test-repo", + expect.objectContaining({ + method: "DELETE", + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + }), + }), + ); + }); + + it("returns false when GITHUB_TOKEN is not set", async () => { + vi.stubEnv("GITHUB_TOKEN", ""); + + const result = await deleteGithubRepo(mockRepoUrl); + + expect(result).toBe(false); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("returns false when URL cannot be parsed", async () => { + vi.mocked(parseGitHubRepoUrl).mockReturnValue(null); + + const result = await deleteGithubRepo("not-a-valid-url"); + + expect(result).toBe(false); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("returns true when repo does not exist (404)", async () => { + vi.mocked(parseGitHubRepoUrl).mockReturnValue({ + owner: "recoupable", + repo: "test-repo", + }); + vi.mocked(global.fetch).mockResolvedValue({ + ok: false, + status: 404, + } as Response); + + const result = await deleteGithubRepo(mockRepoUrl); + + expect(result).toBe(true); + }); + + it("returns false when GitHub API returns a non-404 error", async () => { + vi.mocked(parseGitHubRepoUrl).mockReturnValue({ + owner: "recoupable", + repo: "test-repo", + }); + vi.mocked(global.fetch).mockResolvedValue({ + ok: false, + status: 403, + } as Response); + + const result = await deleteGithubRepo(mockRepoUrl); + + expect(result).toBe(false); + }); + + it("returns false when fetch throws an error", async () => { + vi.mocked(parseGitHubRepoUrl).mockReturnValue({ + owner: "recoupable", + repo: "test-repo", + }); + vi.mocked(global.fetch).mockRejectedValue(new Error("Network error")); + + const result = await deleteGithubRepo(mockRepoUrl); + + expect(result).toBe(false); + }); +}); diff --git a/lib/github/__tests__/findOrgReposByAccountId.test.ts b/lib/github/__tests__/findOrgReposByAccountId.test.ts new file mode 100644 index 00000000..4b3d8b4f --- /dev/null +++ b/lib/github/__tests__/findOrgReposByAccountId.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { findOrgReposByAccountId } from "../findOrgReposByAccountId"; + +describe("findOrgReposByAccountId", () => { + const mockAccountId = "550e8400-e29b-41d4-a716-446655440000"; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("GITHUB_TOKEN", "test-token"); + global.fetch = vi.fn(); + }); + + it("returns matching repo URLs when repos contain the account ID", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => [ + { name: "artist-name-550e8400-e29b-41d4-a716-446655440000", html_url: "https://github.com/recoupable/artist-name-550e8400-e29b-41d4-a716-446655440000" }, + { name: "other-repo", html_url: "https://github.com/recoupable/other-repo" }, + ], + } as Response); + + const result = await findOrgReposByAccountId(mockAccountId); + + expect(result).toEqual([ + "https://github.com/recoupable/artist-name-550e8400-e29b-41d4-a716-446655440000", + ]); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("https://api.github.com/orgs/recoupable/repos"), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + }), + }), + ); + }); + + it("returns empty array when no repos match", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => [ + { name: "unrelated-repo", html_url: "https://github.com/recoupable/unrelated-repo" }, + ], + } as Response); + + const result = await findOrgReposByAccountId(mockAccountId); + + expect(result).toEqual([]); + }); + + it("returns empty array when GITHUB_TOKEN is not set", async () => { + vi.stubEnv("GITHUB_TOKEN", ""); + + const result = await findOrgReposByAccountId(mockAccountId); + + expect(result).toEqual([]); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("returns empty array when GitHub API returns an error", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: false, + status: 403, + } as Response); + + const result = await findOrgReposByAccountId(mockAccountId); + + expect(result).toEqual([]); + }); + + it("returns empty array when fetch throws", async () => { + vi.mocked(global.fetch).mockRejectedValue(new Error("Network error")); + + const result = await findOrgReposByAccountId(mockAccountId); + + expect(result).toEqual([]); + }); +}); diff --git a/lib/github/deleteAccountGithubRepos.ts b/lib/github/deleteAccountGithubRepos.ts new file mode 100644 index 00000000..f86ee0ba --- /dev/null +++ b/lib/github/deleteAccountGithubRepos.ts @@ -0,0 +1,41 @@ +import { deleteGithubRepo } from "./deleteGithubRepo"; +import { findOrgReposByAccountId } from "./findOrgReposByAccountId"; + +/** + * Deletes all GitHub repos for an account. Combines the snapshot's github_repo URL + * with a search of the recoupable org for repos matching the account ID. + * + * @param accountId - The account ID + * @param githubRepoUrl - The github_repo URL from the snapshot, if any + * @returns true if all deletions succeeded or nothing to delete, false if any deletion failed + */ +export async function deleteAccountGithubRepos( + accountId: string, + githubRepoUrl: string | null, +): Promise { + const repoUrls: string[] = []; + + if (githubRepoUrl) { + repoUrls.push(githubRepoUrl); + } + + const orgRepos = await findOrgReposByAccountId(accountId); + for (const url of orgRepos) { + if (!repoUrls.includes(url)) { + repoUrls.push(url); + } + } + + if (repoUrls.length === 0) { + return true; + } + + for (const url of repoUrls) { + const deleted = await deleteGithubRepo(url); + if (!deleted) { + return false; + } + } + + return true; +} diff --git a/lib/github/deleteGithubRepo.ts b/lib/github/deleteGithubRepo.ts new file mode 100644 index 00000000..39d1d995 --- /dev/null +++ b/lib/github/deleteGithubRepo.ts @@ -0,0 +1,52 @@ +import { parseGitHubRepoUrl } from "./parseGitHubRepoUrl"; + +/** + * Deletes a GitHub repository by its URL. + * + * @param githubRepoUrl - The full GitHub repository URL + * @returns true if deleted successfully, false otherwise + */ +export async function deleteGithubRepo(githubRepoUrl: string): Promise { + const token = process.env.GITHUB_TOKEN; + if (!token) { + console.error("GITHUB_TOKEN environment variable is not set"); + return false; + } + + const repoInfo = parseGitHubRepoUrl(githubRepoUrl); + if (!repoInfo) { + console.error(`Failed to parse GitHub repo URL: ${githubRepoUrl}`); + return false; + } + + try { + const response = await fetch( + `https://api.github.com/repos/${repoInfo.owner}/${repoInfo.repo}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "Recoup-API", + }, + }, + ); + + if (response.status === 404) { + return true; + } + + if (!response.ok) { + const body = await response.text(); + console.error( + `GitHub API error deleting repo ${repoInfo.owner}/${repoInfo.repo}: ${response.status} ${body}`, + ); + return false; + } + + return true; + } catch (error) { + console.error("Error deleting GitHub repo:", error); + return false; + } +} diff --git a/lib/github/findOrgReposByAccountId.ts b/lib/github/findOrgReposByAccountId.ts new file mode 100644 index 00000000..18a50e7b --- /dev/null +++ b/lib/github/findOrgReposByAccountId.ts @@ -0,0 +1,41 @@ +/** + * Searches the recoupable GitHub org for repos whose name contains the account ID. + * Repos are created with the pattern `{sanitized-name}-{accountId}`. + * + * @param accountId - The account UUID to search for + * @returns Array of matching GitHub repo HTML URLs, or empty array on failure + */ +export async function findOrgReposByAccountId(accountId: string): Promise { + const token = process.env.GITHUB_TOKEN; + if (!token) { + console.error("GITHUB_TOKEN environment variable is not set"); + return []; + } + + try { + const response = await fetch( + `https://api.github.com/orgs/recoupable/repos?per_page=100`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "Recoup-API", + }, + }, + ); + + if (!response.ok) { + console.error(`GitHub API error listing org repos: ${response.status}`); + return []; + } + + const repos = (await response.json()) as Array<{ name: string; html_url: string }>; + + return repos + .filter((repo) => repo.name.includes(accountId)) + .map((repo) => repo.html_url); + } catch (error) { + console.error("Error searching org repos:", error); + return []; + } +} diff --git a/lib/sandbox/__tests__/deleteSandboxHandler.test.ts b/lib/sandbox/__tests__/deleteSandboxHandler.test.ts new file mode 100644 index 00000000..4a84eebc --- /dev/null +++ b/lib/sandbox/__tests__/deleteSandboxHandler.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; + +import { deleteSandboxHandler } from "../deleteSandboxHandler"; +import { validateDeleteSandboxBody } from "../validateDeleteSandboxBody"; +import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; +import { deleteAccountSnapshot } from "@/lib/supabase/account_snapshots/deleteAccountSnapshot"; +import { deleteAccountGithubRepos } from "@/lib/github/deleteAccountGithubRepos"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/sandbox/validateDeleteSandboxBody", () => ({ + validateDeleteSandboxBody: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({ + selectAccountSnapshots: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_snapshots/deleteAccountSnapshot", () => ({ + deleteAccountSnapshot: vi.fn(), +})); + +vi.mock("@/lib/github/deleteAccountGithubRepos", () => ({ + deleteAccountGithubRepos: vi.fn(), +})); + +/** + * + */ +function createMockRequest(): Request { + return new Request("https://example.com/api/sandboxes", { + method: "DELETE", + }); +} + +describe("deleteSandboxHandler", () => { + const mockAccountId = "550e8400-e29b-41d4-a716-446655440000"; + const mockSnapshot = { + account_id: mockAccountId, + snapshot_id: "snap_abc123", + github_repo: "https://github.com/recoupable/test-repo", + expires_at: "2027-01-01T00:00:00.000Z", + created_at: "2025-01-01T00:00:00.000Z", + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns validation error when validation fails", async () => { + vi.mocked(validateDeleteSandboxBody).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const request = createMockRequest(); + const response = await deleteSandboxHandler(request as never); + + expect(response.status).toBe(401); + expect(selectAccountSnapshots).not.toHaveBeenCalled(); + }); + + it("returns success with null deleted_snapshot when no snapshot exists", async () => { + vi.mocked(validateDeleteSandboxBody).mockResolvedValue({ + accountId: mockAccountId, + }); + vi.mocked(selectAccountSnapshots).mockResolvedValue([]); + vi.mocked(deleteAccountGithubRepos).mockResolvedValue(true); + + const request = createMockRequest(); + const response = await deleteSandboxHandler(request as never); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.status).toBe("success"); + expect(body.deleted_snapshot).toBeNull(); + expect(deleteAccountGithubRepos).toHaveBeenCalledWith(mockAccountId, null); + expect(deleteAccountSnapshot).not.toHaveBeenCalled(); + }); + + it("deletes github repos and snapshot when snapshot has github_repo", async () => { + vi.mocked(validateDeleteSandboxBody).mockResolvedValue({ + accountId: mockAccountId, + }); + vi.mocked(selectAccountSnapshots).mockResolvedValue([mockSnapshot]); + vi.mocked(deleteAccountGithubRepos).mockResolvedValue(true); + vi.mocked(deleteAccountSnapshot).mockResolvedValue(mockSnapshot); + + const request = createMockRequest(); + const response = await deleteSandboxHandler(request as never); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.status).toBe("success"); + expect(body.deleted_snapshot).toEqual(mockSnapshot); + expect(deleteAccountGithubRepos).toHaveBeenCalledWith(mockAccountId, mockSnapshot.github_repo); + expect(deleteAccountSnapshot).toHaveBeenCalledWith(mockAccountId); + }); + + it("deletes snapshot when snapshot has no github_repo", async () => { + const snapshotWithoutRepo = { ...mockSnapshot, github_repo: null }; + vi.mocked(validateDeleteSandboxBody).mockResolvedValue({ + accountId: mockAccountId, + }); + vi.mocked(selectAccountSnapshots).mockResolvedValue([snapshotWithoutRepo]); + vi.mocked(deleteAccountGithubRepos).mockResolvedValue(true); + vi.mocked(deleteAccountSnapshot).mockResolvedValue(snapshotWithoutRepo); + + const request = createMockRequest(); + const response = await deleteSandboxHandler(request as never); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.status).toBe("success"); + expect(deleteAccountGithubRepos).toHaveBeenCalledWith(mockAccountId, null); + expect(deleteAccountSnapshot).toHaveBeenCalledWith(mockAccountId); + }); + + it("returns 500 and does not delete snapshot when github repo deletion fails", async () => { + vi.mocked(validateDeleteSandboxBody).mockResolvedValue({ + accountId: mockAccountId, + }); + vi.mocked(selectAccountSnapshots).mockResolvedValue([mockSnapshot]); + vi.mocked(deleteAccountGithubRepos).mockResolvedValue(false); + + const request = createMockRequest(); + const response = await deleteSandboxHandler(request as never); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.status).toBe("error"); + expect(body.error).toBe("Failed to delete GitHub repository"); + expect(deleteAccountSnapshot).not.toHaveBeenCalled(); + }); + + it("returns 500 when deleteAccountSnapshot throws", async () => { + vi.mocked(validateDeleteSandboxBody).mockResolvedValue({ + accountId: mockAccountId, + }); + vi.mocked(selectAccountSnapshots).mockResolvedValue([mockSnapshot]); + vi.mocked(deleteAccountGithubRepos).mockResolvedValue(true); + vi.mocked(deleteAccountSnapshot).mockRejectedValue(new Error("Database error")); + + const request = createMockRequest(); + const response = await deleteSandboxHandler(request as never); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.status).toBe("error"); + expect(body.error).toBe("Database error"); + }); +}); diff --git a/lib/sandbox/__tests__/validateDeleteSandboxBody.test.ts b/lib/sandbox/__tests__/validateDeleteSandboxBody.test.ts new file mode 100644 index 00000000..f309950f --- /dev/null +++ b/lib/sandbox/__tests__/validateDeleteSandboxBody.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; + +import { validateDeleteSandboxBody } from "../validateDeleteSandboxBody"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/networking/safeParseJson", () => ({ + safeParseJson: vi.fn(), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +/** + * + */ +function createMockRequest(): Request { + return new Request("https://example.com/api/sandboxes", { + method: "DELETE", + }); +} + +describe("validateDeleteSandboxBody", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when auth fails", async () => { + vi.mocked(safeParseJson).mockResolvedValue({}); + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const request = createMockRequest(); + const result = await validateDeleteSandboxBody(request as never); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(401); + }); + + it("returns 400 when account_id is not a valid UUID", async () => { + vi.mocked(safeParseJson).mockResolvedValue({ + account_id: "not-a-uuid", + }); + + const request = createMockRequest(); + const result = await validateDeleteSandboxBody(request as never); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns validated accountId on success", async () => { + const mockAccountId = "550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(safeParseJson).mockResolvedValue({}); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "test-token", + }); + + const request = createMockRequest(); + const result = await validateDeleteSandboxBody(request as never); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ accountId: mockAccountId }); + }); + + it("passes account_id override to validateAuthContext", async () => { + const targetAccountId = "660e8400-e29b-41d4-a716-446655440000"; + vi.mocked(safeParseJson).mockResolvedValue({ + account_id: targetAccountId, + }); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: targetAccountId, + orgId: "org-123", + authToken: "test-token", + }); + + const request = createMockRequest(); + const result = await validateDeleteSandboxBody(request as never); + + expect(validateAuthContext).toHaveBeenCalledWith(request, { accountId: targetAccountId }); + expect(result).toEqual({ accountId: targetAccountId }); + }); + + it("calls validateAuthContext with no accountId when body is empty", async () => { + vi.mocked(safeParseJson).mockResolvedValue({}); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "550e8400-e29b-41d4-a716-446655440000", + orgId: null, + authToken: "test-token", + }); + + const request = createMockRequest(); + await validateDeleteSandboxBody(request as never); + + expect(validateAuthContext).toHaveBeenCalledWith(request, { accountId: undefined }); + }); +}); diff --git a/lib/sandbox/deleteSandboxHandler.ts b/lib/sandbox/deleteSandboxHandler.ts new file mode 100644 index 00000000..4820334a --- /dev/null +++ b/lib/sandbox/deleteSandboxHandler.ts @@ -0,0 +1,62 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateDeleteSandboxBody } from "@/lib/sandbox/validateDeleteSandboxBody"; +import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; +import { deleteAccountSnapshot } from "@/lib/supabase/account_snapshots/deleteAccountSnapshot"; +import { deleteAccountGithubRepos } from "@/lib/github/deleteAccountGithubRepos"; + +/** + * Handler for DELETE /api/sandboxes. + * + * Deletes the GitHub repository and snapshot record for an account. + * If no snapshot exists, searches the recoupable GitHub org for repos + * matching the account ID and deletes them. + * Requires authentication via x-api-key header or Authorization Bearer token. + * + * @param request - The request object + * @returns A NextResponse with the deletion result or error + */ +export async function deleteSandboxHandler(request: NextRequest): Promise { + const validated = await validateDeleteSandboxBody(request); + if (validated instanceof NextResponse) { + return validated; + } + + const snapshots = await selectAccountSnapshots(validated.accountId); + const snapshot = snapshots[0]; + + try { + const reposDeleted = await deleteAccountGithubRepos( + validated.accountId, + snapshot?.github_repo ?? null, + ); + + if (!reposDeleted) { + return NextResponse.json( + { status: "error", error: "Failed to delete GitHub repository" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + if (!snapshot) { + return NextResponse.json( + { status: "success", deleted_snapshot: null }, + { status: 200, headers: getCorsHeaders() }, + ); + } + + const deletedSnapshot = await deleteAccountSnapshot(validated.accountId); + + return NextResponse.json( + { status: "success", deleted_snapshot: deletedSnapshot ?? snapshot }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to delete sandbox"; + return NextResponse.json( + { status: "error", error: message }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/sandbox/validateDeleteSandboxBody.ts b/lib/sandbox/validateDeleteSandboxBody.ts new file mode 100644 index 00000000..1823d1d4 --- /dev/null +++ b/lib/sandbox/validateDeleteSandboxBody.ts @@ -0,0 +1,58 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { z } from "zod"; + +export const deleteSandboxBodySchema = z.object({ + account_id: z.string().uuid("account_id must be a valid UUID").optional(), +}); + +export type DeleteSandboxBody = { + accountId: string; +}; + +/** + * Validates auth and request body for DELETE /api/sandboxes. + * Handles authentication via x-api-key or Authorization bearer token, + * body validation, and optional account_id override for organization API keys. + * + * @param request - The NextRequest object + * @returns A NextResponse with an error if validation fails, or the validated body with auth context. + */ +export async function validateDeleteSandboxBody( + request: NextRequest, +): Promise { + const body = await safeParseJson(request); + const result = deleteSandboxBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + const { account_id: targetAccountId } = result.data; + + const authResult = await validateAuthContext(request, { + accountId: targetAccountId, + }); + + if (authResult instanceof NextResponse) { + return authResult; + } + + return { + accountId: authResult.accountId, + }; +} diff --git a/lib/supabase/account_snapshots/__tests__/deleteAccountSnapshot.test.ts b/lib/supabase/account_snapshots/__tests__/deleteAccountSnapshot.test.ts new file mode 100644 index 00000000..c63ff75d --- /dev/null +++ b/lib/supabase/account_snapshots/__tests__/deleteAccountSnapshot.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { deleteAccountSnapshot } from "../deleteAccountSnapshot"; + +const mockFrom = vi.fn(); +const mockDelete = vi.fn(); +const mockEq = vi.fn(); +const mockSelect = vi.fn(); +const mockSingle = vi.fn(); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: (...args: unknown[]) => { + mockFrom(...args); + return { + delete: (...dArgs: unknown[]) => { + mockDelete(...dArgs); + return { + eq: (...eqArgs: unknown[]) => { + mockEq(...eqArgs); + return { + select: (...sArgs: unknown[]) => { + mockSelect(...sArgs); + return { single: mockSingle }; + }, + }; + }, + }; + }, + }; + }, + }, +})); + +describe("deleteAccountSnapshot", () => { + const mockAccountId = "550e8400-e29b-41d4-a716-446655440000"; + const mockSnapshot = { + account_id: mockAccountId, + snapshot_id: "snap_abc123", + github_repo: "https://github.com/recoupable/test-repo", + expires_at: "2027-01-01T00:00:00.000Z", + created_at: "2025-01-01T00:00:00.000Z", + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("deletes the snapshot and returns the deleted row", async () => { + mockSingle.mockResolvedValue({ data: mockSnapshot, error: null }); + + const result = await deleteAccountSnapshot(mockAccountId); + + expect(mockFrom).toHaveBeenCalledWith("account_snapshots"); + expect(mockEq).toHaveBeenCalledWith("account_id", mockAccountId); + expect(result).toEqual(mockSnapshot); + }); + + it("returns null when there is an error", async () => { + mockSingle.mockResolvedValue({ + data: null, + error: { message: "Not found" }, + }); + + const result = await deleteAccountSnapshot(mockAccountId); + + expect(result).toBeNull(); + }); + + it("returns null when no data is returned", async () => { + mockSingle.mockResolvedValue({ data: null, error: null }); + + const result = await deleteAccountSnapshot(mockAccountId); + + expect(result).toBeNull(); + }); +}); diff --git a/lib/supabase/account_snapshots/deleteAccountSnapshot.ts b/lib/supabase/account_snapshots/deleteAccountSnapshot.ts new file mode 100644 index 00000000..9c61944e --- /dev/null +++ b/lib/supabase/account_snapshots/deleteAccountSnapshot.ts @@ -0,0 +1,25 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Deletes the snapshot record for an account and returns the deleted row. + * + * @param accountId - The account ID whose snapshot should be deleted + * @returns The deleted snapshot record, or null if not found or on error + */ +export async function deleteAccountSnapshot( + accountId: string, +): Promise | null> { + const { data, error } = await supabase + .from("account_snapshots") + .delete() + .eq("account_id", accountId) + .select("*") + .single(); + + if (error || !data) { + return null; + } + + return data; +}