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
@@ -0,0 +1,168 @@
import { describe, expect, it, vi } from 'vitest'

import { ForbiddenError } from '@/errors/graphql-errors'
import getFlowConnections from '@/graphql/queries/get-flow-connections'
import type Context from '@/types/express/context'

vi.mock('@/models/app', () => ({
default: {
findAll: vi.fn(() => [
{ key: 'slack', name: 'Slack', iconUrl: 'https://example.com/slack.png' },
{
key: 'telegram-bot',
name: 'Telegram',
iconUrl: 'https://example.com/telegram.png',
},
{
key: 'tiles',
name: 'Tables',
iconUrl: 'https://example.com/tiles.png',
},
]),
},
}))

const mockFlowConnectionsQuery = vi.fn()

vi.mock('@/models/flow-connections', () => ({
default: {
query: () => mockFlowConnectionsQuery(),
},
}))

const mockFlow = {
id: 'flow-123',
role: 'editor',
}

const context = {
currentUser: {
withAccessibleFlows: vi.fn().mockReturnValue({
findById: vi.fn().mockReturnValue(mockFlow),
}),
},
} as unknown as Context

describe('getFlowConnections', () => {
it('should use connection screenName and app key for regular connections', async () => {
mockFlowConnectionsQuery.mockReturnValue({
where: vi.fn().mockReturnThis(),
withGraphFetched: vi.fn().mockResolvedValue([
{
flowId: 'flow-123',
connectionId: 'conn-456',
connectionType: 'connection',
connection: {
key: 'slack',
formattedData: {
screenName: 'My Slack Workspace',
},
},
user: { email: 'owner@open.gov.sg' },
},
]),
})

const result = await getFlowConnections({}, { flowId: 'flow-123' }, context)

expect(result).toEqual([
{
flowId: 'flow-123',
connectionId: 'conn-456',
connectionType: 'connection',
addedBy: 'owner@open.gov.sg',
appName: 'Slack',
appIconUrl: 'https://example.com/slack.png',
connectionName: 'My Slack Workspace',
},
])
})

it('should use table name and "tiles" as appKey for table connections', async () => {
mockFlowConnectionsQuery.mockReturnValue({
where: vi.fn().mockReturnThis(),
withGraphFetched: vi.fn().mockResolvedValue([
{
flowId: 'flow-123',
connectionId: 'table-789',
connectionType: 'table',
table: {
name: 'My Customer Table',
},
user: { email: 'owner@open.gov.sg' },
},
]),
})

const result = await getFlowConnections({}, { flowId: 'flow-123' }, context)

expect(result).toEqual([
{
flowId: 'flow-123',
connectionId: 'table-789',
connectionType: 'table',
addedBy: 'owner@open.gov.sg',
appName: 'Tables',
appIconUrl: 'https://example.com/tiles.png',
connectionName: 'My Customer Table',
},
])
})

it('should handle mixed connection types correctly', async () => {
mockFlowConnectionsQuery.mockReturnValue({
where: vi.fn().mockReturnThis(),
withGraphFetched: vi.fn().mockResolvedValue([
{
flowId: 'flow-123',
connectionId: 'conn-456',
connectionType: 'connection',
connection: {
key: 'telegram-bot',
formattedData: {
screenName: 'My Telegram Bot',
},
},
user: {
email: 'owner@open.gov.sg',
},
},
{
flowId: 'flow-123',
connectionId: 'table-789',
connectionType: 'table',
table: {
name: 'Sales Data',
},
user: {
email: 'owner@open.gov.sg',
},
},
]),
})

const result = await getFlowConnections({}, { flowId: 'flow-123' }, context)

expect(result).toHaveLength(2)
expect(result[0]).toMatchObject({
connectionType: 'connection',
appName: 'Telegram',
connectionName: 'My Telegram Bot',
})
expect(result[1]).toMatchObject({
connectionType: 'table',
appName: 'Tables',
connectionName: 'Sales Data',
})
})

it('should throw a ForbiddenError if the user does not have access to the flow', async () => {
context.currentUser.withAccessibleFlows = vi.fn().mockReturnValue({
findById: vi.fn().mockReturnValue(null),
})

await expect(
getFlowConnections({}, { flowId: 'flow-123' }, context),
).rejects.toThrow(ForbiddenError)
})
})
77 changes: 77 additions & 0 deletions packages/backend/src/graphql/queries/get-flow-connections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { IApp } from '@/../../types'
import { ForbiddenError } from '@/errors/graphql-errors'
import App from '@/models/app'
import FlowConnections from '@/models/flow-connections'

