diff --git a/apps/server/src/app/identity/projects.resolver.spec.ts b/apps/server/src/app/identity/projects.resolver.spec.ts new file mode 100644 index 000000000..6445416e3 --- /dev/null +++ b/apps/server/src/app/identity/projects.resolver.spec.ts @@ -0,0 +1,110 @@ +import { ForbiddenException } from "@nestjs/common"; +import { ProjectsResolver } from "./projects.resolver"; +import { RequestUser } from "./users.types"; + +jest.mock("./identity.utils", () => ({ + isOrgAdminOrThrow: jest.fn(), + isOrgMemberOrThrow: jest.fn(), +})); + +jest.mock("@pezzo/common", () => ({ + slugify: jest.fn(), +})); + +import { isOrgMemberOrThrow } from "./identity.utils"; +import { slugify } from "@pezzo/common"; + +describe("ProjectsResolver - createProject authorization", () => { + const projectsService = { + getProjectBySlug: jest.fn(), + createProject: jest.fn(), + } as any; + + const logger = { + assign: jest.fn().mockReturnThis(), + info: jest.fn(), + error: jest.fn(), + } as any; + + const analytics = { + trackEvent: jest.fn(), + } as any; + + let resolver: ProjectsResolver; + + const user = { + userId: "user-1", + orgMemberships: [ + { + organizationId: "org-1", + role: "Member", + }, + ], + } as unknown as RequestUser; + + const input = { + name: "My Test Project", + organizationId: "org-1", + }; + + beforeEach(() => { + jest.clearAllMocks(); + resolver = new ProjectsResolver(projectsService, logger, analytics); + (isOrgMemberOrThrow as jest.Mock).mockImplementation(() => undefined); + (slugify as jest.Mock).mockReturnValue("my-test-project"); + projectsService.getProjectBySlug.mockResolvedValue(null); + projectsService.createProject.mockResolvedValue({ + id: "project-1", + name: input.name, + slug: "my-test-project", + organizationId: input.organizationId, + }); + }); + + it("enforces org membership check with current user and organizationId", async () => { + await resolver.createProject(user, input as any); + + expect(isOrgMemberOrThrow).toHaveBeenCalledTimes(1); + expect(isOrgMemberOrThrow).toHaveBeenCalledWith(user, input.organizationId); + }); + + it("blocks unauthorized users before slug generation and service calls", async () => { + (isOrgMemberOrThrow as jest.Mock).mockImplementation(() => { + throw new ForbiddenException("Forbidden resource"); + }); + + await expect(resolver.createProject(user, input as any)).rejects.toThrow( + ForbiddenException + ); + + expect(slugify).not.toHaveBeenCalled(); + expect(projectsService.getProjectBySlug).not.toHaveBeenCalled(); + expect(projectsService.createProject).not.toHaveBeenCalled(); + expect(analytics.trackEvent).not.toHaveBeenCalled(); + }); + + it("keeps existing create flow for authorized users", async () => { + const created = await resolver.createProject(user, input as any); + + expect(slugify).toHaveBeenCalledWith(input.name); + expect(projectsService.getProjectBySlug).toHaveBeenCalledWith( + "my-test-project", + input.organizationId + ); + expect(projectsService.createProject).toHaveBeenCalledWith( + input.name, + "my-test-project", + input.organizationId + ); + expect(analytics.trackEvent).toHaveBeenCalledWith("project_created", { + projectId: "project-1", + name: input.name, + }); + expect(created).toEqual({ + id: "project-1", + name: input.name, + slug: "my-test-project", + organizationId: input.organizationId, + }); + }); +}); diff --git a/apps/server/src/app/identity/projects.resolver.ts b/apps/server/src/app/identity/projects.resolver.ts index 424546c45..5d6a62758 100644 --- a/apps/server/src/app/identity/projects.resolver.ts +++ b/apps/server/src/app/identity/projects.resolver.ts @@ -78,19 +78,21 @@ export class ProjectsResolver { } @Mutation(() => Project) - async createProject(@Args("data") data: CreateProjectInput) { + async createProject( + @CurrentUser() user: RequestUser, + @Args("data") data: CreateProjectInput + ) { const { organizationId, name } = data; + isOrgMemberOrThrow(user, organizationId); + this.logger.assign({ organizationId, name }).info("Creating project"); - const slug = slugify(data.name); + const slug = slugify(name); let exists: Project; try { - exists = await this.projectsService.getProjectBySlug( - slug, - data.organizationId - ); + exists = await this.projectsService.getProjectBySlug(slug, organizationId); } catch (error) { this.logger.error({ error }, "Error checking for existing project"); throw new InternalServerErrorException(); @@ -102,9 +104,9 @@ export class ProjectsResolver { try { const project = await this.projectsService.createProject( - data.name, + name, slug, - data.organizationId + organizationId ); this.analytics.trackEvent("project_created", {