Skip to content
Draft
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
78 changes: 78 additions & 0 deletions apps/builder/__tests__/conversation-workspace-token-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest"

const mockFindWorkspace = vi.fn()
const mockListConversationsForAPI = vi.fn()

vi.mock("@chatbotx.io/business", () => ({
workspaceService: { find: mockFindWorkspace },
}))

vi.mock("@/features/conversations/queries/list-conversations.query", () => ({
listConversationsForAPI: mockListConversationsForAPI,
}))

vi.mock("@/lib/auth/auth", () => ({
auth: { api: { getSession: vi.fn() } },
}))

process.env.REALTIME_BROADCAST_SECRET =
"test-broadcast-secret-with-at-least-32-characters"

const { call } = await import("@orpc/server")
const { conversationWorkspaceTokenAPIs } = await import(
"@/features/conversations/api/workspace-token"
)

const procedure =
conversationWorkspaceTokenAPIs.listConversationsWorkspaceTokenAPI
const emptyQuery = {
status: undefined,
tags: undefined,
contactFilter: undefined,
}

describe("listConversationsWorkspaceTokenAPI", () => {
beforeEach(() => {
vi.clearAllMocks()
mockFindWorkspace.mockResolvedValue({ id: "ws-1" })
mockListConversationsForAPI.mockResolvedValue({
data: [],
nextCursor: null,
prevCursor: null,
})
})

test("uses the token-authorized workspace without a browser session", async () => {
const result = await call(procedure, emptyQuery, {
context: {
headers: new Headers({ authorization: "Bearer developer-token" }),
},
})

expect(mockFindWorkspace).toHaveBeenCalledWith({
where: { token: "developer-token" },
})
expect(mockListConversationsForAPI).toHaveBeenCalledWith({
workspaceId: "ws-1",
})
expect(result).toEqual({
data: [],
nextCursor: null,
prevCursor: null,
})
})

test("rejects an invalid workspace token", async () => {
mockFindWorkspace.mockResolvedValue(null)

await expect(
call(procedure, emptyQuery, {
context: {
headers: new Headers({ authorization: "Bearer invalid-token" }),
},
}),
).rejects.toMatchObject({ code: "INVALID_CHATBOT_TOKEN" })
expect(mockListConversationsForAPI).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import z from "zod"
import { contactFilterCriteriaSchema } from "@/features/contact-filter"
import { cursorPaginationRequest } from "@/lib/pagination"
import { workspaceTokenAuthAPI } from "@/orpc"
import { listConversations } from "../queries/list-conversations.query"
import { listConversationsForAPI } from "../queries/list-conversations.query"
import { listConversationsResponse } from "../schema/resource"

function jsonQueryParam<T>(schema: z.ZodType<T>) {
Expand Down Expand Up @@ -61,7 +61,7 @@ export const conversationWorkspaceTokenAPIs = {
.output(listConversationsResponse)
.handler(
async ({ context, input }) =>
await listConversations({
await listConversationsForAPI({
...input,
workspaceId: context.workspace.id,
}),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// @vitest-environment node
import { describe, expect, test, vi } from "vitest"
import { beforeEach, describe, expect, test, vi } from "vitest"

vi.mock("@chatbotx.io/business", () => ({
conversationService: { findManyQuery: vi.fn() },
Expand All @@ -25,6 +25,20 @@ vi.mock("@/lib/pagination", () => ({
}))

const { buildConversationWhere } = await import("../build-conversation-where")
const { listConversations, listConversationsForAPI } = await import(
"../list-conversations.query"
)
const { conversationService } = await import("@chatbotx.io/business")
const { createMessageRepository } = await import(
"@chatbotx.io/database/repositories"
)
const { assertCurrentUserCanAccessChatbot } = await import("@/lib/auth/utils")

const findManyQueryMock = vi.mocked(conversationService.findManyQuery)
const createMessageRepositoryMock = vi.mocked(createMessageRepository)
const assertCurrentUserCanAccessChatbotMock = vi.mocked(
assertCurrentUserCanAccessChatbot,
)

const baseInput = {
perPage: 20,
Expand All @@ -35,6 +49,44 @@ const baseInput = {
tags: [],
}

beforeEach(() => {
vi.clearAllMocks()
findManyQueryMock.mockResolvedValue([])
createMessageRepositoryMock.mockResolvedValue({
findLastByConversation: vi.fn(),
} as never)
})

describe("listConversations workspace scope", () => {
test("requires workspace membership for session-authenticated callers", async () => {
await listConversations({
...baseInput,
workspaceId: "1",
})

expect(assertCurrentUserCanAccessChatbotMock).toHaveBeenCalledWith("1")
})

test("uses the workspace already authorized by API middleware", async () => {
const response = await listConversationsForAPI({
...baseInput,
workspaceId: "1",
})

expect(response).toEqual({
data: [],
nextCursor: null,
prevCursor: null,
})
expect(assertCurrentUserCanAccessChatbotMock).not.toHaveBeenCalled()
expect(findManyQueryMock).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ workspaceId: "1" }),
}),
)
})
})

describe("buildConversationWhere channel filter", () => {
test("does not restrict by contactInboxes when channel is the omnichannel sentinel", () => {
const where = buildConversationWhere(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,23 @@ type ConversationCursor = z.infer<typeof conversationCursorSchema>

export const listConversations = async (
data: ListConversationsRequest,
): Promise<ListConversationsResponse> => {
await assertCurrentUserCanAccessChatbot(data.workspaceId)
return await queryConversations(data)
}

export const listConversationsForAPI = async (
data: ListConversationsRequest,
): Promise<ListConversationsResponse> => {
// Workspace-token callers are authorized by workspaceTokenAuthAPI, which
// derives workspaceId from the token rather than from request input.
return await queryConversations(data)
}

const queryConversations = async (
data: ListConversationsRequest,
): Promise<ListConversationsResponse> => {
const { workspaceId, ...input } = data
await assertCurrentUserCanAccessChatbot(workspaceId)

const limit = input.perPage ?? DEFAULT_PER_PAGE
const cursor = input.cursor
Expand Down
2 changes: 1 addition & 1 deletion apps/builder/src/middlewares/workspace-token-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const workspaceTokenAuthMidddleware = base.middleware(
throw new ORPCError("INVALID_CHATBOT_TOKEN")
}

// Adds session and user to the context
// Adds the token-authorized workspace to the context.
return await next({
context: {
workspace,
Expand Down