import { QueryResolvers } from '../__generated__/types.generated'

const getFlowConnections: QueryResolvers['getFlowConnections'] = async (
_parent,
params,
context,
) => {
const apps = await App.findAll()

const flow = await context.currentUser
.withAccessibleFlows({ requiredRole: 'editor' })
.findById(params.flowId)

if (!flow) {
throw new ForbiddenError(
'You do not have sufficient permissions for this pipe',
)
}

const rawFlowConnections = await FlowConnections.query()
.where({
flow_id: params.flowId,
})
.withGraphFetched({
connection: true,
user: true,
table: true,
})

const filteredFlowConnections = rawFlowConnections.filter(
(flowConnection) => {
if (flowConnection.connectionType === 'table') {
return !!flowConnection.table
}
return !!flowConnection.connection
},
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Filter doesn't match connectionType, causing undefined connectionName

The filter keeps records where connection OR table exists, but the subsequent logic uses connectionType to determine which relation to read from. If connectionType is 'table' but only connection exists (or vice versa), the record passes the filter but connectionName becomes undefined. The GraphQL schema declares connectionName: String! as non-nullable, so returning undefined will cause a runtime error. The filter needs to verify the correct relation exists based on connectionType.

Additional Locations (1)

Fix in Cursor Fix in Web


const flowConnections = await Promise.all(
filteredFlowConnections.map(async (flowConnection) => {
let connectionName = flowConnection?.connection?.formattedData?.screenName
if (flowConnection.connectionType === 'table' && flowConnection.table) {
connectionName = flowConnection.table?.name
}

const appKey = flowConnection.connection?.key
const app = apps.find(
(app: IApp) =>
app.key ===
(flowConnection.connectionType === 'table' ? 'tiles' : appKey),
)

if (!app) {
throw new Error(`App not found for key: ${appKey}`)
}
Comment on lines +51 to +60
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message will be misleading for table connections. When connectionType === 'table', appKey is undefined (since flowConnection.connection doesn't exist), but the actual key being searched for is 'tiles'. The error will say "App not found for key: undefined" instead of "App not found for key: tiles".

Fix:

const appKey = flowConnection.connectionType === 'table' 
  ? 'tiles' 
  : flowConnection.connection?.key

const app = apps.find((app: IApp) => app.key === appKey)

if (!app) {
  throw new Error(`App not found for key: ${appKey}`)
}

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.


return {
flowId: flowConnection.flowId,
connectionId: flowConnection.connectionId,
connectionType: flowConnection.connectionType,
addedBy: flowConnection?.user?.email || '',
appName: app.name,
appIconUrl: app.iconUrl,
Comment thread
kevinkim-ogp marked this conversation as resolved.
connectionName: connectionName as string,
}
}),
)

return flowConnections
}

export default getFlowConnections
2 changes: 2 additions & 0 deletions packages/backend/src/graphql/query-resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import getExecution from './queries/get-execution'
import getExecutionSteps from './queries/get-execution-steps'
import getExecutions from './queries/get-executions'
import getFlow from './queries/get-flow'
import getFlowConnections from './queries/get-flow-connections'
import getFlowTransferDetails from './queries/get-flow-transfer-details'
import getFlows from './queries/get-flows'
import getPendingFlowTransfers from './queries/get-pending-flow-transfers'
Expand Down Expand Up @@ -41,6 +42,7 @@ export default {
getCurrentUser,
healthcheck,
getPendingFlowTransfers,
getFlowConnections,
getFlowTransferDetails,
getTemplates,
...tilesQueryResolvers,
Expand Down
11 changes: 11 additions & 0 deletions packages/backend/src/graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type Query {
key: String!
parameters: JSONObject
): [JSONObject]
getFlowConnections(flowId: String!): [SharedFlowConnection!]!
# Tiles
getTable(tableId: String!): TableMetadata!
getTableConnections(tableIds: [String!]!): JSONObject
Expand Down Expand Up @@ -508,6 +509,16 @@ type FlowAttachmentConfig {
updatedAt: String!
}

type SharedFlowConnection {
flowId: String!
connectionId: String!
connectionType: String!
appName: String!
appIconUrl: String!
addedBy: String!
connectionName: String!
}

type Execution {
id: String
testRun: Boolean
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/models/flow-connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class FlowConnections extends Base {
connectionType!: 'connection' | 'table'
connection?: Connection
table?: TableMetadata
user?: User
metadata: Record<string, any>
flow?: Flow

Expand Down
Loading