diff --git a/src/app/api/ci/file/route.test.ts b/src/app/api/ci/file/route.test.ts new file mode 100644 index 0000000..86faf1f --- /dev/null +++ b/src/app/api/ci/file/route.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + hashAccessTokenSecret: vi.fn(), + getObjectBuffer: vi.fn(), + decryptToUtf8: vi.fn(), + loadCollectionAccessState: vi.fn(), + accessTokenFindUnique: vi.fn(), + collectionFindUnique: vi.fn(), +})); + +vi.mock("@/lib/access-token-hash", () => ({ + hashAccessTokenSecret: mocks.hashAccessTokenSecret, +})); + +vi.mock("@/lib/s3", () => ({ + getObjectBuffer: mocks.getObjectBuffer, +})); + +vi.mock("@/lib/crypto", () => ({ + decryptToUtf8: mocks.decryptToUtf8, +})); + +vi.mock("@/server/access/collections", () => ({ + loadCollectionAccessState: mocks.loadCollectionAccessState, +})); + +vi.mock("@/lib/paths", () => ({ + getBucket: vi.fn(), + assertValidCollectionSlug: vi.fn(), + assertSafeRelativePath: vi.fn(), + fullObjectKey: vi.fn((slug: string, relativePath: string) => { + return `${slug}/${relativePath}`; + }), +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + accessToken: { + findUnique: mocks.accessTokenFindUnique, + }, + collection: { + findUnique: mocks.collectionFindUnique, + }, + }, +})); + +import { GET } from "./route"; + +function makeReq(secret = "team/app.env") { + return new NextRequest(`https://example.com/api/ci/file?secret=${secret}`, { + headers: { + authorization: "Bearer crb_test", + }, + }); +} + +describe("GET /api/ci/file", () => { + beforeEach(() => { + vi.clearAllMocks(); + + mocks.hashAccessTokenSecret.mockReturnValue("lookup"); + mocks.accessTokenFindUnique.mockResolvedValue({ + id: "token-1", + createdById: "user-1", + createdBy: { id: "user-1", email: "user@example.com" }, + collections: [{ collectionId: "collection-1" }], + }); + mocks.collectionFindUnique.mockResolvedValue({ id: "collection-1" }); + mocks.loadCollectionAccessState.mockResolvedValue({ kind: "creator" }); + mocks.getObjectBuffer.mockResolvedValue(Buffer.from("encrypted")); + mocks.decryptToUtf8.mockReturnValue("SECRET=value"); + }); + + it("returns forbidden when token creator no longer has collection access", async () => { + mocks.loadCollectionAccessState.mockResolvedValue({ kind: "none" }); + + const response = await GET(makeReq()); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "Forbidden" }); + expect(mocks.getObjectBuffer).not.toHaveBeenCalled(); + expect(mocks.loadCollectionAccessState).toHaveBeenCalledWith({ + userId: "user-1", + email: "user@example.com", + slug: "team", + }); + }); + + it("returns decrypted content when token creator still has access", async () => { + const response = await GET(makeReq()); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ content: "SECRET=value" }); + expect(mocks.getObjectBuffer).toHaveBeenCalledWith("team/app.env"); + }); +}); diff --git a/src/app/api/ci/file/route.ts b/src/app/api/ci/file/route.ts index acb69a5..b186e85 100644 --- a/src/app/api/ci/file/route.ts +++ b/src/app/api/ci/file/route.ts @@ -9,6 +9,7 @@ import { getBucket, } from "@/lib/paths"; import { getObjectBuffer } from "@/lib/s3"; +import { loadCollectionAccessState } from "@/server/access/collections"; function parseBearer(req: NextRequest): string | null { const h = req.headers.get("authorization"); @@ -77,7 +78,12 @@ export async function GET(req: NextRequest) { const row = await prisma.accessToken.findUnique({ where: { tokenLookup }, - include: { collections: true }, + include: { + collections: true, + createdBy: { + select: { id: true, email: true }, + }, + }, }); if (!row) { @@ -98,6 +104,15 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } + const creatorState = await loadCollectionAccessState({ + userId: row.createdBy.id, + email: row.createdBy.email, + slug, + }); + if (creatorState.kind === "none") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const objectKey = fullObjectKey(slug, relativePath); let body: Buffer;