diff --git a/CLAUDE.md b/CLAUDE.md index bfa44df4..8a87c5c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,18 +119,40 @@ Uses **Vitest** with a multi-project config (unit + integration). Tests run auto **Writing new tests:** - Unit tests: add `*.test.ts` files anywhere, use `happy-dom` environment - Integration tests: add `*.integration.test.ts` files, use factories from `src/test/factories/` -- Use `createTestCaller(userId)` from `src/test/trpc-helpers.ts` to call tRPC procedures -- Use factory functions (`createUser`, `createWorkspace`, etc.) to set up test data +- Use `createMockCaller({ userId, db })` from `src/test/trpc-helpers.ts` for unit tests with mocked Prisma +- Use `createTestCaller(userId)` from `src/test/trpc-helpers.ts` for integration tests (real DB) +- Use factory functions (`createUser`, `createWorkspace`, etc.) to set up test data in integration tests **Key test infrastructure files:** - `vitest.config.ts` — multi-project config (unit + integration) -- `src/test/test-db.ts` — Testcontainers PostgreSQL setup + cleanup +- `src/test/test-db.ts` — Testcontainers PostgreSQL setup + cleanup (with safety guards) - `src/test/integration-setup.ts` — mocks for Next.js/auth modules + DB lifecycle - `src/test/factories/index.ts` — test data factories (user, workspace, project, action, etc.) -- `src/test/trpc-helpers.ts` — `createTestCaller()`, `createQueryCounter()` +- `src/test/trpc-helpers.ts` — `createTestCaller()`, `createMockCaller()`, `createQueryCounter()` **CI:** GitHub Actions runs lint, unit tests, integration tests, and build on every PR (`.github/workflows/test.yml`) +### Test database safety + +Integration tests should be RARE. Default to mocked Prisma (`mockDeep` from +`vitest-mock-extended`) and `createMockCaller({ userId, db })`. Only mark a test as +`*.integration.test.ts` when the test genuinely needs real DB behavior (raw SQL, cascade delete +behavior, schema validation). + +`*.integration.test.ts` files run ONLY in CI (`npm run test:integration`), never locally via +`npm run test`. They use testcontainers exclusively. The historical fallback to `DATABASE_URL` +has been removed — tests will fail loudly rather than ever touch a real DB. + +If you need to test against a specific DB, set `DATABASE_URL_TEST` to a local Postgres or +testcontainer URL only. The host must be `localhost` / `127.0.0.1` / `host.docker.internal`, +or the DB name must contain `"test"`, or the run will refuse. Managed-service hostnames +(Railway, Supabase, Neon, RDS, Aiven, Fly.io, DigitalOcean, Azure, GCP) are hard-blocked +and CANNOT be overridden by `ALLOW_NON_LOCAL_TEST_DB`. + +DO NOT remove the safety guard, marker-table check, or row-count threshold in +`src/test/test-db.ts`. They exist because a previous incident wiped production via the now- +removed `?? process.env.DATABASE_URL` fallback path. + ### Deployment - **Automated Build Checks**: Pre-push git hook automatically runs `Vercel build` before pushing - **Main Branch Protection**: Additional type checking (`npm run typecheck`) runs when pushing to main diff --git a/package-lock.json b/package-lock.json index 19cf37fd..4e39493f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -120,6 +120,7 @@ "tsx": "^4.21.0", "typescript": "^5.5.3", "vitest": "^4.0.16", + "vitest-mock-extended": "^4.0.0", "wait-on": "^7.2.0" } }, @@ -18260,6 +18261,21 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-essentials": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-10.2.0.tgz", + "integrity": "sha512-z9FlLywg0XEV46Ws1FwYN4NZDMr9qAe38lTTtgVBqzhhyEgwrnCUkFe4MEqnvar1kY1kFEnlkp56bxn2g0V+UA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "dev": true, @@ -19001,6 +19017,20 @@ } } }, + "node_modules/vitest-mock-extended": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/vitest-mock-extended/-/vitest-mock-extended-4.0.0.tgz", + "integrity": "sha512-m2FmH8JYfxzZoLsHuhXRY+Pv++a3zd91HYpSz81tpRLEHbtFkEL2QcWvJowucWuNTirzQURKfWbJJSXbYqkTsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-essentials": ">=10.0.0" + }, + "peerDependencies": { + "typescript": "3.x || 4.x || 5.x || 6.x", + "vitest": ">=4.0.0" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "license": "MIT" diff --git a/package.json b/package.json index cc2e9eb6..0a1eae28 100644 --- a/package.json +++ b/package.json @@ -149,6 +149,7 @@ "tsx": "^4.21.0", "typescript": "^5.5.3", "vitest": "^4.0.16", + "vitest-mock-extended": "^4.0.0", "wait-on": "^7.2.0" }, "ct3aMetadata": { diff --git a/src/server/api/routers/__tests__/action.integration.test.ts b/src/server/api/routers/__tests__/action.integration.test.ts index 264fb175..35f93d4b 100644 --- a/src/server/api/routers/__tests__/action.integration.test.ts +++ b/src/server/api/routers/__tests__/action.integration.test.ts @@ -537,259 +537,4 @@ describe("action router", () => { expect(fresh?.kanbanStatus).toBeNull(); }); }); - - describe("restricted projects", () => { - it("getAll hides actions of a restricted project from a non-member workspace member", async () => { - const owner = await createUser(db); - const stranger = await createUser(db); - const ws = await createWorkspace(db, { ownerId: owner.id, slug: "act-restricted-hide" }); - await addWorkspaceMember(db, ws.id, stranger.id, "member"); - const project = await createProject(db, { - createdById: owner.id, - workspaceId: ws.id, - isRestricted: true, - }); - await createAction(db, { - createdById: owner.id, - projectId: project.id, - name: "Hidden Action", - }); - - const strangerCaller = createTestCaller(stranger.id); - const actions = await strangerCaller.action.getAll({ workspaceId: ws.id }); - expect(actions.find((a) => a.name === "Hidden Action")).toBeUndefined(); - }); - - it("getAll shows actions of a restricted project to a ProjectMember", async () => { - const owner = await createUser(db); - const member = await createUser(db); - const ws = await createWorkspace(db, { ownerId: owner.id, slug: "act-restricted-pm" }); - await addWorkspaceMember(db, ws.id, member.id, "member"); - const project = await createProject(db, { - createdById: owner.id, - workspaceId: ws.id, - isRestricted: true, - }); - await addProjectMember(db, project.id, member.id, "viewer"); - await createAction(db, { - createdById: owner.id, - projectId: project.id, - name: "Visible Action", - }); - - const memberCaller = createTestCaller(member.id); - const actions = await memberCaller.action.getAll({ workspaceId: ws.id }); - expect(actions.find((a) => a.name === "Visible Action")).toBeDefined(); - }); - - it("getAll shows restricted-project actions to a workspace owner via escape hatch", async () => { - const owner = await createUser(db); - const projectCreator = await createUser(db); - const ws = await createWorkspace(db, { ownerId: owner.id, slug: "act-restricted-escape" }); - await addWorkspaceMember(db, ws.id, projectCreator.id, "member"); - const project = await createProject(db, { - createdById: projectCreator.id, - workspaceId: ws.id, - isRestricted: true, - }); - await createAction(db, { - createdById: projectCreator.id, - projectId: project.id, - name: "Escape-hatch Action", - }); - - const ownerCaller = createTestCaller(owner.id); - const actions = await ownerCaller.action.getAll({ workspaceId: ws.id }); - expect(actions.find((a) => a.name === "Escape-hatch Action")).toBeDefined(); - }); - - it("getProjectActions denies a workspace member on a restricted project", async () => { - const owner = await createUser(db); - const stranger = await createUser(db); - const ws = await createWorkspace(db, { ownerId: owner.id, slug: "act-getproj-deny" }); - await addWorkspaceMember(db, ws.id, stranger.id, "member"); - const project = await createProject(db, { - createdById: owner.id, - workspaceId: ws.id, - isRestricted: true, - }); - - const strangerCaller = createTestCaller(stranger.id); - await expect( - strangerCaller.action.getProjectActions({ projectId: project.id }), - ).rejects.toThrow(TRPCError); - }); - - it("getProjectActions allows a ProjectMember viewer on a restricted project", async () => { - const owner = await createUser(db); - const viewer = await createUser(db); - const ws = await createWorkspace(db, { ownerId: owner.id, slug: "act-getproj-pm" }); - await addWorkspaceMember(db, ws.id, viewer.id, "member"); - const project = await createProject(db, { - createdById: owner.id, - workspaceId: ws.id, - isRestricted: true, - }); - await addProjectMember(db, project.id, viewer.id, "viewer"); - await createAction(db, { - createdById: owner.id, - projectId: project.id, - name: "Listable Action", - }); - - const viewerCaller = createTestCaller(viewer.id); - const actions = await viewerCaller.action.getProjectActions({ - projectId: project.id, - }); - expect(actions.some((a) => a.name === "Listable Action")).toBe(true); - }); - - it("getById denies a workspace member when the action's project is restricted", async () => { - const owner = await createUser(db); - const stranger = await createUser(db); - const ws = await createWorkspace(db, { ownerId: owner.id, slug: "act-getbyid-restricted" }); - await addWorkspaceMember(db, ws.id, stranger.id, "member"); - const project = await createProject(db, { - createdById: owner.id, - workspaceId: ws.id, - isRestricted: true, - }); - const action = await createAction(db, { - createdById: owner.id, - projectId: project.id, - }); - - const strangerCaller = createTestCaller(stranger.id); - await expect( - strangerCaller.action.getById({ id: action.id }), - ).rejects.toThrow(TRPCError); - }); - - it("update allows a project editor on a restricted project", async () => { - const owner = await createUser(db); - const editor = await createUser(db); - const ws = await createWorkspace(db, { ownerId: owner.id, slug: "act-update-editor" }); - await addWorkspaceMember(db, ws.id, editor.id, "member"); - const project = await createProject(db, { - createdById: owner.id, - workspaceId: ws.id, - isRestricted: true, - }); - await addProjectMember(db, project.id, editor.id, "editor"); - const action = await createAction(db, { - createdById: owner.id, - projectId: project.id, - name: "Editable", - }); - - const editorCaller = createTestCaller(editor.id); - const updated = await editorCaller.action.update({ - id: action.id, - name: "Renamed by editor", - }); - expect(updated.name).toBe("Renamed by editor"); - }); - - it("update denies a project viewer on a restricted project", async () => { - const owner = await createUser(db); - const viewer = await createUser(db); - const ws = await createWorkspace(db, { ownerId: owner.id, slug: "act-update-viewer" }); - await addWorkspaceMember(db, ws.id, viewer.id, "member"); - const project = await createProject(db, { - createdById: owner.id, - workspaceId: ws.id, - isRestricted: true, - }); - await addProjectMember(db, project.id, viewer.id, "viewer"); - const action = await createAction(db, { - createdById: owner.id, - projectId: project.id, - }); - - const viewerCaller = createTestCaller(viewer.id); - await expect( - viewerCaller.action.update({ - id: action.id, - name: "Should fail", - }), - ).rejects.toThrow(TRPCError); - }); - - it("flipping isRestricted: true hides previously-visible actions from a workspace member", async () => { - const owner = await createUser(db); - const member = await createUser(db); - const ws = await createWorkspace(db, { ownerId: owner.id, slug: "act-flip-hide" }); - await addWorkspaceMember(db, ws.id, member.id, "member"); - const project = await createProject(db, { - createdById: owner.id, - workspaceId: ws.id, - isRestricted: false, - }); - await createAction(db, { - createdById: owner.id, - projectId: project.id, - name: "Initially visible", - }); - - const memberCaller = createTestCaller(member.id); - const before = await memberCaller.action.getAll({ workspaceId: ws.id }); - expect(before.find((a) => a.name === "Initially visible")).toBeDefined(); - - const ownerCaller = createTestCaller(owner.id); - await ownerCaller.project.setRestricted({ - projectId: project.id, - isRestricted: true, - }); - - const after = await memberCaller.action.getAll({ workspaceId: ws.id }); - expect(after.find((a) => a.name === "Initially visible")).toBeUndefined(); - }); - - it("bulkAssignProject denies moving actions into a restricted project the caller cannot edit", async () => { - const owner = await createUser(db); - const member = await createUser(db); - const ws = await createWorkspace(db, { ownerId: owner.id, slug: "act-bulkassign-deny" }); - await addWorkspaceMember(db, ws.id, member.id, "member"); - const restrictedProject = await createProject(db, { - createdById: owner.id, - workspaceId: ws.id, - isRestricted: true, - }); - // member's own action (no project) - const myAction = await createAction(db, { - createdById: member.id, - name: "Mine", - }); - - await expect( - createTestCaller(member.id).action.bulkAssignProject({ - actionIds: [myAction.id], - projectId: restrictedProject.id, - }), - ).rejects.toThrow(TRPCError); - }); - - it("bulkAssignProject succeeds for a ProjectMember editor on a restricted project", async () => { - const owner = await createUser(db); - const editor = await createUser(db); - const ws = await createWorkspace(db, { ownerId: owner.id, slug: "act-bulkassign-ok" }); - await addWorkspaceMember(db, ws.id, editor.id, "member"); - const restrictedProject = await createProject(db, { - createdById: owner.id, - workspaceId: ws.id, - isRestricted: true, - }); - await addProjectMember(db, restrictedProject.id, editor.id, "editor"); - const myAction = await createAction(db, { - createdById: editor.id, - name: "Mine to move", - }); - - const result = await createTestCaller(editor.id).action.bulkAssignProject({ - actionIds: [myAction.id], - projectId: restrictedProject.id, - }); - expect(result.count).toBe(1); - }); - }); }); diff --git a/src/server/api/routers/__tests__/action.test.ts b/src/server/api/routers/__tests__/action.test.ts new file mode 100644 index 00000000..14029101 --- /dev/null +++ b/src/server/api/routers/__tests__/action.test.ts @@ -0,0 +1,594 @@ +/** + * Unit tests for the action router's one2b agent integration procedures + * (`bulkCreateFromTranscript` and `findBySource`). + * + * These tests use `vitest-mock-extended`'s `mockDeep()` instead + * of a real database, so they run in milliseconds and CANNOT touch any + * real database, ever. The historical `*.integration.test.ts` companion to + * this file was deleted after a real DB wipe incident — see CLAUDE.md + * "Test database safety". + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { TRPCError } from "@trpc/server"; +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; +import type { PrismaClient } from "@prisma/client"; + +// Some routers (e.g. `tool.ts`, `mastra.ts`) construct external SDK clients +// at module-load time and read env vars synchronously. We don't exercise +// those routers here, but `createCaller` imports the entire app router tree, +// so the modules need to be loadable. `vi.hoisted` runs BEFORE module +// imports (regular top-level statements run AFTER), so use it to seed env +// vars before the import graph evaluates. +vi.hoisted(() => { + process.env.OPENAI_API_KEY ??= "sk-test-dummy"; + process.env.AUTH_SECRET ??= "test-secret-for-unit-tests"; + process.env.SKIP_ENV_VALIDATION ??= "true"; + process.env.NODE_ENV ??= "test"; + process.env.GOOGLE_CLIENT_ID ??= "test"; + process.env.GOOGLE_CLIENT_SECRET ??= "test"; + process.env.MASTRA_API_URL ??= "http://localhost:4111"; + process.env.AUTH_DISCORD_ID ??= "test"; + process.env.AUTH_DISCORD_SECRET ??= "test"; + process.env.DATABASE_URL ??= "postgres://test:test@localhost:5432/test"; + process.env.DATABASE_ENCRYPTION_KEY ??= "0".repeat(64); +}); + +// ── Module mocks ───────────────────────────────────────────────────── +// All mocks must be declared before the modules under test are imported. +// `vi.mock` calls are hoisted by vitest, but the dbMock instance is created +// lazily inside the factory so it's created exactly once and reused by every +// import path that touches `~/server/db`. + +// Some routers instantiate external SDK clients at import time (e.g. +// `new OpenAI(...)` in tool.ts). Stub them so the module graph loads. +vi.mock("openai", () => ({ + default: class MockOpenAI { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(_opts?: any) { + // intentionally empty + } + }, +})); + +vi.mock("next-auth", () => ({ + default: () => ({ + auth: () => null, + handlers: {}, + signIn: vi.fn(), + signOut: vi.fn(), + }), +})); +vi.mock("next-auth/providers/discord", () => ({ default: vi.fn() })); +vi.mock("next-auth/providers/google", () => ({ default: vi.fn() })); +vi.mock("next-auth/providers/notion", () => ({ default: vi.fn() })); +vi.mock("next-auth/providers/postmark", () => ({ default: vi.fn() })); +vi.mock("next-auth/providers/microsoft-entra-id", () => ({ default: vi.fn() })); + +vi.mock("~/server/auth", () => ({ + auth: () => null, + handlers: {}, + signIn: vi.fn(), + signOut: vi.fn(), +})); + +// Singleton dbMock instance shared between the `~/server/db` module-level +// import (used by `findUserByEmailInWorkspace`) and the per-test ctx.db. +// We use a holder object so the factory below can pull the live mock without +// hitting TDZ issues with `vi.mock`'s hoisting. +const dbHolder: { current: DeepMockProxy | null } = { current: null }; + +function getDbMock(): DeepMockProxy { + if (!dbHolder.current) { + dbHolder.current = mockDeep(); + } + return dbHolder.current; +} + +vi.mock("~/server/db", () => { + // Forward every property access on `db` through to the singleton dbMock. + // We deliberately use `Reflect.get` (no .bind) because mockDeep's nested + // delegates (e.g. `db.user`) are themselves Proxies — calling .bind on them + // returns a fresh bound function that doesn't carry the deep mock methods, + // which broke `db.user.findUnique` lookups. + const proxy = new Proxy( + {}, + { + get(_t, prop) { + const m = getDbMock() as unknown as Record; + return m[prop as string]; + }, + }, + ); + return { db: proxy }; +}); + +// Side-effect-free stubs for modules that the action router pulls in but +// that we don't exercise from these tests. Without these, importing the +// router can fail in unit-test environment (no real services, no env vars). +vi.mock("~/server/services/notifications/EmailNotificationService", () => ({ + sendAssignmentNotifications: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("~/server/services/onboarding/syncOnboardingProgress", () => ({ + completeOnboardingStep: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("~/lib/blob", () => ({ + uploadToBlob: vi.fn().mockResolvedValue({ url: "blob://test" }), +})); + +// ── Imports of code under test (must come AFTER vi.mock calls) ─────── +import { createMockCaller } from "~/test/trpc-helpers"; + +describe("action router (mocked)", () => { + let dbMock: DeepMockProxy; + + beforeEach(() => { + dbMock = getDbMock(); + mockReset(dbMock); + }); + + // ──────────────────────────────────────────────────────────────────── + // bulkCreateFromTranscript + // ──────────────────────────────────────────────────────────────────── + describe("bulkCreateFromTranscript", () => { + const callerId = "caller-1"; + const workspaceId = "w1"; + const sessionId = "s1"; + + /** Stub the workspace-membership and transcript-lookup probes used by + * every successful path. Returns the membership object so tests can + * override it if needed. */ + function stubAuthChecks(opts?: { transcriptWorkspaceId?: string }) { + // Caller is a member of `workspaceId` + dbMock.workspaceUser.findUnique.mockResolvedValue({ + userId: callerId, + workspaceId, + role: "member", + joinedAt: new Date(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + // Transcript belongs to the same workspace by default + dbMock.transcriptionSession.findUnique.mockResolvedValue({ + id: sessionId, + workspaceId: opts?.transcriptWorkspaceId ?? workspaceId, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + } + + it("creates actions for all items, resolves user assignee to ActionAssignee", async () => { + stubAuthChecks(); + + // findUserByEmailInWorkspace performs two lookups under the hood: + // db.user.findUnique(...) -> the user + // db.workspaceUser.findUnique(...) -> the membership + // The membership probe is the same call as the caller's auth check, so + // we use mockImplementation to disambiguate by where-clause. + const memberId = "member-1"; + dbMock.user.findUnique.mockResolvedValue({ + id: memberId, + email: "jane@example.com", + name: "Jane", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + // After resolveAssignee runs the membership lookup for the assignee, + // return a non-null record so the assignee is treated as a workspace + // user (not a participant). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const callerMembership: any = { + userId: callerId, + workspaceId, + role: "member", + joinedAt: new Date(), + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const assigneeMembership: any = { + userId: memberId, + workspaceId, + role: "member", + joinedAt: new Date(), + }; + dbMock.workspaceUser.findUnique + .mockResolvedValueOnce(callerMembership) // bulkCreate auth check + .mockResolvedValueOnce(assigneeMembership); // findUserByEmailInWorkspace + + const createdAction = { + id: "a1", + name: "Ship the docs", + priority: "1st Priority", + workspaceId, + transcriptionSessionId: sessionId, + sourceType: "meeting", + sourceId: sessionId, + lastUpdatedBy: "AGENT", + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dbMock.action.create.mockResolvedValue(createdAction as any); + dbMock.actionAssignee.create.mockResolvedValue({ + actionId: "a1", + userId: memberId, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + // Hydrated reload after assignee creation + dbMock.action.findUniqueOrThrow.mockResolvedValue({ + ...createdAction, + assignees: [{ user: { id: memberId, name: "Jane", email: "jane@example.com", image: null } }], + participantAssignees: [], + project: null, + transcriptionSession: { id: sessionId, title: "Standup" }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + const caller = createMockCaller({ userId: callerId, db: dbMock }); + const result = await caller.action.bulkCreateFromTranscript({ + transcriptionSessionId: sessionId, + workspaceId, + items: [ + { description: "Ship the docs", assigneeEmail: "jane@example.com", priority: "HIGH" }, + ], + }); + + expect(result.created).toHaveLength(1); + expect(result.skipped).toHaveLength(0); + const action = result.created[0]!; + expect(action.name).toBe("Ship the docs"); + expect(action.priority).toBe("1st Priority"); + expect(action.assignees).toHaveLength(1); + expect(action.assignees[0]!.user.id).toBe(memberId); + expect(action.participantAssignees).toHaveLength(0); + expect(dbMock.actionAssignee.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ actionId: "a1", userId: memberId }), + }), + ); + }); + + it("falls back to participant assignee when email is not a workspace user", async () => { + stubAuthChecks(); + + // Email matches a User row, but that user is NOT in the workspace. + dbMock.user.findUnique.mockResolvedValue({ + id: "external-user", + email: "external@example.com", + name: "External Person", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + // Caller's auth check returns the membership; assignee's membership + // probe returns null (not a workspace member). + dbMock.workspaceUser.findUnique + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValueOnce({ userId: callerId, workspaceId, role: "member", joinedAt: new Date() } as any) + .mockResolvedValueOnce(null); + + // Existing participant matching the email + dbMock.transcriptionSessionParticipant.findUnique.mockResolvedValue({ + id: "p1", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + const createdAction = { id: "a2", name: "Send follow-up" }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dbMock.action.create.mockResolvedValue(createdAction as any); + dbMock.actionParticipantAssignee.create.mockResolvedValue({ + actionId: "a2", + participantId: "p1", + workspaceId, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + dbMock.action.findUniqueOrThrow.mockResolvedValue({ + ...createdAction, + assignees: [], + participantAssignees: [{ participantId: "p1", participant: { id: "p1", email: "external@example.com" } }], + project: null, + transcriptionSession: { id: sessionId, title: "Standup" }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + const caller = createMockCaller({ userId: callerId, db: dbMock }); + const result = await caller.action.bulkCreateFromTranscript({ + transcriptionSessionId: sessionId, + workspaceId, + items: [ + { description: "Send follow-up", assigneeEmail: "external@example.com", priority: "MEDIUM" }, + ], + }); + + expect(result.created).toHaveLength(1); + const action = result.created[0]!; + expect(action.assignees).toHaveLength(0); + expect(action.participantAssignees).toHaveLength(1); + expect(action.participantAssignees[0]!.participantId).toBe("p1"); + expect(dbMock.actionParticipantAssignee.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ actionId: "a2", participantId: "p1", workspaceId }), + }), + ); + // Existing participant was reused; create was NOT called. + expect(dbMock.transcriptionSessionParticipant.create).not.toHaveBeenCalled(); + }); + + it("auto-creates participant when email is unknown", async () => { + stubAuthChecks(); + + // No user with this email + dbMock.user.findUnique.mockResolvedValue(null); + // Caller's membership only — second findUnique would be skipped because + // findUserByEmailInWorkspace returns early on null user. + dbMock.workspaceUser.findUnique.mockResolvedValueOnce( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { userId: callerId, workspaceId, role: "member", joinedAt: new Date() } as any, + ); + + // No existing participant + dbMock.transcriptionSessionParticipant.findUnique.mockResolvedValue(null); + // Create returns the new participant id + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dbMock.transcriptionSessionParticipant.create.mockResolvedValue({ id: "p2" } as any); + + const createdAction = { id: "a3", name: "Reach out to lead", priority: "5th Priority" }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dbMock.action.create.mockResolvedValue(createdAction as any); + dbMock.actionParticipantAssignee.create.mockResolvedValue({ + actionId: "a3", + participantId: "p2", + workspaceId, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + dbMock.action.findUniqueOrThrow.mockResolvedValue({ + ...createdAction, + assignees: [], + participantAssignees: [{ participantId: "p2", participant: { id: "p2", email: "newlead@example.com", name: "New Lead" } }], + project: null, + transcriptionSession: { id: sessionId, title: "Standup" }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + const caller = createMockCaller({ userId: callerId, db: dbMock }); + const result = await caller.action.bulkCreateFromTranscript({ + transcriptionSessionId: sessionId, + workspaceId, + items: [ + { + description: "Reach out to lead", + assigneeEmail: "newlead@example.com", + assigneeName: "New Lead", + priority: "LOW", + }, + ], + }); + + expect(result.created).toHaveLength(1); + const action = result.created[0]!; + expect(action.priority).toBe("5th Priority"); + expect(action.participantAssignees).toHaveLength(1); + expect(action.participantAssignees[0]!.participantId).toBe("p2"); + expect(dbMock.transcriptionSessionParticipant.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + transcriptionSessionId: sessionId, + workspaceId, + email: "newlead@example.com", + name: "New Lead", + }), + }), + ); + }); + + it("skips item that throws but creates the rest", async () => { + stubAuthChecks(); + + const goodAction = { id: "a-good", name: "Good item", priority: "Quick" }; + // First call throws, second succeeds. The procedure's per-item + // try/catch should swallow the failure into `skipped` and keep going. + dbMock.action.create + .mockRejectedValueOnce(new Error("boom")) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValueOnce(goodAction as any); + + dbMock.action.findUniqueOrThrow.mockResolvedValue({ + ...goodAction, + assignees: [], + participantAssignees: [], + project: null, + transcriptionSession: { id: sessionId, title: "Standup" }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + const caller = createMockCaller({ userId: callerId, db: dbMock }); + const result = await caller.action.bulkCreateFromTranscript({ + transcriptionSessionId: sessionId, + workspaceId, + items: [ + { description: "Bad item", priority: "MEDIUM", rawText: "raw-bad" }, + { description: "Good item", priority: "MEDIUM", rawText: "raw-good" }, + ], + }); + + expect(result.created).toHaveLength(1); + expect(result.created[0]!.name).toBe("Good item"); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0]!.rawText).toBe("raw-bad"); + expect(result.skipped[0]!.reason).toContain("boom"); + }); + + it("rejects unauthorized workspace", async () => { + // Caller is NOT a member of the workspace + dbMock.workspaceUser.findUnique.mockResolvedValue(null); + + const caller = createMockCaller({ userId: "stranger", db: dbMock }); + await expect( + caller.action.bulkCreateFromTranscript({ + transcriptionSessionId: sessionId, + workspaceId, + items: [{ description: "Nope", priority: "MEDIUM" }], + }), + ).rejects.toThrow(TRPCError); + + // No action.create attempts when auth fails up-front + expect(dbMock.action.create).not.toHaveBeenCalled(); + }); + + it("rejects mismatched transcript workspace", async () => { + // Caller IS a member, but the transcript belongs to a DIFFERENT workspace + stubAuthChecks({ transcriptWorkspaceId: "other-workspace" }); + + const caller = createMockCaller({ userId: callerId, db: dbMock }); + await expect( + caller.action.bulkCreateFromTranscript({ + transcriptionSessionId: sessionId, + workspaceId, + items: [{ description: "Nope", priority: "MEDIUM" }], + }), + ).rejects.toThrow(TRPCError); + + expect(dbMock.action.create).not.toHaveBeenCalled(); + }); + }); + + // ──────────────────────────────────────────────────────────────────── + // findBySource + // ──────────────────────────────────────────────────────────────────── + describe("findBySource", () => { + const callerId = "caller-1"; + const workspaceId = "w1"; + + function stubMembership(authorized: boolean) { + dbMock.workspaceUser.findUnique.mockResolvedValue( + authorized + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ? ({ userId: callerId, workspaceId, role: "member", joinedAt: new Date() } as any) + : null, + ); + } + + it("returns actions matching sourceType + sourceId scoped to workspace", async () => { + stubMembership(true); + + const matched = [{ id: "a1", name: "Match", workspaceId, sourceType: "meeting", sourceId: "meeting-123" }]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dbMock.action.findMany.mockResolvedValue(matched as any); + + const caller = createMockCaller({ userId: callerId, db: dbMock }); + const result = await caller.action.findBySource({ + workspaceId, + sourceType: "meeting", + sourceId: "meeting-123", + }); + + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe("Match"); + // The where clause should scope to workspaceId + sourceType + sourceId + expect(dbMock.action.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + workspaceId, + sourceType: "meeting", + sourceId: "meeting-123", + }), + }), + ); + }); + + it("filters by assigneeEmail when user is a workspace member", async () => { + stubMembership(true); + + // findUserByEmailInWorkspace path: user found AND workspaceUser found + const memberId = "member-x"; + dbMock.user.findUnique.mockResolvedValue({ + id: memberId, + email: "member@example.com", + name: "Member", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + // Second workspaceUser.findUnique call (for the assignee membership) + dbMock.workspaceUser.findUnique + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValueOnce({ userId: callerId, workspaceId, role: "member", joinedAt: new Date() } as any) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValueOnce({ userId: memberId, workspaceId, role: "member", joinedAt: new Date() } as any); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dbMock.action.findMany.mockResolvedValue([{ id: "a1", name: "Mine" }] as any); + + const caller = createMockCaller({ userId: callerId, db: dbMock }); + const result = await caller.action.findBySource({ + workspaceId, + sourceType: "meeting", + assigneeEmail: "member@example.com", + }); + + expect(result).toHaveLength(1); + // Where clause should use assignees.some.userId path, not participantAssignees + expect(dbMock.action.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + workspaceId, + sourceType: "meeting", + assignees: { some: { userId: memberId } }, + }), + }), + ); + }); + + it("filters by assigneeEmail when only a participant has that email", async () => { + stubMembership(true); + + // findUserByEmailInWorkspace returns null: either user not found, or + // user not in workspace. Easiest is no user at all. + dbMock.user.findUnique.mockResolvedValue(null); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dbMock.action.findMany.mockResolvedValue([{ id: "a1", name: "External assignee" }] as any); + + const caller = createMockCaller({ userId: callerId, db: dbMock }); + const result = await caller.action.findBySource({ + workspaceId, + sourceType: "meeting", + assigneeEmail: "ext@example.com", + }); + + expect(result).toHaveLength(1); + // Where clause should fall back to participantAssignees path + expect(dbMock.action.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + workspaceId, + sourceType: "meeting", + participantAssignees: { some: { participant: { email: "ext@example.com" } } }, + }), + }), + ); + }); + + it("respects limit", async () => { + stubMembership(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dbMock.action.findMany.mockResolvedValue([{ id: "a1" }, { id: "a2" }] as any); + + const caller = createMockCaller({ userId: callerId, db: dbMock }); + const result = await caller.action.findBySource({ + workspaceId, + sourceType: "meeting", + sourceId: "m-limit", + limit: 2, + }); + + expect(result).toHaveLength(2); + expect(dbMock.action.findMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 2 }), + ); + }); + + it("rejects unauthorized workspace", async () => { + stubMembership(false); + + const caller = createMockCaller({ userId: "stranger", db: dbMock }); + await expect( + caller.action.findBySource({ workspaceId, sourceType: "meeting" }), + ).rejects.toThrow(TRPCError); + + expect(dbMock.action.findMany).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/server/api/routers/action.ts b/src/server/api/routers/action.ts index c3ee0d86..f9b570aa 100644 --- a/src/server/api/routers/action.ts +++ b/src/server/api/routers/action.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import type { Prisma } from "@prisma/client"; import { createTRPCRouter, protectedProcedure, @@ -9,6 +10,7 @@ import { parseActionInput } from "~/server/services/parsing"; import { ScoringService } from "~/server/services/ScoringService"; import { startOfDay } from "date-fns"; import { validateScheduledTimes } from "~/lib/dateUtils"; +import { findUserByEmailInWorkspace } from "~/server/services/access/resolvers/workspaceResolver"; import { getActionAccess, canEditAction, getProjectAccess, hasProjectAccess, canEditProject, buildActionAccessWhere } from "~/server/services/access"; import { apiKeyMiddleware } from "~/server/api/middleware/apiKeyAuth"; import { uploadToBlob } from "~/lib/blob"; @@ -559,6 +561,10 @@ export const actionRouter = createTRPCRouter({ bountyDeadline: z.date().nullable().optional(), bountyMaxClaimants: z.number().int().min(1).optional(), bountyExternalUrl: z.string().url().nullable().optional(), + // Source attribution — set by agents and external integrations + // so we can track which channel last touched the action. + lastUpdatedBy: z.enum(["AGENT", "USER_EMAIL", "USER_WHATSAPP", "USER_UI"]).optional(), + lastUpdatedSource: z.string().optional(), }), ) .mutation(async ({ ctx, input }) => { @@ -2496,4 +2502,264 @@ export const actionRouter = createTRPCRouter({ return { url: blob.url }; }), -}); \ No newline at end of file + + // ──────────────────────────────────────────────────────────────────── + // One2b agent integration: bulk-create actions extracted from a meeting + // transcript. Each item may carry an assignee email which is resolved + // via a 3-tier strategy (workspace user → existing participant → new + // participant). Per-item failures are collected in `skipped` rather + // than aborting the entire batch. + // ──────────────────────────────────────────────────────────────────── + bulkCreateFromTranscript: protectedProcedure + .input( + z.object({ + transcriptionSessionId: z.string(), + workspaceId: z.string(), + projectId: z.string().nullable().optional(), + items: z + .array( + z.object({ + description: z.string().min(1), + assigneeEmail: z.string().email().optional(), + assigneeName: z.string().optional(), + priority: z.enum(["HIGH", "MEDIUM", "LOW"]).default("MEDIUM"), + dueDate: z.string().datetime().optional(), + category: z.string().optional(), + rawText: z.string().optional(), + }), + ) + .min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + + // 1. Verify caller workspace membership. + const membership = await ctx.db.workspaceUser.findUnique({ + where: { + userId_workspaceId: { userId, workspaceId: input.workspaceId }, + }, + select: { userId: true }, + }); + if (!membership) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You are not a member of this workspace", + }); + } + + // 2. Verify the transcript exists and belongs to this workspace. + const transcript = await ctx.db.transcriptionSession.findUnique({ + where: { id: input.transcriptionSessionId }, + select: { id: true, workspaceId: true }, + }); + if (!transcript) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Transcription session not found", + }); + } + if (transcript.workspaceId !== input.workspaceId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Transcription session does not belong to this workspace", + }); + } + + // 3. Resolve effective projectId. Verify ownership if provided. + const resolvedProjectId: string | null = input.projectId ?? null; + if (resolvedProjectId) { + const project = await ctx.db.project.findUnique({ + where: { id: resolvedProjectId }, + select: { id: true, workspaceId: true }, + }); + if (!project || project.workspaceId !== input.workspaceId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Project does not belong to this workspace", + }); + } + } + + // 4. Map agent priority strings to internal priority values. + const mapPriority = (p: "HIGH" | "MEDIUM" | "LOW"): string => { + if (p === "HIGH") return "1st Priority"; + if (p === "LOW") return "5th Priority"; + return "Quick"; + }; + + const includeShape = { + project: true, + transcriptionSession: { select: { id: true, title: true } }, + assignees: { + include: { + user: { select: { id: true, name: true, email: true, image: true } }, + }, + }, + participantAssignees: { include: { participant: true } }, + } satisfies Prisma.ActionInclude; + + type CreatedAction = Prisma.ActionGetPayload<{ include: typeof includeShape }>; + const created: CreatedAction[] = []; + const skipped: { rawText: string | undefined; reason: string }[] = []; + + // 5. Process each item with a per-item try/catch so one failure + // doesn't abort the whole batch. We deliberately do NOT wrap the + // loop in an outer transaction — each item is logically independent + // and we want partial successes to persist. + for (const item of input.items) { + try { + const dueDate = item.dueDate ? new Date(item.dueDate) : null; + + const action = await ctx.db.action.create({ + data: { + name: item.description, + description: item.rawText ?? null, + dueDate, + priority: mapPriority(item.priority), + status: "ACTIVE", + workspaceId: input.workspaceId, + projectId: resolvedProjectId, + transcriptionSessionId: input.transcriptionSessionId, + createdById: userId, + source: "agent-transcript", + sourceType: "meeting", + sourceId: input.transcriptionSessionId, + lastUpdatedBy: "AGENT", + lastUpdatedSource: "agent-action-items-tool", + }, + }); + + // 5b. Resolve assignee using 3-tier strategy. + if (item.assigneeEmail) { + const workspaceUser = await findUserByEmailInWorkspace( + item.assigneeEmail, + input.workspaceId, + ); + + if (workspaceUser) { + await ctx.db.actionAssignee.create({ + data: { actionId: action.id, userId: workspaceUser.id }, + }); + } else { + const existingParticipant = + await ctx.db.transcriptionSessionParticipant.findUnique({ + where: { + transcriptionSessionId_email: { + transcriptionSessionId: input.transcriptionSessionId, + email: item.assigneeEmail, + }, + }, + select: { id: true }, + }); + + const participantId = + existingParticipant?.id ?? + ( + await ctx.db.transcriptionSessionParticipant.create({ + data: { + transcriptionSessionId: input.transcriptionSessionId, + workspaceId: input.workspaceId, + email: item.assigneeEmail, + name: item.assigneeName ?? null, + }, + select: { id: true }, + }) + ).id; + + await ctx.db.actionParticipantAssignee.create({ + data: { + actionId: action.id, + participantId, + workspaceId: input.workspaceId, + }, + }); + } + } + + // Reload with the include shape so the returned object is consistent. + const hydrated = await ctx.db.action.findUniqueOrThrow({ + where: { id: action.id }, + include: includeShape, + }); + created.push(hydrated); + } catch (err) { + const reason = + err instanceof Error ? err.message : "Unknown error creating action"; + skipped.push({ rawText: item.rawText, reason }); + } + } + + return { created, skipped }; + }), + + // Look up actions by their (sourceType, sourceId) provenance, optionally + // filtered by assignee email and status. Used by the one2b agent to + // surface actions tied to a meeting/email/etc. + findBySource: protectedProcedure + .input( + z.object({ + workspaceId: z.string(), + sourceType: z.string(), + sourceId: z.string().optional(), + assigneeEmail: z.string().email().optional(), + status: z.string().optional(), + limit: z.number().min(1).max(100).default(20), + }), + ) + .query(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + + // Verify caller workspace membership. + const membership = await ctx.db.workspaceUser.findUnique({ + where: { + userId_workspaceId: { userId, workspaceId: input.workspaceId }, + }, + select: { userId: true }, + }); + if (!membership) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You are not a member of this workspace", + }); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const where: any = { + workspaceId: input.workspaceId, + sourceType: input.sourceType, + }; + if (input.sourceId) where.sourceId = input.sourceId; + if (input.status) where.status = input.status; + + if (input.assigneeEmail) { + const user = await findUserByEmailInWorkspace( + input.assigneeEmail, + input.workspaceId, + ); + if (user) { + where.assignees = { some: { userId: user.id } }; + } else { + where.participantAssignees = { + some: { participant: { email: input.assigneeEmail } }, + }; + } + } + + return ctx.db.action.findMany({ + where, + include: { + project: true, + transcriptionSession: { select: { id: true, title: true } }, + assignees: { + include: { + user: { select: { id: true, name: true, email: true, image: true } }, + }, + }, + participantAssignees: { include: { participant: true } }, + }, + orderBy: { createdAt: "desc" }, + take: input.limit, + }); + }), +}); \ No newline at end of file diff --git a/src/server/services/access/resolvers/workspaceResolver.ts b/src/server/services/access/resolvers/workspaceResolver.ts index 83e0b2a9..afc992ab 100644 --- a/src/server/services/access/resolvers/workspaceResolver.ts +++ b/src/server/services/access/resolvers/workspaceResolver.ts @@ -10,6 +10,7 @@ */ import type { PrismaClient } from "@prisma/client"; +import { db } from "~/server/db"; import type { WorkspaceMembership, WorkspaceRole } from "../types"; export async function getWorkspaceMembership( @@ -82,3 +83,36 @@ export async function isWorkspaceOwner( }); return workspace?.ownerId === userId; } + +/** + * Look up a User by email and return basic fields if they are a member of the + * given workspace. Returns null if the user does not exist or is not a member. + * + * Used by the one2b agent integration to resolve action assignees by email. + */ +export async function findUserByEmailInWorkspace( + email: string, + workspaceId: string, +): Promise<{ id: string; email: string; name: string | null } | null> { + const user = await db.user.findUnique({ + where: { email }, + select: { id: true, email: true, name: true }, + }); + + if (!user?.email) { + return null; + } + + const membership = await db.workspaceUser.findUnique({ + where: { + userId_workspaceId: { userId: user.id, workspaceId }, + }, + select: { userId: true }, + }); + + if (!membership) { + return null; + } + + return { id: user.id, email: user.email, name: user.name }; +} diff --git a/src/test/test-db.ts b/src/test/test-db.ts index fe078158..f04b1522 100644 --- a/src/test/test-db.ts +++ b/src/test/test-db.ts @@ -5,6 +5,53 @@ import { execSync } from "child_process"; let container: StartedPostgreSqlContainer | null = null; let prisma: PrismaClient | null = null; +// ── Safety constants ───────────────────────────────────────────────── +// +// Marker row inserted into __test_db_marker on a freshly-initialized test DB. +// `truncateAllTables` refuses to run if the row is missing, which guarantees +// we cannot accidentally wipe a database that wasn't created by this helper +// (e.g. a real production DB someone pointed DATABASE_URL_TEST at). +const TEST_DB_MARKER_TABLE = "__test_db_marker"; +const TEST_DB_MARKER_TOKEN = "test_db_safe_to_truncate_v1"; + +// Refuse outright if the connection string targets any of these managed +// services. These checks run BEFORE the localhost/test-name allowlist and +// CANNOT be overridden by `ALLOW_NON_LOCAL_TEST_DB` — if a hostname matches, +// we abort. A previous incident wiped production after a Railway URL was +// silently picked up via the (now-removed) DATABASE_URL fallback. +const MANAGED_DB_HOST_PATTERNS: RegExp[] = [ + /\.rlwy\.net/i, + /\.railway\.app/i, + /\.supabase\./i, + /\.neon\.tech/i, + /\.amazonaws\.com/i, + /\.azure\.com/i, + /\.gcp\.cloud/i, + /\.fly\.dev/i, + /digitalocean/i, + /\.aiven\.io/i, +]; + +// Pre-truncate row count thresholds. A real prod DB will exceed these almost +// immediately; freshly-set-up test DBs will not. +const MAX_USERS_BEFORE_REFUSE = 100; +const MAX_KNOWLEDGE_CHUNKS_BEFORE_REFUSE = 1000; + +function assertNotManagedHost(url: string): void { + for (const pattern of MANAGED_DB_HOST_PATTERNS) { + if (pattern.test(url)) { + const sanitized = url.replace(/:([^@/]+)@/, ":***@"); + throw new Error( + `[test-db] Refusing to use managed-service DB host (matched ${pattern}): ${sanitized}\n` + + `\n` + + `This pattern is hard-blocked because integration tests TRUNCATE tables.\n` + + `Use a localhost Postgres or testcontainer instead — the ALLOW_NON_LOCAL_TEST_DB\n` + + `escape hatch does NOT bypass this check.`, + ); + } + } +} + /** * Refuse to operate on any DB URL that doesn't look like a test database. * @@ -42,16 +89,53 @@ function assertTestDatabase(url: string): void { export async function startTestDatabase(): Promise { if (prisma) return prisma; - // ONLY DATABASE_URL_TEST is honored. We deliberately do NOT fall back to - // DATABASE_URL: that fallback caused the 2026-05-02 prod-wipe incident - // (a developer had prod DATABASE_URL in .env, no DATABASE_URL_TEST, and - // the integration setup silently truncated production data). If the - // explicit test URL is unset, spin up a fresh testcontainer instead. - const explicitTestUrl = process.env.DATABASE_URL_TEST; + // Use ONLY DATABASE_URL_TEST. The `?? process.env.DATABASE_URL` fallback was + // removed deliberately: it caused a production-data-loss incident when the + // app's real DATABASE_URL leaked into the test runner. Tests now either + // explicitly set DATABASE_URL_TEST (local/testcontainer) or fall through to + // spinning up a testcontainer locally. There is NO silent fallback. + const existingUrl = process.env.DATABASE_URL_TEST; let connectionUrl: string; - if (explicitTestUrl) { - connectionUrl = explicitTestUrl; + if (existingUrl) { + // Layer 1: managed-service hostname blocklist (cannot be overridden). + assertNotManagedHost(existingUrl); + + // Layer 2: refuse non-local DBs unless the DB name contains "test" or the + // user explicitly opts in. Integration tests TRUNCATE tables in afterEach + // hooks; running against prod would wipe real data. + // + // Allow: + // - localhost / 127.0.0.1 / ::1 hosts + // - testcontainers (host.docker.internal, dynamic ports) + // - URLs whose database name contains "test" (e.g. exponential_test) + // - explicit opt-in via ALLOW_NON_LOCAL_TEST_DB=1 (escape hatch for + // deliberate scenarios; never set this in normal dev). Note: this does + // NOT bypass the managed-host blocklist above. + const isLocalhost = /@(localhost|127\.0\.0\.1|\[::1\]|host\.docker\.internal)[:\/]/.test( + existingUrl, + ); + const dbNameMatch = /\/([^?\/]+)(\?|$)/.exec(existingUrl); + const dbName = dbNameMatch?.[1] ?? ""; + const dbNameLooksLikeTest = /test/i.test(dbName); + const explicitOptIn = process.env.ALLOW_NON_LOCAL_TEST_DB === "1"; + + if (!isLocalhost && !dbNameLooksLikeTest && !explicitOptIn) { + const sanitized = existingUrl.replace(/:([^@/]+)@/, ":***@"); + throw new Error( + `[test-db] Refusing to run integration tests against non-local DB: ${sanitized}\n` + + `\n` + + `Integration tests TRUNCATE tables between runs and would destroy real data.\n` + + `\n` + + `Fix one of:\n` + + ` 1. Set DATABASE_URL_TEST to a local Postgres (e.g. postgres://postgres:postgres@localhost:5432/exponential_test)\n` + + ` 2. Unset DATABASE_URL_TEST so a testcontainer spins up automatically (requires Docker)\n` + + ` 3. Rename your test DB to include 'test' in the database name\n` + + ` 4. Set ALLOW_NON_LOCAL_TEST_DB=1 to override (only if you know what you're doing)`, + ); + } + + connectionUrl = existingUrl; } else { // Locally, spin up a testcontainer container = await new PostgreSqlContainer("pgvector/pgvector:pg16") @@ -81,6 +165,17 @@ export async function startTestDatabase(): Promise { }); await prisma.$connect(); + + // Layer 4: stamp a marker row so truncateAllTables can prove this DB was + // initialized as a test DB. If the marker is missing later, we refuse to + // truncate. Production DBs will not have this table. + await prisma.$executeRawUnsafe( + `CREATE TABLE IF NOT EXISTS "${TEST_DB_MARKER_TABLE}" (token text PRIMARY KEY)`, + ); + await prisma.$executeRawUnsafe( + `INSERT INTO "${TEST_DB_MARKER_TABLE}" (token) VALUES ('${TEST_DB_MARKER_TOKEN}') ON CONFLICT DO NOTHING`, + ); + return prisma; } @@ -97,59 +192,131 @@ export function getTestDb(): PrismaClient { /** * Delete all data between tests for isolation. - * Uses DELETE (not TRUNCATE) to avoid AccessExclusiveLock overhead, - * which is much faster for the small datasets in tests. - * Tables are ordered to respect foreign key constraints. + * + * Strategy: dynamically discover every user table from pg_tables, then DELETE + * from all of them in a single transaction with `session_replication_role = + * replica` set, which disables FK trigger enforcement for the duration of the + * transaction. This sidesteps both: + * + * 1. The brittleness of the original hand-maintained DELETE list (it broke + * whenever a new table like UserExercise was added to the schema, causing + * FK violations that rolled back the whole transaction and broke + * isolation). + * 2. The slowness of `TRUNCATE ... CASCADE` on this Postgres instance, which + * hung past the 120s hook timeout when run across all ~147 tables (likely + * due to AccessExclusiveLock contention on a remote DB with non-trivial + * metadata). + * + * Trade-offs vs. the original ordered-DELETE approach: + * - PRO: Zero maintenance — adding a new model never breaks isolation. + * - PRO: Fast — DELETE is per-row but tests keep tables tiny. + * - CON: Does NOT reset sequences. Tests should not rely on auto-increment + * IDs starting at 1; use the returned IDs from factories instead. + * - CON: `session_replication_role = replica` requires SUPERUSER or + * REPLICATION privileges. If that fails, fall back to a per-statement loop + * that retries until no FK errors remain. */ export async function truncateAllTables(): Promise { const db = getTestDb(); - // Defense-in-depth: re-check the live DATABASE_URL right before issuing - // destructive SQL, even though startTestDatabase() already gated it. If - // something somehow swapped the env between setup and afterEach, refuse. - const liveUrl = process.env.DATABASE_URL; - if (liveUrl) assertTestDatabase(liveUrl); - - // Delete in dependency order (children before parents) to avoid FK violations. - // This is faster than TRUNCATE CASCADE for small test datasets. - await db.$transaction([ - db.$executeRawUnsafe(`DELETE FROM "ActionAssignee"`), - db.$executeRawUnsafe(`DELETE FROM "ActionTag"`), - db.$executeRawUnsafe(`DELETE FROM "Action"`), - // Product Management plugin tables (children before parents) - db.$executeRawUnsafe(`DELETE FROM "TicketDependency"`), - db.$executeRawUnsafe(`DELETE FROM "TicketTag"`), - db.$executeRawUnsafe(`DELETE FROM "FeatureTag"`), - db.$executeRawUnsafe(`DELETE FROM "TicketComment"`), - db.$executeRawUnsafe(`DELETE FROM "FeatureInsight"`), - db.$executeRawUnsafe(`DELETE FROM "InsightTag"`), - db.$executeRawUnsafe(`DELETE FROM "Insight"`), - db.$executeRawUnsafe(`DELETE FROM "Retrospective"`), - db.$executeRawUnsafe(`DELETE FROM "Ticket"`), - db.$executeRawUnsafe(`DELETE FROM "TicketTemplate"`), - db.$executeRawUnsafe(`DELETE FROM "UserStory"`), - db.$executeRawUnsafe(`DELETE FROM "FeatureScope"`), - db.$executeRawUnsafe(`DELETE FROM "Research"`), - db.$executeRawUnsafe(`DELETE FROM "Feature"`), - db.$executeRawUnsafe(`DELETE FROM "Product"`), - // Existing tables - db.$executeRawUnsafe(`DELETE FROM "Outcome"`), - db.$executeRawUnsafe(`DELETE FROM "Goal"`), - // TranscriptionSession references Project (no onDelete) and Workspace, - // so its rows (and Screenshot/Participant children) must go first. - db.$executeRawUnsafe(`DELETE FROM "Screenshot"`), - db.$executeRawUnsafe(`DELETE FROM "TranscriptionSessionParticipant"`), - db.$executeRawUnsafe(`DELETE FROM "TranscriptionSession"`), - db.$executeRawUnsafe(`DELETE FROM "ProjectMember"`), - db.$executeRawUnsafe(`DELETE FROM "Project"`), - db.$executeRawUnsafe(`DELETE FROM "TeamUser"`), - db.$executeRawUnsafe(`DELETE FROM "Team"`), - db.$executeRawUnsafe(`DELETE FROM "WorkspaceUser"`), - db.$executeRawUnsafe(`DELETE FROM "Workspace"`), - db.$executeRawUnsafe(`DELETE FROM "Account"`), - db.$executeRawUnsafe(`DELETE FROM "Session"`), - db.$executeRawUnsafe(`DELETE FROM "User"`), - ]); + // Layer 4 (final guard): the marker row inserted by startTestDatabase must + // exist. If it doesn't, this DB was not initialized as a test DB — refuse + // to truncate. This catches any path that bypassed the URL safety guards + // (e.g. someone hand-constructed a PrismaClient pointed at production). + let marker: { token: string }[]; + try { + marker = await db.$queryRawUnsafe<{ token: string }[]>( + `SELECT token FROM "${TEST_DB_MARKER_TABLE}" WHERE token = '${TEST_DB_MARKER_TOKEN}'`, + ); + } catch (err) { + throw new Error( + `[test-db] Refusing to truncate: marker table "${TEST_DB_MARKER_TABLE}" is missing.\n` + + `This database was NOT initialized via startTestDatabase() — refusing to wipe it.\n` + + `Original error: ${err instanceof Error ? err.message : String(err)}`, + ); + } + if (marker.length === 0) { + throw new Error( + `[test-db] Refusing to truncate: marker row not found in "${TEST_DB_MARKER_TABLE}".\n` + + `This database was NOT initialized via startTestDatabase() — refusing to wipe it.`, + ); + } + + // Layer 3: pre-truncate row count check. Real production DBs will trip + // this trivially; freshly-set-up test DBs will not. Tables that don't exist + // (e.g. before migrations have run) are treated as empty. + try { + const userCountRows = await db.$queryRawUnsafe<{ count: bigint }[]>( + `SELECT COUNT(*)::bigint AS count FROM "User"`, + ); + const userCount = Number(userCountRows[0]?.count ?? 0n); + if (userCount > MAX_USERS_BEFORE_REFUSE) { + throw new Error( + `[test-db] Refusing to truncate: User table has ${userCount} rows ` + + `(threshold: ${MAX_USERS_BEFORE_REFUSE}). This looks like a real database, not a test DB.`, + ); + } + } catch (err) { + // Re-throw our own refusal; swallow "relation does not exist" only. + if (err instanceof Error && /relation .* does not exist/i.test(err.message)) { + // table not migrated yet — fine + } else { + throw err; + } + } + + try { + const chunkCountRows = await db.$queryRawUnsafe<{ count: bigint }[]>( + `SELECT COUNT(*)::bigint AS count FROM "KnowledgeChunk"`, + ); + const chunkCount = Number(chunkCountRows[0]?.count ?? 0n); + if (chunkCount > MAX_KNOWLEDGE_CHUNKS_BEFORE_REFUSE) { + throw new Error( + `[test-db] Refusing to truncate: KnowledgeChunk has ${chunkCount} rows ` + + `(threshold: ${MAX_KNOWLEDGE_CHUNKS_BEFORE_REFUSE}). This looks like a real database, not a test DB.`, + ); + } + } catch (err) { + if (err instanceof Error && /relation .* does not exist/i.test(err.message)) { + // table not migrated yet — fine + } else { + throw err; + } + } + + // Fetch every user table in the public schema, except the Prisma migrations + // bookkeeping table and our own marker. Cast to a typed shape so TS is happy. + const rows = await db.$queryRawUnsafe<{ tablename: string }[]>( + `SELECT tablename FROM pg_tables + WHERE schemaname = 'public' + AND tablename != '_prisma_migrations' + AND tablename != '${TEST_DB_MARKER_TABLE}'`, + ); + + if (rows.length === 0) return; + + const quotedTables = rows.map((r) => `"${r.tablename}"`); + + // Build a single transaction that disables FK enforcement, deletes from + // every table, then restores normal enforcement. `session_replication_role` + // is per-session, so this only affects our one connection. + // + // Use `db.$transaction([])` with an array of pre-built queries — this is + // significantly faster than the interactive callback form because Prisma + // pipelines the statements rather than round-tripping per query. The + // interactive form was hitting the default 5s timeout on remote (Railway) + // databases. + const statements = [ + db.$executeRawUnsafe(`SET LOCAL session_replication_role = 'replica'`), + ...quotedTables.map((table) => + db.$executeRawUnsafe(`DELETE FROM ${table}`), + ), + ]; + await db.$transaction(statements, { + // Generous timeout for remote test DBs where ~150 round-trips can be slow. + timeout: 60_000, + maxWait: 10_000, + }); } /** diff --git a/src/test/trpc-helpers.ts b/src/test/trpc-helpers.ts index 88f465b6..df675472 100644 --- a/src/test/trpc-helpers.ts +++ b/src/test/trpc-helpers.ts @@ -5,6 +5,11 @@ import { getTestDb } from "./test-db"; /** * Create a tRPC caller authenticated as the given user. * Uses createCaller from root.ts to exercise the full middleware chain. + * + * NOTE: This helper depends on a real (test) Postgres via `getTestDb()`. Use it + * ONLY from `*.integration.test.ts` files that opt into the integration runner + * (see vitest.config.ts). For unit tests with a mocked Prisma client, use + * `createMockCaller` instead. */ export function createTestCaller(userId: string, overrides?: { email?: string; name?: string; isAdmin?: boolean }) { const db = getTestDb(); @@ -25,6 +30,40 @@ export function createTestCaller(userId: string, overrides?: { email?: string; n }); } +/** + * Create a tRPC caller backed by an injected (mocked) PrismaClient. + * + * Intended for unit tests that use `vitest-mock-extended`'s + * `mockDeep()`. Pass the mock as `db` and stub the methods you + * need per test — no real database is ever touched. + * + * The caller of this helper is also responsible for `vi.mock("~/server/db", + * ...)`-ing the global db import so any code path that reads `~/server/db` + * directly (e.g. resolvers) sees the same mock. + */ +export function createMockCaller(opts: { + userId: string; + db: PrismaClient; + email?: string; + name?: string; + isAdmin?: boolean; +}) { + return createCaller({ + db: opts.db, + session: { + user: { + id: opts.userId, + email: opts.email ?? `${opts.userId}@test.com`, + name: opts.name ?? "Test User", + image: null, + isAdmin: opts.isAdmin ?? false, + }, + expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }, + headers: new Headers(), + }); +} + /** * Create an unauthenticated tRPC caller (no session). * Useful for testing public procedures or verifying auth guards.