From 63ab3029f73c1096b37d67176eaf44f9f81c46c9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Apr 2026 11:08:32 +0000 Subject: [PATCH 1/2] fix: block CI token access after creator grant revocation Co-authored-by: Vitalii Melnychuk --- src/app/api/ci/file/route.test.ts | 96 +++++++++++++++++++++++++++++++ src/app/api/ci/file/route.ts | 17 +++++- 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 src/app/api/ci/file/route.test.ts 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..f9a9e91 --- /dev/null +++ b/src/app/api/ci/file/route.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const hashAccessTokenSecretMock = vi.fn(); +const getObjectBufferMock = vi.fn(); +const decryptToUtf8Mock = vi.fn(); +const loadCollectionAccessStateMock = vi.fn(); +const accessTokenFindUniqueMock = vi.fn(); +const collectionFindUniqueMock = vi.fn(); + +vi.mock("@/lib/access-token-hash", () => ({ + hashAccessTokenSecret: hashAccessTokenSecretMock, +})); + +vi.mock("@/lib/s3", () => ({ + getObjectBuffer: getObjectBufferMock, +})); + +vi.mock("@/lib/crypto", () => ({ + decryptToUtf8: decryptToUtf8Mock, +})); + +vi.mock("@/server/access/collections", () => ({ + loadCollectionAccessState: loadCollectionAccessStateMock, +})); + +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: accessTokenFindUniqueMock, + }, + collection: { + findUnique: collectionFindUniqueMock, + }, + }, +})); + +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(); + + hashAccessTokenSecretMock.mockReturnValue("lookup"); + accessTokenFindUniqueMock.mockResolvedValue({ + id: "token-1", + createdById: "user-1", + createdBy: { id: "user-1", email: "user@example.com" }, + collections: [{ collectionId: "collection-1" }], + }); + collectionFindUniqueMock.mockResolvedValue({ id: "collection-1" }); + loadCollectionAccessStateMock.mockResolvedValue({ kind: "creator" }); + getObjectBufferMock.mockResolvedValue(Buffer.from("encrypted")); + decryptToUtf8Mock.mockReturnValue("SECRET=value"); + }); + + it("returns forbidden when token creator no longer has collection access", async () => { + loadCollectionAccessStateMock.mockResolvedValue({ kind: "none" }); + + const response = await GET(makeReq()); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "Forbidden" }); + expect(getObjectBufferMock).not.toHaveBeenCalled(); + expect(loadCollectionAccessStateMock).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(getObjectBufferMock).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; From 4133bbf62937a7ae64aeb9e634623027be96b29b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Apr 2026 11:10:22 +0000 Subject: [PATCH 2/2] test: fix hoisted mocks in CI route regression test --- src/app/api/ci/file/route.test.ts | 46 ++++++++++++++++--------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/src/app/api/ci/file/route.test.ts b/src/app/api/ci/file/route.test.ts index f9a9e91..86faf1f 100644 --- a/src/app/api/ci/file/route.test.ts +++ b/src/app/api/ci/file/route.test.ts @@ -1,27 +1,29 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; -const hashAccessTokenSecretMock = vi.fn(); -const getObjectBufferMock = vi.fn(); -const decryptToUtf8Mock = vi.fn(); -const loadCollectionAccessStateMock = vi.fn(); -const accessTokenFindUniqueMock = vi.fn(); -const collectionFindUniqueMock = vi.fn(); +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: hashAccessTokenSecretMock, + hashAccessTokenSecret: mocks.hashAccessTokenSecret, })); vi.mock("@/lib/s3", () => ({ - getObjectBuffer: getObjectBufferMock, + getObjectBuffer: mocks.getObjectBuffer, })); vi.mock("@/lib/crypto", () => ({ - decryptToUtf8: decryptToUtf8Mock, + decryptToUtf8: mocks.decryptToUtf8, })); vi.mock("@/server/access/collections", () => ({ - loadCollectionAccessState: loadCollectionAccessStateMock, + loadCollectionAccessState: mocks.loadCollectionAccessState, })); vi.mock("@/lib/paths", () => ({ @@ -36,10 +38,10 @@ vi.mock("@/lib/paths", () => ({ vi.mock("@/lib/prisma", () => ({ prisma: { accessToken: { - findUnique: accessTokenFindUniqueMock, + findUnique: mocks.accessTokenFindUnique, }, collection: { - findUnique: collectionFindUniqueMock, + findUnique: mocks.collectionFindUnique, }, }, })); @@ -58,28 +60,28 @@ describe("GET /api/ci/file", () => { beforeEach(() => { vi.clearAllMocks(); - hashAccessTokenSecretMock.mockReturnValue("lookup"); - accessTokenFindUniqueMock.mockResolvedValue({ + 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" }], }); - collectionFindUniqueMock.mockResolvedValue({ id: "collection-1" }); - loadCollectionAccessStateMock.mockResolvedValue({ kind: "creator" }); - getObjectBufferMock.mockResolvedValue(Buffer.from("encrypted")); - decryptToUtf8Mock.mockReturnValue("SECRET=value"); + 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 () => { - loadCollectionAccessStateMock.mockResolvedValue({ kind: "none" }); + mocks.loadCollectionAccessState.mockResolvedValue({ kind: "none" }); const response = await GET(makeReq()); expect(response.status).toBe(403); expect(await response.json()).toEqual({ error: "Forbidden" }); - expect(getObjectBufferMock).not.toHaveBeenCalled(); - expect(loadCollectionAccessStateMock).toHaveBeenCalledWith({ + expect(mocks.getObjectBuffer).not.toHaveBeenCalled(); + expect(mocks.loadCollectionAccessState).toHaveBeenCalledWith({ userId: "user-1", email: "user@example.com", slug: "team", @@ -91,6 +93,6 @@ describe("GET /api/ci/file", () => { expect(response.status).toBe(200); expect(await response.json()).toEqual({ content: "SECRET=value" }); - expect(getObjectBufferMock).toHaveBeenCalledWith("team/app.env"); + expect(mocks.getObjectBuffer).toHaveBeenCalledWith("team/app.env"); }); });