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
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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: '[email protected]'
}

const mockSync = {
id: 'sync-id',
journeyId: 'journey-id',
teamId: 'team-id',
integrationId: 'integration-id',
spreadsheetId: 'spreadsheet-id',
sheetName: 'Sheet1',
folderId: null,
email: '[email protected]',
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' }]
}
}

Expand All @@ -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,
Expand All @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
})
}
})
)
Loading