From d478b8ab0633f90abca76b762977129eb527c35e Mon Sep 17 00:00:00 2001 From: Alexis Jamet Date: Fri, 3 Apr 2026 11:43:36 +0200 Subject: [PATCH] fix: filter form sessions by userId (+tests) --- .../common/test/test-transaction-manager.ts | 3 + .../e2e-tests/auth.spec.ts | 6 +- .../e2e-tests/auth.spec.ts | 223 ++++++++++++++++++ .../e2e-tests/get-all.spec.ts | 153 ++++++++++++ .../form-agent-session.factory.ts | 44 ++++ .../form-agent-sessions.controller.ts | 3 +- .../form-agent-sessions.service.ts | 4 +- .../organizations/organization.factory.ts | 16 +- 8 files changed, 444 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/domains/agents/form-agent-sessions/e2e-tests/auth.spec.ts create mode 100644 apps/api/src/domains/agents/form-agent-sessions/e2e-tests/get-all.spec.ts create mode 100644 apps/api/src/domains/agents/form-agent-sessions/form-agent-session.factory.ts diff --git a/apps/api/src/common/test/test-transaction-manager.ts b/apps/api/src/common/test/test-transaction-manager.ts index 4e001d5..7ed7dd1 100644 --- a/apps/api/src/common/test/test-transaction-manager.ts +++ b/apps/api/src/common/test/test-transaction-manager.ts @@ -14,6 +14,7 @@ import { DataSource, EntityManager } from "typeorm" import { Agent } from "@/domains/agents/agent.entity" import { ConversationAgentSession } from "@/domains/agents/conversation-agent-sessions/conversation-agent-session.entity" import { ExtractionAgentSession } from "@/domains/agents/extraction-agent-sessions/extraction-agent-session.entity" +import { FormAgentSession } from "@/domains/agents/form-agent-sessions/form-agent-session.entity" import { AgentMembership } from "@/domains/agents/memberships/agent-membership.entity" import { AgentMessage } from "@/domains/agents/shared/agent-session-messages/agent-message.entity" import { AgentMessageFeedback } from "@/domains/agents/shared/agent-session-messages/feedback/agent-message-feedback.entity" @@ -42,6 +43,7 @@ export interface TransactionalTestSetup { agentMembershipRepository: Repository extractionAgentSessionRepository: Repository conversationAgentSessionRepository: Repository + formAgentSessionRepository: Repository documentRepository: Repository evaluationReportRepository: Repository evaluationRepository: Repository @@ -207,6 +209,7 @@ export async function setupTransactionalTestDatabase( agentRepository: getRepository(Agent), extractionAgentSessionRepository: getRepository(ExtractionAgentSession), conversationAgentSessionRepository: getRepository(ConversationAgentSession), + formAgentSessionRepository: getRepository(FormAgentSession), agentMessageRepository: getRepository(AgentMessage), agentMessageFeedbackRepository: getRepository(AgentMessageFeedback), documentRepository: getRepository(Document), diff --git a/apps/api/src/domains/agents/extraction-agent-sessions/e2e-tests/auth.spec.ts b/apps/api/src/domains/agents/extraction-agent-sessions/e2e-tests/auth.spec.ts index a16bcbc..b452e74 100644 --- a/apps/api/src/domains/agents/extraction-agent-sessions/e2e-tests/auth.spec.ts +++ b/apps/api/src/domains/agents/extraction-agent-sessions/e2e-tests/auth.spec.ts @@ -179,8 +179,7 @@ describe("ExtractionAgentSessions - Auth", () => { }) }) - // FIXME: it works with UI but fails in tests - describe.skip("ExtractionAgentSessionsRoutes.getOne", () => { + describe("ExtractionAgentSessionsRoutes.getOne", () => { const subject = async (type: "playground" | "live") => request({ route: ExtractionAgentSessionsRoutes.getOne, @@ -208,8 +207,7 @@ describe("ExtractionAgentSessions - Auth", () => { }) }) - // FIXME: it works with UI but fails in tests - describe.skip("ConversationAgentSessionsRoutes.deleteOne", () => { + describe("ExtractionAgentSessionsRoutes.deleteOne", () => { const subject = async (type: "playground" | "live") => request({ route: ExtractionAgentSessionsRoutes.deleteOne, diff --git a/apps/api/src/domains/agents/form-agent-sessions/e2e-tests/auth.spec.ts b/apps/api/src/domains/agents/form-agent-sessions/e2e-tests/auth.spec.ts new file mode 100644 index 0000000..8af9898 --- /dev/null +++ b/apps/api/src/domains/agents/form-agent-sessions/e2e-tests/auth.spec.ts @@ -0,0 +1,223 @@ +import { randomUUID } from "node:crypto" +import { + FormAgentSessionsRoutes, + type ProjectMembershipRoleDto, +} from "@caseai-connect/api-contracts" +import { afterAll } from "@jest/globals" +import type { INestApplication } from "@nestjs/common" +import type { App } from "supertest/types" +import { AUTH_ERRORS } from "@/common/errors/auth-errors" +import { clearTestDatabase } from "@/common/test/test-database" +import { + type AllRepositories, + setupTransactionalTestDatabase, + teardownTestDatabase, +} from "@/common/test/test-transaction-manager" +import { removeNullish } from "@/common/utils/remove-nullish" +import { createOrganizationWithAgentSession } from "@/domains/organizations/organization.factory" +import { sdk } from "@/external/llm/open-telemetry-init" +import { setupUserGuardForTesting } from "../../../../../test/e2e.helpers" +import { expectResponse, type Requester, testRequester } from "../../../../../test/request" +import { FormAgentSessionsModule } from "../form-agent-sessions.module" + +describe("Agent Sessions - Auth", () => { + let app: INestApplication + let request: Requester + let setup: Awaited> + let repositories: AllRepositories + + // Variables for the tests + let organizationId: string | null = randomUUID() + let projectId: string | null = randomUUID() + let agentId: string | null = randomUUID() + let agentSessionId: string | null = randomUUID() + let accessToken: string | null = "token" + let auth0Id = "auth0|123" + + beforeAll(async () => { + setup = await setupTransactionalTestDatabase({ + additionalImports: [FormAgentSessionsModule], + applyOverrides: (moduleBuilder) => setupUserGuardForTesting(moduleBuilder, () => auth0Id), + }) + repositories = setup.getAllRepositories() + + app = setup.module.createNestApplication() + await app.init() + request = testRequester(app) + }) + + beforeEach(async () => { + await clearTestDatabase(setup.dataSource) + organizationId = randomUUID() + projectId = randomUUID() + agentId = randomUUID() + agentSessionId = randomUUID() + accessToken = "token" + auth0Id = "auth0|123" + }) + + afterAll(async () => { + await teardownTestDatabase(setup) + await sdk.shutdown() + await app.close() + }) + + const createContextForRole = async (role: ProjectMembershipRoleDto) => { + const { user, organization, project, agent, agentSession } = + await createOrganizationWithAgentSession({ + repositories, + params: { + projectMembership: { role }, + }, + agentType: "form", + }) + organizationId = organization.id + projectId = project.id + agentId = agent.id + agentSessionId = agentSession.id + accessToken = "token" + auth0Id = user.auth0Id + } + + describe("FormAgentSessionsRoutes.createOne", () => { + const subject = async (type: "playground" | "live") => + request({ + route: FormAgentSessionsRoutes.createOne, + pathParams: removeNullish({ organizationId, projectId, agentId }), + token: accessToken ?? undefined, + request: { payload: { type } }, + }) + + describe.each([["live"], ["playground"]] as const)("creating a %s session", (type) => { + it("requires an authentication token", async () => { + accessToken = null + expectResponse(await subject(type), 401, AUTH_ERRORS.NO_ACCESS_TOKEN) + }) + + it("requires a valid organization ID", async () => { + organizationId = null + expectResponse(await subject(type), 400, AUTH_ERRORS.NO_ORGANIZATION_ID) + }) + it("requires a valid agent ID", async () => { + await createContextForRole("member") + agentId = null + expectResponse(await subject(type), 404) + }) + + it("requires the user to be a member of the organization", async () => { + await createContextForRole("member") + auth0Id = "another-auth0-id" + expectResponse(await subject(type), 401, AUTH_ERRORS.NOT_MEMBER_OF_ORG) + }) + + if (type === "playground") { + it("doesn't allow members to create playground sessions", async () => { + await createContextForRole("member") + expectResponse(await subject(type), 403, AUTH_ERRORS.UNAUTHORIZED_RESOURCE) + }) + } else { + it("allows members to create live sessions", async () => { + await createContextForRole("member") + expectResponse(await subject(type), 201) + }) + } + + it("allows owners to create live sessions", async () => { + await createContextForRole("owner") + expectResponse(await subject(type), 201) + }) + }) + }) + + describe("FormAgentSessionsRoutes.getAll", () => { + const subject = async (type: "playground" | "live") => + request({ + route: FormAgentSessionsRoutes.getAll, + pathParams: removeNullish({ organizationId, projectId, agentId }), + token: accessToken ?? undefined, + request: { payload: { type } }, + }) + + describe.each([["live"], ["playground"]] as const)("getting %s sessions", (type) => { + it("requires an authentication token", async () => { + accessToken = null + expectResponse(await subject(type), 401, AUTH_ERRORS.NO_ACCESS_TOKEN) + }) + it("requires a valid organization ID", async () => { + organizationId = null + expectResponse(await subject(type), 400, AUTH_ERRORS.NO_ORGANIZATION_ID) + }) + it("requires a valid agent ID", async () => { + await createContextForRole("owner") + agentId = null + expectResponse(await subject(type), 404) + }) + if (type === "playground") { + it("doesn't allow simple member to get playground sessions", async () => { + await createContextForRole("member") + expectResponse(await subject(type), 403, AUTH_ERRORS.UNAUTHORIZED_RESOURCE) + }) + } else { + it("allows members to get live sessions", async () => { + await createContextForRole("member") + expectResponse(await subject(type), 201) + }) + } + it("allows owner to get sessions", async () => { + await createContextForRole("owner") + expectResponse(await subject(type), 201) + }) + it("requires the user to be a member of the organization", async () => { + await createContextForRole("owner") + auth0Id = "another-auth0-id" + expectResponse(await subject(type), 401, AUTH_ERRORS.NOT_MEMBER_OF_ORG) + }) + }) + }) + + describe("FormAgentSessionsRoutes.deleteOne", () => { + const subject = async (type: "playground" | "live") => + request({ + route: FormAgentSessionsRoutes.deleteOne, + pathParams: removeNullish({ organizationId, projectId, agentId, agentSessionId }), + token: accessToken ?? undefined, + request: { payload: { type } }, + }) + + describe.each([["live"], ["playground"]] as const)("deleting a %s session", (type) => { + it("requires an authentication token", async () => { + accessToken = null + expectResponse(await subject(type), 401, AUTH_ERRORS.NO_ACCESS_TOKEN) + }) + it("requires a valid organization ID", async () => { + organizationId = null + expectResponse(await subject(type), 400, AUTH_ERRORS.NO_ORGANIZATION_ID) + }) + it("requires a valid agent ID", async () => { + await createContextForRole("owner") + agentId = null + expectResponse(await subject(type), 404) + }) + it("requires the user to be a member of the organization", async () => { + await createContextForRole("owner") + auth0Id = "another-auth0-id" + expectResponse(await subject(type), 401, AUTH_ERRORS.NOT_MEMBER_OF_ORG) + }) + if (type === "playground") { + it("doesn't allow a simple member to delete playground sessions", async () => { + await createContextForRole("member") + expectResponse(await subject(type), 403, AUTH_ERRORS.UNAUTHORIZED_RESOURCE) + }) + } else { + it("allows member to delete sessions", async () => { + await createContextForRole("member") + expectResponse(await subject(type), 201) + }) + } + it("allows owner to delete sessions", async () => { + await createContextForRole("owner") + expectResponse(await subject(type), 201) + }) + }) + }) +}) diff --git a/apps/api/src/domains/agents/form-agent-sessions/e2e-tests/get-all.spec.ts b/apps/api/src/domains/agents/form-agent-sessions/e2e-tests/get-all.spec.ts new file mode 100644 index 0000000..16c8b15 --- /dev/null +++ b/apps/api/src/domains/agents/form-agent-sessions/e2e-tests/get-all.spec.ts @@ -0,0 +1,153 @@ +import { + type BaseAgentSessionTypeDto, + FormAgentSessionsRoutes, +} from "@caseai-connect/api-contracts" +import type { INestApplication } from "@nestjs/common" +import type { App } from "supertest/types" +import { clearTestDatabase } from "@/common/test/test-database" +import { + type AllRepositories, + setupTransactionalTestDatabase, + teardownTestDatabase, +} from "@/common/test/test-transaction-manager" +import { removeNullish } from "@/common/utils/remove-nullish" +import { createOrganizationWithAgent } from "@/domains/organizations/organization.factory" +import { inviteUserToProject } from "@/domains/projects/memberships/project-membership.factory" +import { sdk } from "@/external/llm/open-telemetry-init" +import { setupUserGuardForTesting } from "../../../../../test/e2e.helpers" +import { expectResponse, type Requester, testRequester } from "../../../../../test/request" +import { formAgentSessionFactory } from "../form-agent-session.factory" +import { FormAgentSessionsModule } from "../form-agent-sessions.module" + +describe("FormAgentSessionsRoutes.getAll", () => { + let app: INestApplication + let request: Requester + let setup: Awaited> + let repositories: AllRepositories + + let organizationId: string + let projectId: string + let agentId: string + let accessToken: string | undefined = "token" + let auth0Id = "auth0|123" + + beforeAll(async () => { + setup = await setupTransactionalTestDatabase({ + additionalImports: [FormAgentSessionsModule], + applyOverrides: (moduleBuilder) => setupUserGuardForTesting(moduleBuilder, () => auth0Id), + }) + repositories = setup.getAllRepositories() + app = setup.module.createNestApplication() + await app.init() + request = testRequester(app) + }) + + beforeEach(async () => { + await clearTestDatabase(setup.dataSource) + accessToken = "token" + auth0Id = "auth0|123" + }) + + afterAll(async () => { + await teardownTestDatabase(setup) + await sdk.shutdown() + await app.close() + }) + + const createContext = async () => { + const { organization, project, agent } = await createOrganizationWithAgent(repositories) + const { invitedUser } = await inviteUserToProject({ repositories, organization, project }) + const { invitedUser: anotherUser } = await inviteUserToProject({ + repositories, + organization, + project, + user: { + email: "another-user@caseai.test", + auth0Id: "auth0|another-user", + }, + }) + + const oldestAppSession = formAgentSessionFactory + .transient({ + organization, + project, + user: invitedUser, + agent, + }) + .live() + .build({ + createdAt: new Date("2024-01-01T10:00:00.000Z"), + }) + + const newestAppSession = formAgentSessionFactory + .transient({ + organization, + project, + user: invitedUser, + agent, + }) + .live() + .build({ + createdAt: new Date("2024-01-01T12:00:00.000Z"), + }) + + const playgroundSession = formAgentSessionFactory + .transient({ + organization, + project, + user: invitedUser, + agent, + }) + .playground() + .build() + + const anotherUserAppSession = formAgentSessionFactory + .transient({ + organization, + project, + user: anotherUser, + agent, + }) + .live() + .build() + + await repositories.formAgentSessionRepository.save([ + oldestAppSession, + newestAppSession, + playgroundSession, + anotherUserAppSession, + ]) + + organizationId = organization.id + projectId = project.id + agentId = agent.id + auth0Id = invitedUser.auth0Id + + return { + oldestAppSession, + newestAppSession, + } + } + + const subject = async (type: BaseAgentSessionTypeDto) => + request({ + route: FormAgentSessionsRoutes.getAll, + pathParams: removeNullish({ organizationId, projectId, agentId }), + token: accessToken, + request: { payload: { type } }, + }) + + it("should return only live sessions for authenticated user sorted by newest first", async () => { + const { oldestAppSession, newestAppSession } = await createContext() + + const response = await subject("live") + + expectResponse(response, 201) + const sessions = response.body.data + expect(sessions).toHaveLength(2) + expect(sessions[0]?.id).toBe(newestAppSession.id) + expect(sessions[1]?.id).toBe(oldestAppSession.id) + expect(sessions.every((session) => session.type === "live")).toBeTruthy() + expect(sessions.every((session) => session.agentId === agentId)).toBeTruthy() + }) +}) diff --git a/apps/api/src/domains/agents/form-agent-sessions/form-agent-session.factory.ts b/apps/api/src/domains/agents/form-agent-sessions/form-agent-session.factory.ts new file mode 100644 index 0000000..398a784 --- /dev/null +++ b/apps/api/src/domains/agents/form-agent-sessions/form-agent-session.factory.ts @@ -0,0 +1,44 @@ +import { randomUUID } from "node:crypto" +import { Factory } from "fishery" +import { v4 } from "uuid" +import type { RequiredScopeTransientParams } from "@/common/entities/connect-required-fields" +import type { Agent } from "@/domains/agents/agent.entity" +import type { User } from "@/domains/users/user.entity" +import type { FormAgentSession } from "./form-agent-session.entity" + +type AgentSessionTransientParams = RequiredScopeTransientParams & { + agent: Agent + user: User +} + +class FormAgentSessionFactory extends Factory { + playground() { + return this.params({ type: "playground" }) + } + + live() { + return this.params({ type: "live" }) + } +} + +export const formAgentSessionFactory = FormAgentSessionFactory.define( + ({ params, transientParams }) => { + const now = new Date() + + return { + id: params.id || randomUUID(), + agentId: transientParams.agent?.id || params.agentId || "no-agent-id", + userId: transientParams.user?.id || params.userId || "no-user-id", + organizationId: + transientParams.organization?.id || params.organizationId || "no-organization-id", + projectId: transientParams.project?.id || params.projectId || "no-project-id", + type: params.type || "playground", + createdAt: params.createdAt || now, + updatedAt: params.updatedAt || now, + deletedAt: null, + messages: params.messages || [], + traceId: v4(), + result: params.result || null, + } satisfies FormAgentSession + }, +) diff --git a/apps/api/src/domains/agents/form-agent-sessions/form-agent-sessions.controller.ts b/apps/api/src/domains/agents/form-agent-sessions/form-agent-sessions.controller.ts index c1ea395..edd1887 100644 --- a/apps/api/src/domains/agents/form-agent-sessions/form-agent-sessions.controller.ts +++ b/apps/api/src/domains/agents/form-agent-sessions/form-agent-sessions.controller.ts @@ -39,6 +39,7 @@ export class FormAgentSessionsController { connectScope: getRequiredConnectScope(request), agentId: request.agent.id, type: payload.type, + userId: request.user.id, }) return { data: sessions.map(toDto(payload.type)) } } @@ -58,9 +59,9 @@ export class FormAgentSessionsController { return { data: toDto(payload.type)(session) } } + @Post(FormAgentSessionsRoutes.deleteOne.path) @AddContext("agentSession") @CheckPolicy((policy) => policy.canDelete()) - @Post(FormAgentSessionsRoutes.deleteOne.path) async deleteOne( @Req() request: EndpointRequestWithAgentSession, ): Promise { diff --git a/apps/api/src/domains/agents/form-agent-sessions/form-agent-sessions.service.ts b/apps/api/src/domains/agents/form-agent-sessions/form-agent-sessions.service.ts index fa896b2..60a3e69 100644 --- a/apps/api/src/domains/agents/form-agent-sessions/form-agent-sessions.service.ts +++ b/apps/api/src/domains/agents/form-agent-sessions/form-agent-sessions.service.ts @@ -23,14 +23,16 @@ export class FormAgentSessionsService { async listSessions({ connectScope, agentId, + userId, type, }: { + userId: string connectScope: RequiredConnectScope agentId: string type: BaseAgentSessionType }): Promise { return this.sessionConnectRepository.find(connectScope, { - where: { agentId, type }, + where: { agentId, type, userId }, order: { createdAt: "DESC" }, }) } diff --git a/apps/api/src/domains/organizations/organization.factory.ts b/apps/api/src/domains/organizations/organization.factory.ts index b768e08..edf2eee 100644 --- a/apps/api/src/domains/organizations/organization.factory.ts +++ b/apps/api/src/domains/organizations/organization.factory.ts @@ -14,6 +14,7 @@ import { userFactory } from "@/domains/users/user.factory" import type { ExtractionAgentSession } from "../agents/extraction-agent-sessions/extraction-agent-session.entity" import { extractionAgentSessionFactory } from "../agents/extraction-agent-sessions/extraction-agent-session.factory" import type { FormAgentSession } from "../agents/form-agent-sessions/form-agent-session.entity" +import { formAgentSessionFactory } from "../agents/form-agent-sessions/form-agent-session.factory" import type { AgentMembership } from "../agents/memberships/agent-membership.entity" import { agentMembershipFactory } from "../agents/memberships/agent-membership.factory" import type { AgentMessage } from "../agents/shared/agent-session-messages/agent-message.entity" @@ -165,7 +166,10 @@ export async function createOrganizationWithAgentSession > { - const data = await createOrganizationWithAgent(repositories, params) + const data = await createOrganizationWithAgent(repositories, { + ...params, + agent: { ...params.agent, type: agentType }, + }) const { organization, user, agent, project } = data switch (agentType) { @@ -191,7 +195,15 @@ export async function createOrganizationWithAgentSession