Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions app/api/sandboxes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -100,3 +101,31 @@ export async function GET(request: NextRequest): Promise<Response> {
export async function PATCH(request: NextRequest): Promise<Response> {
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<Response> {
return deleteSandboxHandler(request);
}
71 changes: 71 additions & 0 deletions lib/github/__tests__/deleteAccountGithubRepos.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
102 changes: 102 additions & 0 deletions lib/github/__tests__/deleteGithubRepo.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
78 changes: 78 additions & 0 deletions lib/github/__tests__/findOrgReposByAccountId.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
41 changes: 41 additions & 0 deletions lib/github/deleteAccountGithubRepos.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;
}
52 changes: 52 additions & 0 deletions lib/github/deleteGithubRepo.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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;
}
}
Loading