Skip to content
Open
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
11 changes: 11 additions & 0 deletions frontend/ui/src/app/api/github/token/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,17 @@ describe("GET /api/github/token", () => {
const res = await GET(makeRequest({ workspaceIdQuery: "ws_1" }));
expect(res.status).toBe(403);
});

it("session path requires ADMIN — VIEWER gets 403 and no token is minted", async () => {
requireAuthMock.mockResolvedValue({ user: { id: "u_1" } });
requireMembershipMock.mockResolvedValue({
error: { status: 403, json: async () => ({ error: "Requires ADMIN role or higher" }) },
});
const res = await GET(makeRequest({ workspaceIdQuery: "ws_1" }));
expect(res.status).toBe(403);
expect(requireMembershipMock).toHaveBeenCalledWith("u_1", "ws_1", "ADMIN");
expect(getInstallationTokenMock).not.toHaveBeenCalled();
});
});

describe("installation lookup", () => {
Expand Down
6 changes: 5 additions & 1 deletion frontend/ui/src/app/api/github/token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ export async function GET(request: NextRequest) {
if (!workspaceId) {
return NextResponse.json({ error: "workspaceId required" }, { status: 400 });
}
const memberCheck = await requireWorkspaceMembership(authResult.user.id, workspaceId);
const memberCheck = await requireWorkspaceMembership(
authResult.user.id,
workspaceId,
"ADMIN",
);
if (memberCheck.error) return memberCheck.error;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("next/server", () => ({ NextRequest: class {} }));

const requireAuthMock = vi.fn();
const requireProjectAccessMock = vi.fn();
vi.mock("@/lib/auth-helpers", () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
requireProjectAccess: (...args: unknown[]) => requireProjectAccessMock(...args),
successResponse: (data: unknown, status = 200) => ({
status,
json: async () => data,
}),
}));

vi.mock("@traceroot/core", () => ({
ModelSource: { BYOK: "byok", SYSTEM: "system" },
PlanType: { FREE: "FREE" },
isBillingEnabled: () => false,
prisma: {
modelProvider: { findFirst: vi.fn().mockResolvedValue(null) },
workspace: { findUnique: vi.fn().mockResolvedValue(null) },
},
}));

const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);

import { POST } from "./route";

function makeParams(sessionId = "sess-1") {
return { params: Promise.resolve({ projectId: "proj-1", sessionId }) };
}

function makeRequest(body: unknown = { message: "hello", model: "gpt-5", source: "system" }) {
return { json: async () => body } as unknown as Parameters<typeof POST>[0];
}

beforeEach(() => {
requireAuthMock.mockReset();
requireProjectAccessMock.mockReset();
fetchMock.mockReset();
requireAuthMock.mockResolvedValue({ user: { id: "user-1" } });
});

