From f0af2955316e7bf1631a2f67849af263817384bf Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 3 Apr 2026 04:46:34 +1100 Subject: [PATCH] PM-4211: add M2M project member write regression tests What was broken QA reported that M2M POST, PATCH, and DELETE requests against /v6/projects/{projectId}/members were still returning 403 responses after the earlier permission changes. Root cause (if identifiable) The earlier fix path added M2M write-scope handling in the permission layer, but the exact guarded HTTP flows for the project-member write endpoints were not covered by regression tests, so the QA scenario was not exercised directly. What was changed Added explicit project-member e2e coverage for client-credentials tokens with project-member write scope on POST, PATCH, and DELETE routes. Verified the attached M2M token shape still resolves to the expected write permissions on the current dev baseline before adding the regression coverage. Any added/updated tests Updated test/project-member.e2e-spec.ts with M2M POST/PATCH/DELETE coverage. Validated the updated spec with pnpm test:e2e -- --runInBand test/project-member.e2e-spec.ts. --- test/project-member.e2e-spec.ts | 80 +++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/test/project-member.e2e-spec.ts b/test/project-member.e2e-spec.ts index 2bcd9db..568fecc 100644 --- a/test/project-member.e2e-spec.ts +++ b/test/project-member.e2e-spec.ts @@ -225,6 +225,86 @@ describe('Project Member endpoints (e2e)', () => { expect(projectMemberServiceMock.deleteMember).toHaveBeenCalled(); }); + it('creates members for m2m token with project-member write scope', async () => { + (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce({ + scopes: [Scope.PROJECT_MEMBERS_WRITE], + isMachine: true, + tokenPayload: { + gty: 'client-credentials', + scope: Scope.PROJECT_MEMBERS_WRITE, + }, + }); + + await request(app.getHttpServer()) + .post('/v6/projects/1001/members') + .set('Authorization', 'Bearer m2m-member-write') + .send({ userId: '101125', role: 'observer' }) + .expect(201); + + expect(projectMemberServiceMock.addMember).toHaveBeenCalledWith( + '1001', + expect.objectContaining({ userId: '101125', role: 'observer' }), + expect.objectContaining({ + scopes: [Scope.PROJECT_MEMBERS_WRITE], + isMachine: true, + }), + undefined, + ); + }); + + it('updates members for m2m token with project-member write scope', async () => { + (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce({ + scopes: [Scope.PROJECT_MEMBERS_WRITE], + isMachine: true, + tokenPayload: { + gty: 'client-credentials', + scope: Scope.PROJECT_MEMBERS_WRITE, + }, + }); + + await request(app.getHttpServer()) + .patch('/v6/projects/1001/members/11') + .set('Authorization', 'Bearer m2m-member-write') + .send({ role: 'observer' }) + .expect(200); + + expect(projectMemberServiceMock.updateMember).toHaveBeenCalledWith( + '1001', + '11', + expect.objectContaining({ role: 'observer' }), + expect.objectContaining({ + scopes: [Scope.PROJECT_MEMBERS_WRITE], + isMachine: true, + }), + undefined, + ); + }); + + it('deletes members for m2m token with project-member write scope', async () => { + (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce({ + scopes: [Scope.PROJECT_MEMBERS_WRITE], + isMachine: true, + tokenPayload: { + gty: 'client-credentials', + scope: Scope.PROJECT_MEMBERS_WRITE, + }, + }); + + await request(app.getHttpServer()) + .delete('/v6/projects/1001/members/11') + .set('Authorization', 'Bearer m2m-member-write') + .expect(204); + + expect(projectMemberServiceMock.deleteMember).toHaveBeenCalledWith( + '1001', + '11', + expect.objectContaining({ + scopes: [Scope.PROJECT_MEMBERS_WRITE], + isMachine: true, + }), + ); + }); + it('lists members for m2m token with project-member read scope', async () => { (jwtServiceMock.validateToken as jest.Mock).mockResolvedValueOnce({ scopes: [Scope.PROJECT_MEMBERS_READ],