import { beforeEach, describe, expect, it, vi } from "vitest"; import { TenantUserStatus } from "~/application/enums/tenants/TenantUserStatus"; const mocks = vi.hoisted(() => ({ tx: { $executeRaw: vi.fn(), task: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), }, taskWatcher: { findFirst: vi.fn(), upsert: vi.fn(), deleteMany: vi.fn(), }, tenantUser: { findFirst: vi.fn() }, userRole: { findMany: vi.fn() }, group: { findFirst: vi.fn() }, groupUser: { findFirst: vi.fn(), findMany: vi.fn() }, projektMitglied: { findMany: vi.fn() }, projekt: { findFirst: vi.fn() }, kontakt: { findFirst: vi.fn() }, inboxLead: { findFirst: vi.fn() }, appointment: { findFirst: vi.fn() }, }, publishTaskSearchUpsert: vi.fn(), publishSearchProjectionDelete: vi.fn(), })); vi.mock("~/utils/db/.server/tenantRls.server", () => ({ withTenantRLS: async (_tenantId: string, callback: (tx: typeof mocks.tx) => Promise) => callback(mocks.tx), })); vi.mock("~/services/search/searchIndexProjection.server", () => ({ publishTaskSearchUpsert: mocks.publishTaskSearchUpsert, publishSearchProjectionDelete: mocks.publishSearchProjectionDelete, })); import { addWatcher, archiveTask, completeTask, createAcceptanceFollowUpTask, createIdempotentTaskFromSource, createTask, hardDeleteTask, isTaskVisible, reactivateTask, reassignTask, removeWatcher, updateTask, type TaskRow, type TaskVisibilityContext, } from "./taskService.server"; const TENANT_ID = "tenant-a"; function context(permissions: readonly string[]): TaskVisibilityContext { return { userId: "actor-a", permissions, roleIds: ["role-a"], groupIds: ["group-a"], }; } function task(overrides: Partial = {}): TaskRow { return { id: "task-a", tenantId: TENANT_ID, createdByUserId: "actor-a", title: "Visible task", description: null, status: "open", priority: "normal", deadline: null, assignedToUserId: "actor-a", assignedToGroupId: null, linkedEntityType: null, linkedEntityId: null, source: "manual", sourceRef: null, completedAt: null, isDemoData: false, createdAt: new Date("2026-07-13T08:00:00.000Z"), updatedAt: new Date("2026-07-13T08:00:00.000Z"), archivedAt: null, ...overrides, }; } describe("task mutation authorization", () => { beforeEach(() => { vi.clearAllMocks(); mocks.tx.task.findFirst.mockResolvedValue(task()); mocks.tx.$executeRaw.mockResolvedValue(1); mocks.tx.task.create.mockResolvedValue(task()); mocks.tx.task.update.mockResolvedValue(task()); mocks.tx.taskWatcher.findFirst.mockResolvedValue(null); mocks.tx.tenantUser.findFirst.mockResolvedValue({ id: "member-a" }); mocks.tx.group.findFirst.mockResolvedValue({ id: "group-a" }); mocks.tx.groupUser.findMany.mockResolvedValue([]); mocks.tx.userRole.findMany.mockResolvedValue([]); mocks.tx.projektMitglied.findMany.mockResolvedValue([]); mocks.tx.projekt.findFirst.mockResolvedValue({ id: "project-a" }); mocks.tx.kontakt.findFirst.mockResolvedValue({ id: "contact-a" }); mocks.tx.inboxLead.findFirst.mockResolvedValue({ id: "lead-a" }); mocks.tx.appointment.findFirst.mockResolvedValue({ id: "appointment-a" }); }); it("rejects every mutation when the actor cannot see the task", async () => { const hidden = task({ createdByUserId: "other-user", assignedToUserId: "other-user" }); const ctx = context(["tasks.write", "tasks.delete", "tasks.read_own"]); const attempts = [ () => updateTask({ tenantId: TENANT_ID, taskId: hidden.id, ctx, title: "Changed" }), () => reassignTask({ tenantId: TENANT_ID, taskId: hidden.id, ctx, assignedToUserId: "actor-a" }), () => completeTask({ tenantId: TENANT_ID, taskId: hidden.id, ctx }), () => archiveTask({ tenantId: TENANT_ID, taskId: hidden.id, ctx }), () => reactivateTask({ tenantId: TENANT_ID, taskId: hidden.id, ctx }), () => hardDeleteTask({ tenantId: TENANT_ID, taskId: hidden.id, ctx }), () => addWatcher({ tenantId: TENANT_ID, taskId: hidden.id, userId: "actor-a", ctx }), () => removeWatcher({ tenantId: TENANT_ID, taskId: hidden.id, userId: "actor-a", ctx }), ]; for (const attempt of attempts) { mocks.tx.task.findFirst.mockResolvedValueOnce(hidden); await expect(attempt()).rejects.toMatchObject({ status: 404 }); } expect(mocks.tx.task.update).not.toHaveBeenCalled(); expect(mocks.tx.task.delete).not.toHaveBeenCalled(); expect(mocks.tx.taskWatcher.upsert).not.toHaveBeenCalled(); expect(mocks.tx.taskWatcher.deleteMany).not.toHaveBeenCalled(); }); it("requires delete permission inside the service before touching the database", async () => { await expect( hardDeleteTask({ tenantId: TENANT_ID, taskId: "task-a", ctx: context(["tasks.write", "tasks.read_all"]), }), ).rejects.toMatchObject({ status: 404 }); expect(mocks.tx.task.findFirst).not.toHaveBeenCalled(); expect(mocks.tx.task.delete).not.toHaveBeenCalled(); }); it("does not grant task visibility from ownership without a read permission", async () => { await expect(isTaskVisible(task(), context(["tasks.write"]))).resolves.toBe(false); await expect( updateTask({ tenantId: TENANT_ID, taskId: "task-a", ctx: context(["tasks.write"]), title: "Guessed owner task", }), ).rejects.toMatchObject({ status: 404 }); expect(mocks.tx.task.update).not.toHaveBeenCalled(); expect(mocks.tx.taskWatcher.findFirst).not.toHaveBeenCalled(); }); it("keeps the tenant predicate on the final hard delete", async () => { await hardDeleteTask({ tenantId: TENANT_ID, taskId: "task-a", ctx: context(["tasks.delete", "tasks.read_all"]), }); expect(mocks.tx.task.delete).toHaveBeenCalledWith({ where: { id: "task-a", tenantId: TENANT_ID } }); }); it("rejects cross-tenant users and groups before assigning them", async () => { mocks.tx.tenantUser.findFirst.mockImplementation(({ where }: { where: { userId: string } }) => Promise.resolve(where.userId === "actor-a" ? { id: "member-a" } : null), ); await expect( createTask({ tenantId: TENANT_ID, ctx: context(["tasks.write"]), title: "Cross-tenant assignee", assignedToUserId: "foreign-user", }), ).rejects.toMatchObject({ status: 404 }); expect(mocks.tx.task.create).not.toHaveBeenCalled(); mocks.tx.group.findFirst.mockResolvedValueOnce(null); await expect( reassignTask({ tenantId: TENANT_ID, taskId: "task-a", ctx: context(["tasks.write", "tasks.read_own"]), assignedToGroupId: "foreign-group", }), ).rejects.toMatchObject({ status: 404 }); expect(mocks.tx.task.update).not.toHaveBeenCalled(); }); it("allows the acceptance workflow to create only its project follow-up task", async () => { await createAcceptanceFollowUpTask({ tenantId: TENANT_ID, ctx: context(["acceptance.write", "projects.read_all"]), title: "Resolve acceptance defects", linkedEntityType: "project", linkedEntityId: "project-a", sourceRef: "acceptance-a", }); expect(mocks.tx.task.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ source: "automation", sourceRef: "acceptance-a", linkedEntityType: "project", linkedEntityId: "project-a", }), }), ); await expect( createAcceptanceFollowUpTask({ tenantId: TENANT_ID, ctx: context(["tasks.write", "projects.read_all"]), title: "Forged acceptance follow-up", linkedEntityType: "project", linkedEntityId: "project-a", sourceRef: "acceptance-b", }), ).rejects.toMatchObject({ status: 404 }); }); it("serializes and replays task creation by tenant/source/sourceRef", async () => { const existing = task({ id: "chat-task", source: "chat", sourceRef: "chat-message:message-1" }); mocks.tx.task.findFirst.mockResolvedValueOnce(existing); const result = await createIdempotentTaskFromSource({ tenantId: TENANT_ID, ctx: context(["tasks.write"]), title: "Message task", source: "chat", sourceRef: "chat-message:message-1", }); expect(result).toEqual({ task: existing, replayed: true }); expect(mocks.tx.$executeRaw).toHaveBeenCalledOnce(); expect(mocks.tx.task.findFirst).toHaveBeenCalledWith({ where: { tenantId: TENANT_ID, source: "chat", sourceRef: "chat-message:message-1", archivedAt: null }, }); expect(mocks.tx.task.create).not.toHaveBeenCalled(); }); it("rejects a foreign watcher before writing the watcher relation", async () => { mocks.tx.tenantUser.findFirst.mockResolvedValueOnce(null); await expect( addWatcher({ tenantId: TENANT_ID, taskId: "task-a", userId: "foreign-user", ctx: context(["tasks.write", "tasks.read_own"]), }), ).rejects.toMatchObject({ status: 404 }); expect(mocks.tx.taskWatcher.upsert).not.toHaveBeenCalled(); }); it("rejects project links outside the central project and pipeline scope", async () => { mocks.tx.projekt.findFirst.mockResolvedValueOnce(null); await expect( createTask({ tenantId: TENANT_ID, ctx: context(["tasks.write", "projects.read_all"]), title: "Hidden project link", linkedEntityType: "project", linkedEntityId: "project-hidden", }), ).rejects.toMatchObject({ status: 404 }); expect(mocks.tx.projekt.findFirst).toHaveBeenCalledWith({ where: expect.objectContaining({ id: "project-hidden", tenantId: TENANT_ID, archivedAt: null, pipeline: expect.objectContaining({ accessRules: expect.objectContaining({ none: expect.objectContaining({ kannSehen: false }) }), }), }), select: { id: true }, }); expect(JSON.stringify(mocks.tx.projekt.findFirst.mock.calls[0]?.[0]?.where)).toContain("role-a"); expect(JSON.stringify(mocks.tx.projekt.findFirst.mock.calls[0]?.[0]?.where)).toContain("group-a"); expect(mocks.tx.task.create).not.toHaveBeenCalled(); mocks.tx.projekt.findFirst.mockResolvedValueOnce(null); await expect( updateTask({ tenantId: TENANT_ID, taskId: "task-a", ctx: context(["tasks.write", "tasks.read_own", "projects.read_all"]), linkedEntityType: "project", linkedEntityId: "project-hidden", }), ).rejects.toMatchObject({ status: 404 }); expect(mocks.tx.task.update).not.toHaveBeenCalled(); }); it("accepts a visible member project link and persists it", async () => { await createTask({ tenantId: TENANT_ID, ctx: context(["tasks.write", "projects.read_own_member"]), title: "Visible project link", linkedEntityType: "project", linkedEntityId: "project-a", }); const projectWhere = mocks.tx.projekt.findFirst.mock.calls[0]?.[0]?.where; expect(projectWhere).toEqual( expect.objectContaining({ id: "project-a", tenantId: TENANT_ID, AND: expect.arrayContaining([ { OR: [ { mitglieder: { some: { tenantId: TENANT_ID, archivedAt: null, OR: [{ userId: "actor-a" }, { groupId: { in: ["group-a"] } }], }, }, }, ], }, ]), }), ); expect(mocks.tx.task.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ tenantId: TENANT_ID, createdByUserId: "actor-a", linkedEntityType: "project", linkedEntityId: "project-a", }), }), ); }); it("loads project principals lazily only when a task receives a project link", async () => { mocks.tx.userRole.findMany.mockResolvedValueOnce([{ roleId: "role-loaded" }]); mocks.tx.groupUser.findMany.mockResolvedValueOnce([{ groupId: "group-loaded" }]); await createTask({ tenantId: TENANT_ID, ctx: { userId: "actor-a", permissions: ["tasks.write", "projects.read_own_member"] }, title: "Lazy project context", linkedEntityType: "project", linkedEntityId: "project-a", }); expect(mocks.tx.userRole.findMany).toHaveBeenCalledWith({ where: { tenantId: TENANT_ID, userId: "actor-a" }, select: { roleId: true }, }); expect(mocks.tx.groupUser.findMany).toHaveBeenCalledWith({ where: { userId: "actor-a", group: { tenantId: TENANT_ID } }, select: { groupId: true }, }); expect(mocks.tx.projektMitglied.findMany).not.toHaveBeenCalled(); const projectWhere = mocks.tx.projekt.findFirst.mock.calls[0]?.[0]?.where; expect(JSON.stringify(projectWhere)).toContain("role-loaded"); expect(JSON.stringify(projectWhere)).toContain("group-loaded"); expect(JSON.stringify(projectWhere)).toContain("project-a"); }); it.each([ ["contact", "contact-a"], ["company", "company-a"], ["lead", "lead-a"], ["project", "project-a"], ["deal", "project-a"], ["order", "project-a"], ["user", "other-user"], ["appointment", "appointment-a"], ] as const)("rejects a %s link when the actor lacks the target read permission", async (linkedEntityType, linkedEntityId) => { await expect( createTask({ tenantId: TENANT_ID, ctx: context(["tasks.write"]), title: "Unauthorized entity link", linkedEntityType, linkedEntityId, }), ).rejects.toMatchObject({ status: 404 }); expect(mocks.tx.task.create).not.toHaveBeenCalled(); }); it("rejects a cross-tenant contact link on update through the CRM owner scope", async () => { mocks.tx.kontakt.findFirst.mockResolvedValueOnce(null); await expect( updateTask({ tenantId: TENANT_ID, taskId: "task-a", ctx: context(["tasks.write", "tasks.read_own", "crm.read_all"]), linkedEntityType: "contact", linkedEntityId: "contact-from-tenant-b", }), ).rejects.toMatchObject({ status: 404 }); expect(mocks.tx.kontakt.findFirst).toHaveBeenCalledWith({ where: { id: "contact-from-tenant-b", tenantId: TENANT_ID, archivedAt: null, kontaktTyp: "privatperson", }, select: { id: true }, }); expect(mocks.tx.task.update).not.toHaveBeenCalled(); }); it("rejects a cross-tenant employee link even for an HR-wide reader", async () => { mocks.tx.tenantUser.findFirst .mockResolvedValueOnce({ id: "actor-membership" }) .mockResolvedValueOnce({ id: "actor-membership" }) .mockResolvedValueOnce(null); await expect( createTask({ tenantId: TENANT_ID, ctx: context(["tasks.write", "hr.read_all"]), title: "Foreign employee link", linkedEntityType: "user", linkedEntityId: "user-from-tenant-b", }), ).rejects.toMatchObject({ status: 404 }); expect(mocks.tx.tenantUser.findFirst).toHaveBeenLastCalledWith({ where: { tenantId: TENANT_ID, userId: "user-from-tenant-b", status: TenantUserStatus.ACTIVE, user: { active: true }, }, select: { id: true }, }); expect(mocks.tx.task.create).not.toHaveBeenCalled(); }); it.each([ ["deal", "sales"], ["order", "auftrag"], ] as const)("rejects a %s link when the visible project has the wrong pipeline discriminator", async (linkedEntityType, expectedPipelineType) => { mocks.tx.projekt.findFirst.mockResolvedValueOnce({ id: "project-a" }).mockResolvedValueOnce(null); await expect( createTask({ tenantId: TENANT_ID, ctx: context(["tasks.write", "projects.read_all"]), title: "Mismatched project view", linkedEntityType, linkedEntityId: "project-a", }), ).rejects.toMatchObject({ status: 404 }); expect(mocks.tx.projekt.findFirst).toHaveBeenLastCalledWith({ where: { id: "project-a", tenantId: TENANT_ID, archivedAt: null, pipeline: { tenantId: TENANT_ID, archivedAt: null, typHint: expectedPipelineType }, }, select: { id: true }, }); expect(mocks.tx.task.create).not.toHaveBeenCalled(); }); it("fails closed for a runtime entity type outside the Task enum", async () => { await expect( createTask({ tenantId: TENANT_ID, ctx: context(["tasks.write"]), title: "Unknown entity link", linkedEntityType: "email_message" as never, linkedEntityId: "message-a", }), ).rejects.toMatchObject({ status: 404 }); expect(mocks.tx.task.create).not.toHaveBeenCalled(); }); });