diff --git a/apis/api-journeys-modern/src/schema/googleSheetsSync/googleSheetsSyncCreate.mutation.spec.ts b/apis/api-journeys-modern/src/schema/googleSheetsSync/googleSheetsSyncCreate.mutation.spec.ts index 3e39f058cac..5873157d916 100644 --- a/apis/api-journeys-modern/src/schema/googleSheetsSync/googleSheetsSyncCreate.mutation.spec.ts +++ b/apis/api-journeys-modern/src/schema/googleSheetsSync/googleSheetsSyncCreate.mutation.spec.ts @@ -48,14 +48,9 @@ describe('googleSheetsSyncCreate', () => { userId: mockUser.id, roles: [] } as any) - // Default auth: user is integration owner for provided integrationId - prismaMock.integration.findUnique.mockResolvedValue({ - id: 'integration-id', - userId: 'userId' - } as any) }) - it('should create Google Sheets sync', async () => { + it('should create Google Sheets sync when user is integration owner', async () => { const mockJourney = { id: 'journey-id', teamId: 'team-id', @@ -245,14 +240,73 @@ describe('googleSheetsSyncCreate', () => { }) }) - it('should throw error when user is not the integration owner', async () => { + it('should create sync when user is team manager but not integration owner', async () => { const mockJourney = { id: 'journey-id', teamId: 'team-id', team: { id: 'team-id', integrations: [], - userTeams: [] + userTeams: [{ userId: 'userId', role: 'manager' }] + } + } + + const mockIntegration = { + id: 'integration-id', + userId: 'other-user-id', + teamId: 'team-id', + type: 'google' as const, + accountEmail: 'other@example.com' + } + + const mockSync = { + id: 'sync-id', + journeyId: 'journey-id', + teamId: 'team-id', + integrationId: 'integration-id', + spreadsheetId: 'spreadsheet-id', + sheetName: 'Sheet1', + folderId: null, + email: 'other@example.com', + deletedAt: null + } + + prismaMock.journey.findUnique.mockResolvedValue(mockJourney as any) + prismaMock.integration.findFirst.mockResolvedValue(mockIntegration as any) + prismaMock.googleSheetsSync.create.mockResolvedValue(mockSync as any) + + const result = await authClient({ + document: GOOGLE_SHEETS_SYNC_CREATE_MUTATION, + variables: { + input: { + journeyId: 'journey-id', + integrationId: 'integration-id', + spreadsheetId: 'spreadsheet-id', + sheetName: 'Sheet1' + } + } + }) + + expect(result).toEqual({ + data: { + googleSheetsSyncCreate: expect.objectContaining({ + id: 'sync-id', + journeyId: 'journey-id', + spreadsheetId: 'spreadsheet-id', + sheetName: 'Sheet1' + }) + } + }) + }) + + it('should throw Forbidden when user is neither integration owner nor team manager', async () => { + const mockJourney = { + id: 'journey-id', + teamId: 'team-id', + team: { + id: 'team-id', + integrations: [], + userTeams: [{ userId: 'other-user-id', role: 'manager' }] } } @@ -265,11 +319,50 @@ describe('googleSheetsSyncCreate', () => { prismaMock.journey.findUnique.mockResolvedValue(mockJourney as any) prismaMock.integration.findFirst.mockResolvedValue(mockIntegration as any) - // Auth guard denies ownership - prismaMock.integration.findUnique.mockResolvedValue({ + + const result = await authClient({ + document: GOOGLE_SHEETS_SYNC_CREATE_MUTATION, + variables: { + input: { + journeyId: 'journey-id', + integrationId: 'integration-id', + spreadsheetId: 'spreadsheet-id', + sheetName: 'Sheet1' + } + } + }) + + expect(result).toEqual({ + data: null, + errors: [ + expect.objectContaining({ + message: 'Forbidden' + }) + ] + }) + expect(prismaMock.googleSheetsSync.create).not.toHaveBeenCalled() + }) + + it('should throw Forbidden when user is a team member but not manager or owner', async () => { + const mockJourney = { + id: 'journey-id', + teamId: 'team-id', + team: { + id: 'team-id', + integrations: [], + userTeams: [{ userId: 'userId', role: 'member' }] + } + } + + const mockIntegration = { id: 'integration-id', - userId: 'other-user-id' - } as any) + userId: 'other-user-id', + teamId: 'team-id', + type: 'google' as const + } + + prismaMock.journey.findUnique.mockResolvedValue(mockJourney as any) + prismaMock.integration.findFirst.mockResolvedValue(mockIntegration as any) const result = await authClient({ document: GOOGLE_SHEETS_SYNC_CREATE_MUTATION, @@ -287,10 +380,11 @@ describe('googleSheetsSyncCreate', () => { data: null, errors: [ expect.objectContaining({ - message: expect.stringContaining('Not authorized') + message: 'Forbidden' }) ] }) + expect(prismaMock.googleSheetsSync.create).not.toHaveBeenCalled() }) it('should throw error when user lacks export permission', async () => { diff --git a/apis/api-journeys-modern/src/schema/googleSheetsSync/googleSheetsSyncCreate.mutation.ts b/apis/api-journeys-modern/src/schema/googleSheetsSync/googleSheetsSyncCreate.mutation.ts index f4dfa922b42..4bb6d382f40 100644 --- a/apis/api-journeys-modern/src/schema/googleSheetsSync/googleSheetsSyncCreate.mutation.ts +++ b/apis/api-journeys-modern/src/schema/googleSheetsSync/googleSheetsSyncCreate.mutation.ts @@ -11,67 +11,73 @@ import { CreateGoogleSheetsSyncInput } from './inputs' export const GoogleSheetsSyncCreateMutation = builder.mutationField( 'googleSheetsSyncCreate', (t) => - t - .withAuth((_parent, args) => ({ - $all: { - isAuthenticated: true, - isIntegrationOwner: ( - args.input as typeof CreateGoogleSheetsSyncInput.$inferInput - ).integrationId - } - })) - .prismaField({ - type: GoogleSheetsSync, - nullable: false, - args: { - input: t.arg({ type: CreateGoogleSheetsSyncInput, required: true }) - }, - resolve: async (query, _parent, { input }, context) => { - const journey = await prisma.journey.findUnique({ - where: { id: input.journeyId }, - include: { - team: { include: { integrations: true, userTeams: true } } - } + t.withAuth({ isAuthenticated: true }).prismaField({ + type: GoogleSheetsSync, + nullable: false, + args: { + input: t.arg({ type: CreateGoogleSheetsSyncInput, required: true }) + }, + resolve: async (query, _parent, { input }, context) => { + const journey = await prisma.journey.findUnique({ + where: { id: input.journeyId }, + include: { + team: { include: { integrations: true, userTeams: true } } + } + }) + if (journey == null) + throw new GraphQLError('Journey not found', { + extensions: { code: 'NOT_FOUND' } }) - if (journey == null) - throw new GraphQLError('Journey not found', { - extensions: { code: 'NOT_FOUND' } - }) - const googleIntegration = await prisma.integration.findFirst({ - where: { - id: input.integrationId, - teamId: journey.teamId, - type: 'google' - } + const googleIntegration = await prisma.integration.findFirst({ + where: { + id: input.integrationId, + teamId: journey.teamId, + type: 'google' + } + }) + if (googleIntegration == null) + throw new GraphQLError('Google integration not found for team', { + extensions: { code: 'BAD_REQUEST' } }) - if (googleIntegration == null) - throw new GraphQLError('Google integration not found for team', { - extensions: { code: 'BAD_REQUEST' } - }) - // Must also have export ability on the journey - if ( - !ability(Action.Export, subject('Journey', journey), context.user) - ) { - throw new GraphQLError('Forbidden', { - extensions: { code: 'FORBIDDEN' } - }) - } + // Check permissions: must be team manager or integration owner + const userId = context.user.id + const isTeamManager = + journey.team?.userTeams?.some( + (userTeam) => + userTeam.userId === userId && userTeam.role === 'manager' + ) ?? false + const isIntegrationOwner = googleIntegration.userId === userId - return await prisma.googleSheetsSync.create({ - ...query, - data: { - teamId: journey.teamId, - journeyId: journey.id, - integrationId: googleIntegration.id, - spreadsheetId: input.spreadsheetId, - sheetName: input.sheetName, - folderId: input.folderId ?? null, - email: googleIntegration.accountEmail ?? null, - deletedAt: null - } + if (!(isIntegrationOwner || isTeamManager)) { + throw new GraphQLError('Forbidden', { + extensions: { code: 'FORBIDDEN' } }) } - }) + + // Must also have export ability on the journey + if ( + !ability(Action.Export, subject('Journey', journey), context.user) + ) { + throw new GraphQLError('Forbidden', { + extensions: { code: 'FORBIDDEN' } + }) + } + + return await prisma.googleSheetsSync.create({ + ...query, + data: { + teamId: journey.teamId, + journeyId: journey.id, + integrationId: googleIntegration.id, + spreadsheetId: input.spreadsheetId, + sheetName: input.sheetName, + folderId: input.folderId ?? null, + email: googleIntegration.accountEmail ?? null, + deletedAt: null + } + }) + } + }) )