501 lines
17 KiB
TypeScript
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();
|
|
});
|
|
});
|