describe("POST .../ai/sessions/[sessionId]/messages — role gate", () => {
it("returns 403 for VIEWER (requires MEMBER)", async () => {
requireProjectAccessMock.mockResolvedValue({
error: { status: 403, json: async () => ({ error: "Requires MEMBER role or higher" }) },
});
const res = await POST(makeRequest(), makeParams());
expect(res.status).toBe(403);
expect(requireProjectAccessMock).toHaveBeenCalledWith("user-1", "proj-1", "MEMBER");
expect(fetchMock).not.toHaveBeenCalled();
});

it("allows MEMBER to post a message", async () => {
requireProjectAccessMock.mockResolvedValue({
project: { id: "proj-1", workspaceId: "ws-1" },
});
const mockStream = new ReadableStream();
fetchMock.mockResolvedValue({ ok: true, body: mockStream });
const res = await POST(makeRequest(), makeParams());
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {

const { projectId, sessionId } = await params;

const accessResult = await requireProjectAccess(user.id, projectId);
const accessResult = await requireProjectAccess(user.id, projectId, "MEMBER");
if (accessResult.error) return accessResult.error;

const body = await request.json();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("next/server", () => ({ NextRequest: class {} }));

const requireAuthMock = vi.fn();
const requireProjectAccessMock = vi.fn();
vi.mock("@/lib/auth-helpers", () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
requireProjectAccess: (...args: unknown[]) => requireProjectAccessMock(...args),
successResponse: (data: unknown, status = 200) => ({
status,
json: async () => data,
}),
}));

const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);

import { DELETE } from "./route";

function makeParams(sessionId = "sess-1") {
return { params: Promise.resolve({ projectId: "proj-1", sessionId }) };
}

function makeRequest() {
return {} as unknown as Parameters<typeof DELETE>[0];
}

beforeEach(() => {
requireAuthMock.mockReset();
requireProjectAccessMock.mockReset();
fetchMock.mockReset();
requireAuthMock.mockResolvedValue({ user: { id: "user-1" } });
});

describe("DELETE .../ai/sessions/[sessionId] — role gate", () => {
it("returns 403 for VIEWER (requires ADMIN)", async () => {
requireProjectAccessMock.mockResolvedValue({
error: { status: 403, json: async () => ({ error: "Requires ADMIN role or higher" }) },
});
const res = await DELETE(makeRequest(), makeParams());
expect(res.status).toBe(403);
expect(requireProjectAccessMock).toHaveBeenCalledWith("user-1", "proj-1", "ADMIN");
expect(fetchMock).not.toHaveBeenCalled();
});

it("allows ADMIN to delete a session", async () => {
requireProjectAccessMock.mockResolvedValue({
project: { id: "proj-1", workspaceId: "ws-1" },
});
fetchMock.mockResolvedValue({ ok: true, json: async () => ({}) });
const res = await DELETE(makeRequest(), makeParams());
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export async function DELETE(_request: NextRequest, { params }: RouteParams) {

const { projectId, sessionId } = await params;

const accessResult = await requireProjectAccess(user.id, projectId);
const accessResult = await requireProjectAccess(user.id, projectId, "ADMIN");
if (accessResult.error) return accessResult.error;

const res = await fetch(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("next/server", () => ({ NextRequest: class {} }));

const requireAuthMock = vi.fn();
const requireProjectAccessMock = vi.fn();
vi.mock("@/lib/auth-helpers", () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
requireProjectAccess: (...args: unknown[]) => requireProjectAccessMock(...args),
successResponse: (data: unknown, status = 200) => ({
status,
json: async () => data,
}),
}));

const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);

import { POST } from "./route";

function makeParams() {
return { params: Promise.resolve({ projectId: "proj-1" }) };
}

function makeRequest(body: unknown = {}) {
return { json: async () => body } as unknown as Parameters<typeof POST>[0];
}

beforeEach(() => {
requireAuthMock.mockReset();
requireProjectAccessMock.mockReset();
fetchMock.mockReset();
requireAuthMock.mockResolvedValue({ user: { id: "user-1" } });
});

describe("POST .../ai/sessions — role gate", () => {
it("returns 403 for VIEWER (requires MEMBER)", async () => {
requireProjectAccessMock.mockResolvedValue({
error: { status: 403, json: async () => ({ error: "Requires MEMBER role or higher" }) },
});
const res = await POST(makeRequest({ title: "new session" }), makeParams());
expect(res.status).toBe(403);
expect(requireProjectAccessMock).toHaveBeenCalledWith("user-1", "proj-1", "MEMBER");
expect(fetchMock).not.toHaveBeenCalled();
});

it("allows MEMBER to create a session", async () => {
requireProjectAccessMock.mockResolvedValue({
project: { id: "proj-1", workspaceId: "ws-1" },
});
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ id: "sess-1" }),
});
const res = await POST(makeRequest({ title: "new session" }), makeParams());
expect(res.status).toBe(201);
expect(fetchMock).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {

const { projectId } = await params;

const accessResult = await requireProjectAccess(user.id, projectId);
const accessResult = await requireProjectAccess(user.id, projectId, "MEMBER");
Comment thread
dark-sorceror marked this conversation as resolved.
if (accessResult.error) return accessResult.error;

const body = await request.json();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("next/server", () => ({ NextRequest: class {} }));

const detectorFindFirstMock = vi.fn();
const detectorUpdateMock = vi.fn();
const detectorDeleteMock = vi.fn();
vi.mock("@traceroot/core", () => ({
prisma: {
detector: {
findFirst: (...args: unknown[]) => detectorFindFirstMock(...args),
update: (...args: unknown[]) => detectorUpdateMock(...args),
delete: (...args: unknown[]) => detectorDeleteMock(...args),
},
},
}));

const requireAuthMock = vi.fn();
const requireProjectAccessMock = vi.fn();
vi.mock("@/lib/auth-helpers", () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
requireProjectAccess: (...args: unknown[]) => requireProjectAccessMock(...args),
errorResponse: (msg: string, status: number) => ({
status,
json: async () => ({ error: msg }),
}),
successResponse: (data: unknown, status = 200) => ({
status,
json: async () => data,
}),
}));

import { PATCH, DELETE } from "./route";

function makeParams(detectorId = "det-1") {
return { params: Promise.resolve({ projectId: "proj-1", detectorId }) };
}

function makePatchRequest(body: unknown) {
return { json: async () => body } as unknown as Parameters<typeof PATCH>[0];
}

function makeDeleteRequest() {
return {} as unknown as Parameters<typeof DELETE>[0];
}

function viewerForbidden() {
return {
error: { status: 403, json: async () => ({ error: "Requires MEMBER role or higher" }) },
};
}

function adminForbidden() {
return {
error: { status: 403, json: async () => ({ error: "Requires ADMIN role or higher" }) },
};
}

beforeEach(() => {
detectorFindFirstMock.mockReset();
detectorUpdateMock.mockReset();
detectorDeleteMock.mockReset();
requireAuthMock.mockReset();
requireProjectAccessMock.mockReset();
requireAuthMock.mockResolvedValue({ user: { id: "user-1" } });
});

describe("PATCH .../detectors/[detectorId] — role gate", () => {
it("returns 403 for VIEWER (requires MEMBER)", async () => {
requireProjectAccessMock.mockResolvedValue(viewerForbidden());
const res = await PATCH(makePatchRequest({ name: "updated" }), makeParams());
expect(res.status).toBe(403);
expect(requireProjectAccessMock).toHaveBeenCalledWith("user-1", "proj-1", "MEMBER");
expect(detectorUpdateMock).not.toHaveBeenCalled();
});

it("allows MEMBER to update a detector", async () => {
requireProjectAccessMock.mockResolvedValue({ project: { workspaceId: "ws-1" } });
detectorFindFirstMock.mockResolvedValue({ id: "det-1", projectId: "proj-1" });
detectorUpdateMock.mockResolvedValue({ id: "det-1", name: "updated" });
const res = await PATCH(makePatchRequest({ name: "updated" }), makeParams());
expect(res.status).toBe(200);
expect(detectorUpdateMock).toHaveBeenCalled();
});
});

describe("DELETE .../detectors/[detectorId] — role gate", () => {
it("returns 403 for VIEWER (requires ADMIN)", async () => {
requireProjectAccessMock.mockResolvedValue(adminForbidden());
const res = await DELETE(makeDeleteRequest(), makeParams());
expect(res.status).toBe(403);
expect(requireProjectAccessMock).toHaveBeenCalledWith("user-1", "proj-1", "ADMIN");
expect(detectorDeleteMock).not.toHaveBeenCalled();
});

it("allows ADMIN to delete a detector", async () => {
requireProjectAccessMock.mockResolvedValue({ project: { workspaceId: "ws-1" } });
detectorFindFirstMock.mockResolvedValue({ id: "det-1", projectId: "proj-1" });
detectorDeleteMock.mockResolvedValue({});
const res = await DELETE(makeDeleteRequest(), makeParams());
expect(res.status).toBe(200);
expect(detectorDeleteMock).toHaveBeenCalledWith({ where: { id: "det-1" } });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export async function PATCH(req: NextRequest, { params }: RouteParams) {
const { user } = authResult;

const { projectId, detectorId } = await params;
const accessResult = await requireProjectAccess(user.id, projectId);
const accessResult = await requireProjectAccess(user.id, projectId, "MEMBER");
if (accessResult.error) return accessResult.error;

const existing = await prisma.detector.findFirst({
Expand Down Expand Up @@ -167,7 +167,7 @@ export async function DELETE(_req: NextRequest, { params }: RouteParams) {
const { user } = authResult;

const { projectId, detectorId } = await params;
const accessResult = await requireProjectAccess(user.id, projectId);
const accessResult = await requireProjectAccess(user.id, projectId, "ADMIN");
Comment thread
dark-sorceror marked this conversation as resolved.
if (accessResult.error) return accessResult.error;

const existing = await prisma.detector.findFirst({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ beforeEach(() => {
detectorCreateMock.mockResolvedValue({ id: "det-1" });
});

describe("POST .../detectors — role gate", () => {
it("returns 403 for VIEWER (requires MEMBER)", async () => {
requireProjectAccessMock.mockResolvedValue({
error: { status: 403, json: async () => ({ error: "Requires MEMBER role or higher" }) },
});
const res = await POST(makeRequest(validBody()), makeParams());
expect(res.status).toBe(403);
expect(requireProjectAccessMock).toHaveBeenCalledWith("user-1", "proj-1", "MEMBER");
expect(detectorCreateMock).not.toHaveBeenCalled();
});
});

describe("POST .../detectors — sampleRate default", () => {
it("defaults sampleRate to 25 when omitted", async () => {
const res = await POST(makeRequest(validBody()), makeParams());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) {
const { user } = authResult;

const { projectId } = await params;
const accessResult = await requireProjectAccess(user.id, projectId);
const accessResult = await requireProjectAccess(user.id, projectId, "MEMBER");
if (accessResult.error) return accessResult.error;

let body: unknown;
Expand Down
Loading
Loading