diff --git a/server/src/__tests__/project-get-reference-routes.test.ts b/server/src/__tests__/project-get-reference-routes.test.ts new file mode 100644 index 0000000000..80179d0058 --- /dev/null +++ b/server/src/__tests__/project-get-reference-routes.test.ts @@ -0,0 +1,119 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { companies, createDb, projects } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { projectRoutes } from "../routes/projects.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres project reference tests on this host: ${ + embeddedPostgresSupport.reason ?? "unsupported environment" + }`, + ); +} + +function boardActor(companyId: string): Express.Request["actor"] { + return { + type: "board", + userId: "user-1", + source: "session", + isInstanceAdmin: true, + companyIds: [companyId], + memberships: [{ companyId, membershipRole: "admin", status: "active" }], + }; +} + +function createApp(db: ReturnType, actor: Express.Request["actor"]) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api", projectRoutes(db)); + app.use(errorHandler); + return app; +} + +describeEmbeddedPostgres("GET /projects/:id reference resolution", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-project-get-reference-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(projects); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seed() { + const companyId = randomUUID(); + const projectId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Demo Project", + status: "in_progress", + }); + return { companyId, projectId }; + } + + it("resolves a project shortname to the project when companyId is given", async () => { + const { companyId, projectId } = await seed(); + const app = createApp(db, boardActor(companyId)); + + const res = await request(app).get(`/api/projects/demo-project?companyId=${companyId}`); + + expect(res.status).toBe(200); + expect(res.body.id).toBe(projectId); + }); + + it("returns 404 instead of 500 for an unknown shortname", async () => { + const { companyId } = await seed(); + const app = createApp(db, boardActor(companyId)); + + const res = await request(app).get(`/api/projects/no-such-project?companyId=${companyId}`); + + expect(res.status).toBe(404); + }); + + it("returns 404 instead of 500 for a non-uuid ref without company context", async () => { + const { companyId } = await seed(); + const app = createApp(db, boardActor(companyId)); + + const res = await request(app).get("/api/projects/demo-project"); + + expect(res.status).toBe(404); + }); + + it("still returns 404 for an unknown uuid", async () => { + const { companyId } = await seed(); + const app = createApp(db, boardActor(companyId)); + + const res = await request(app).get(`/api/projects/${randomUUID()}?companyId=${companyId}`); + + expect(res.status).toBe(404); + }); +}); diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index f3226c7d5c..9d20e4d882 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -17,7 +17,7 @@ import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } fr import { trackProjectCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { accessService, projectService, logActivity, workspaceOperationService } from "../services/index.js"; -import { conflict, forbidden, unprocessable } from "../errors.js"; +import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { externalObjectService } from "../services/external-objects.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js"; @@ -122,12 +122,13 @@ export function projectRoutes(db: Db) { async function normalizeProjectReference(req: Request, rawId: string) { if (isUuidLike(rawId)) return rawId; const companyId = await resolveCompanyIdForProjectReference(req); - if (!companyId) return rawId; + if (!companyId) throw notFound("Project not found"); const resolved = await svc.resolveByReference(companyId, rawId); if (resolved.ambiguous) { throw conflict("Project shortname is ambiguous in this company. Use the project ID."); } - return resolved.project?.id ?? rawId; + if (!resolved.project) throw notFound("Project not found"); + return resolved.project.id; } async function assertProjectReadAllowed(req: Request, res: Response, project: { id: string; companyId: string }) {