Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/taskService.auth.test.ts
T

501 lines
17 KiB
TypeScript

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<unknown>) => 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> = {}): 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();
});
});