694 lines
29 KiB
TypeScript
694 lines
29 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { TenantUserStatus } from "~/application/enums/tenants/TenantUserStatus";
|
|
import {
|
|
assertFileLinkTargetVisibleWithTx,
|
|
createFileVisibilityQueryState,
|
|
fileReadScopeWhereWithTx,
|
|
filterVisibleFileLinksWithTx,
|
|
type FileVisibilityContext,
|
|
} from "./fileVisibility.server";
|
|
import type { FileEntityType } from "./fileService.server";
|
|
|
|
function transaction() {
|
|
return {
|
|
fileEntityLink: { findMany: vi.fn() },
|
|
projekt: { findMany: vi.fn(), findFirst: vi.fn() },
|
|
kontakt: { findFirst: vi.fn(), findMany: vi.fn() },
|
|
inboxLead: { findFirst: vi.fn(), findMany: vi.fn() },
|
|
tenantUser: { findFirst: vi.fn(), findMany: vi.fn() },
|
|
formSubmission: { findFirst: vi.fn(), findMany: vi.fn() },
|
|
task: { findFirst: vi.fn(), findMany: vi.fn() },
|
|
appointment: { findFirst: vi.fn(), findMany: vi.fn() },
|
|
projektSignatureDocument: { findFirst: vi.fn(), findMany: vi.fn() },
|
|
userRole: { findMany: vi.fn() },
|
|
groupUser: { findMany: vi.fn() },
|
|
projektMitglied: { findMany: vi.fn() },
|
|
teamChatMessage: { findMany: vi.fn(), findFirst: vi.fn() },
|
|
};
|
|
}
|
|
|
|
describe("fileReadScopeWhereWithTx", () => {
|
|
const ownUserLink = {
|
|
links: { some: { tenantId: "tenant_1", entityType: "user", entityId: "user_1" } },
|
|
};
|
|
const nonForeignHrLink = {
|
|
links: { none: { tenantId: "tenant_1", entityType: "user", entityId: { not: "user_1" } } },
|
|
};
|
|
|
|
it("does not treat an arbitrary in-tenant entity link as files.read_own access", async () => {
|
|
const tx = transaction();
|
|
const ctx: FileVisibilityContext = { userId: "user_1", permissions: ["files.read_own"] };
|
|
|
|
await expect(fileReadScopeWhereWithTx(tx as never, "tenant_1", ctx, ["file_1"])).resolves.toEqual({
|
|
AND: [{ OR: [{ createdByUserId: "user_1" }, { assignedToUserId: "user_1" }, ownUserLink] }, nonForeignHrLink],
|
|
});
|
|
expect(tx.fileEntityLink.findMany).not.toHaveBeenCalled();
|
|
expect(tx.projekt.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("transitively authorizes only links to projects visible through membership and PipelineAccess", async () => {
|
|
const tx = transaction();
|
|
tx.fileEntityLink.findMany.mockResolvedValue([{ entityId: "project_visible" }, { entityId: "project_denied" }]);
|
|
tx.projekt.findMany.mockResolvedValue([{ id: "project_visible" }]);
|
|
const ctx: FileVisibilityContext = {
|
|
userId: "user_1",
|
|
permissions: ["files.read_own", "projects.read_own_member"],
|
|
roleIds: ["role_1"],
|
|
groupIds: ["group_1"],
|
|
};
|
|
|
|
await expect(fileReadScopeWhereWithTx(tx as never, "tenant_1", ctx, ["file_1"])).resolves.toEqual({
|
|
AND: [
|
|
{
|
|
OR: [
|
|
{ createdByUserId: "user_1" },
|
|
{ assignedToUserId: "user_1" },
|
|
{
|
|
links: {
|
|
some: {
|
|
tenantId: "tenant_1",
|
|
entityType: "project",
|
|
entityId: { in: ["project_visible"] },
|
|
},
|
|
},
|
|
},
|
|
ownUserLink,
|
|
],
|
|
},
|
|
nonForeignHrLink,
|
|
],
|
|
});
|
|
expect(tx.fileEntityLink.findMany).toHaveBeenCalledWith({
|
|
where: { tenantId: "tenant_1", entityType: "project", fileId: { in: ["file_1"] } },
|
|
select: { entityId: true },
|
|
distinct: ["entityId"],
|
|
});
|
|
expect(tx.projekt.findMany.mock.calls[0]?.[0].where).toEqual({
|
|
tenantId: "tenant_1",
|
|
id: { in: ["project_visible", "project_denied"] },
|
|
archivedAt: null,
|
|
AND: [
|
|
{
|
|
OR: [
|
|
{
|
|
mitglieder: {
|
|
some: {
|
|
tenantId: "tenant_1",
|
|
archivedAt: null,
|
|
OR: [{ userId: "user_1" }, { groupId: { in: ["group_1"] } }],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
pipeline: {
|
|
accessRules: {
|
|
none: {
|
|
tenantId: "tenant_1",
|
|
kannSehen: false,
|
|
OR: [
|
|
{ principalType: "user", principalId: "user_1" },
|
|
{ principalType: "role", principalId: "role_1" },
|
|
{ principalType: "group", principalId: "group_1" },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("keeps foreign user-linked HR files hidden even for files.read_all", async () => {
|
|
const tx = transaction();
|
|
const ctx: FileVisibilityContext = { userId: "user_1", permissions: ["files.read_all"] };
|
|
|
|
await expect(fileReadScopeWhereWithTx(tx as never, "tenant_1", ctx)).resolves.toEqual(nonForeignHrLink);
|
|
expect(tx.fileEntityLink.findMany).not.toHaveBeenCalled();
|
|
expect(tx.projekt.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps PipelineAccess deny filtering when project permission is read_all", async () => {
|
|
const tx = transaction();
|
|
tx.projekt.findMany.mockResolvedValue([]);
|
|
const ctx: FileVisibilityContext = {
|
|
userId: "user_1",
|
|
permissions: ["files.read_own", "projects.read_all"],
|
|
roleIds: ["role_1"],
|
|
groupIds: ["group_1"],
|
|
};
|
|
|
|
await expect(fileReadScopeWhereWithTx(tx as never, "tenant_1", ctx)).resolves.toEqual({
|
|
AND: [{ OR: [{ createdByUserId: "user_1" }, { assignedToUserId: "user_1" }, ownUserLink] }, nonForeignHrLink],
|
|
});
|
|
expect(tx.projekt.findMany.mock.calls[0]?.[0].where).toEqual({
|
|
tenantId: "tenant_1",
|
|
archivedAt: null,
|
|
pipeline: {
|
|
accessRules: {
|
|
none: {
|
|
tenantId: "tenant_1",
|
|
kannSehen: false,
|
|
OR: [
|
|
{ principalType: "user", principalId: "user_1" },
|
|
{ principalType: "role", principalId: "role_1" },
|
|
{ principalType: "group", principalId: "group_1" },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(tx.fileEntityLink.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("hydrates project principals only when a project-linked file scope needs them", async () => {
|
|
const tx = transaction();
|
|
tx.fileEntityLink.findMany.mockResolvedValue([{ entityId: "project_1" }]);
|
|
tx.userRole.findMany.mockResolvedValue([{ roleId: "role_1" }]);
|
|
tx.groupUser.findMany.mockResolvedValue([{ groupId: "group_1" }]);
|
|
tx.projekt.findMany.mockResolvedValue([{ id: "project_1" }]);
|
|
const ctx: FileVisibilityContext = {
|
|
userId: "user_1",
|
|
permissions: ["files.read_own", "projects.read_own_member"],
|
|
};
|
|
|
|
await fileReadScopeWhereWithTx(tx as never, "tenant_1", ctx, ["file_1"]);
|
|
|
|
expect(tx.userRole.findMany).toHaveBeenCalledWith({
|
|
where: { tenantId: "tenant_1", userId: "user_1" },
|
|
select: { roleId: true },
|
|
});
|
|
expect(tx.groupUser.findMany).toHaveBeenCalledWith({
|
|
where: { userId: "user_1", group: { tenantId: "tenant_1" } },
|
|
select: { groupId: true },
|
|
});
|
|
expect(tx.projektMitglied.findMany).not.toHaveBeenCalled();
|
|
expect(tx.projekt.findMany.mock.calls[0]?.[0].where.AND[0]).toEqual({
|
|
OR: [
|
|
{
|
|
mitglieder: {
|
|
some: {
|
|
tenantId: "tenant_1",
|
|
archivedAt: null,
|
|
OR: [{ userId: "user_1" }, { groupId: { in: ["group_1"] } }],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
});
|
|
expect(JSON.stringify(tx.projekt.findMany.mock.calls[0]?.[0]?.where)).toContain('"principalId":"role_1"');
|
|
});
|
|
|
|
it.each(["hr.read_all", "hr.admin"])("allows tenant-wide HR readers through the user-link override via %s", async (permission) => {
|
|
const tx = transaction();
|
|
const ctx: FileVisibilityContext = { userId: "user_1", permissions: ["files.read_all", permission] };
|
|
|
|
await expect(fileReadScopeWhereWithTx(tx as never, "tenant_1", ctx)).resolves.toEqual({});
|
|
expect(tx.fileEntityLink.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["files.write", "files.admin"])("does not infer a read grant from %s", async (permission) => {
|
|
const tx = transaction();
|
|
const ctx: FileVisibilityContext = { userId: "user_1", permissions: [permission] };
|
|
|
|
await expect(fileReadScopeWhereWithTx(tx as never, "tenant_1", ctx)).resolves.toEqual({ id: { in: [] } });
|
|
});
|
|
|
|
it("does not infer the HR override from hr.write", async () => {
|
|
const tx = transaction();
|
|
const ctx: FileVisibilityContext = { userId: "user_1", permissions: ["files.read_all", "hr.write"] };
|
|
|
|
await expect(fileReadScopeWhereWithTx(tx as never, "tenant_1", ctx)).resolves.toEqual(nonForeignHrLink);
|
|
});
|
|
|
|
it("fails closed without any effective Files read permission", async () => {
|
|
const tx = transaction();
|
|
const ctx: FileVisibilityContext = { userId: "user_1", permissions: ["hr.admin"] };
|
|
|
|
await expect(fileReadScopeWhereWithTx(tx as never, "tenant_1", ctx, ["file_1"])).resolves.toEqual({ id: { in: [] } });
|
|
expect(tx.fileEntityLink.findMany).not.toHaveBeenCalled();
|
|
expect(tx.projekt.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not let an own user link or HR privilege replace a Files read permission", async () => {
|
|
const tx = transaction();
|
|
const ctx: FileVisibilityContext = { userId: "user_1", permissions: ["files.write", "hr.admin"] };
|
|
|
|
await expect(fileReadScopeWhereWithTx(tx as never, "tenant_1", ctx, ["own_hr_file"])).resolves.toEqual({ id: { in: [] } });
|
|
expect(tx.fileEntityLink.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("grants only attachment files linked to messages in the caller's active chats", async () => {
|
|
const tx = transaction();
|
|
tx.fileEntityLink.findMany.mockResolvedValue([{ entityId: "message_visible" }, { entityId: "message_hidden" }]);
|
|
tx.teamChatMessage.findMany.mockResolvedValue([{ id: "message_visible" }]);
|
|
const ctx: FileVisibilityContext = { userId: "user_1", permissions: ["chat.read_own_channels"] };
|
|
|
|
await expect(fileReadScopeWhereWithTx(tx as never, "tenant_1", ctx, ["file_1"])).resolves.toEqual({
|
|
AND: [
|
|
{
|
|
OR: [
|
|
{
|
|
links: {
|
|
some: {
|
|
tenantId: "tenant_1",
|
|
entityType: "chat_message",
|
|
entityId: { in: ["message_visible"] },
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
nonForeignHrLink,
|
|
],
|
|
});
|
|
expect(tx.teamChatMessage.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
tenantId: "tenant_1",
|
|
id: { in: ["message_visible", "message_hidden"] },
|
|
chat: { tenantId: "tenant_1", members: { some: { tenantId: "tenant_1", userId: "user_1", leftAt: null } } },
|
|
},
|
|
select: { id: true },
|
|
});
|
|
});
|
|
|
|
it("does not turn chat.read_all_channels into an unaudited Files download bypass", async () => {
|
|
const tx = transaction();
|
|
tx.fileEntityLink.findMany.mockResolvedValue([{ entityId: "message_visible" }, { entityId: "message_foreign" }]);
|
|
tx.teamChatMessage.findMany.mockResolvedValue([{ id: "message_visible" }]);
|
|
const ctx: FileVisibilityContext = { userId: "admin_1", permissions: ["chat.read_all_channels"] };
|
|
|
|
await fileReadScopeWhereWithTx(tx as never, "tenant_1", ctx, ["file_1"]);
|
|
|
|
expect(tx.teamChatMessage.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
tenantId: "tenant_1",
|
|
id: { in: ["message_visible", "message_foreign"] },
|
|
chat: { tenantId: "tenant_1", members: { some: { tenantId: "tenant_1", userId: "admin_1", leftAt: null } } },
|
|
},
|
|
select: { id: true },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("filterVisibleFileLinksWithTx", () => {
|
|
it("filters audit administrators' chat links by ordinary active membership", async () => {
|
|
const tx = transaction();
|
|
tx.teamChatMessage.findMany.mockResolvedValue([{ id: "message_member" }]);
|
|
const links = [
|
|
{ entityType: "chat_message" as const, entityId: "message_member", linkRole: "visible" },
|
|
{ entityType: "chat_message" as const, entityId: "message_foreign", linkRole: "hidden" },
|
|
];
|
|
|
|
await expect(
|
|
filterVisibleFileLinksWithTx(tx as never, "tenant_1", links, {
|
|
userId: "admin_1",
|
|
permissions: ["chat.read_all_channels"],
|
|
})
|
|
).resolves.toEqual([links[0]]);
|
|
|
|
expect(tx.teamChatMessage.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
id: { in: ["message_member", "message_foreign"] },
|
|
tenantId: "tenant_1",
|
|
chat: { tenantId: "tenant_1", members: { some: { tenantId: "tenant_1", userId: "admin_1", leftAt: null } } },
|
|
},
|
|
select: { id: true },
|
|
});
|
|
});
|
|
|
|
it("batches each target type, preserves source order, and fails unknown targets closed", async () => {
|
|
const tx = transaction();
|
|
tx.kontakt.findMany.mockImplementation(async ({ where }: { where: { kontaktTyp: string } }) =>
|
|
where.kontaktTyp === "privatperson" ? [{ id: "contact_visible" }] : [{ id: "company_visible" }]
|
|
);
|
|
tx.inboxLead.findMany.mockResolvedValue([{ id: "lead_visible" }]);
|
|
tx.tenantUser.findMany.mockResolvedValue([{ userId: "user_1" }]);
|
|
tx.formSubmission.findMany.mockResolvedValue([{ id: "submission_visible" }]);
|
|
tx.task.findMany.mockResolvedValue([{ id: "task_visible" }]);
|
|
tx.appointment.findMany.mockResolvedValue([{ id: "appointment_visible" }]);
|
|
const links = [
|
|
{ entityType: "task", entityId: "task_visible", linkRole: "first" },
|
|
{ entityType: "contact", entityId: "contact_hidden", linkRole: "hidden" },
|
|
{ entityType: "company", entityId: "company_visible", linkRole: "second" },
|
|
{ entityType: "email_message", entityId: "message_unknown", linkRole: "unknown" },
|
|
{ entityType: "contact", entityId: "contact_visible", linkRole: "third" },
|
|
{ entityType: "lead", entityId: "lead_visible", linkRole: "fourth" },
|
|
{ entityType: "user", entityId: "user_1", linkRole: "fifth" },
|
|
{ entityType: "form_submission", entityId: "submission_visible", linkRole: "sixth" },
|
|
{ entityType: "appointment", entityId: "appointment_visible", linkRole: "seventh" },
|
|
{ entityType: "contact", entityId: "contact_visible", linkRole: "duplicate" },
|
|
];
|
|
const ctx: FileVisibilityContext = {
|
|
userId: "user_1",
|
|
permissions: ["crm.read_all", "leads.read_all", "tasks.read_all", "forms.admin", "bookings.read_all"],
|
|
groupIds: [],
|
|
};
|
|
|
|
await expect(filterVisibleFileLinksWithTx(tx as never, "tenant_1", links, ctx)).resolves.toEqual([
|
|
links[0],
|
|
links[2],
|
|
links[4],
|
|
links[5],
|
|
links[6],
|
|
links[7],
|
|
links[8],
|
|
links[9],
|
|
]);
|
|
expect(tx.kontakt.findMany).toHaveBeenCalledTimes(2);
|
|
expect(tx.inboxLead.findMany).toHaveBeenCalledTimes(1);
|
|
expect(tx.tenantUser.findMany).toHaveBeenCalledTimes(1);
|
|
expect(tx.formSubmission.findMany).toHaveBeenCalledTimes(1);
|
|
expect(tx.task.findMany).toHaveBeenCalledTimes(1);
|
|
expect(tx.appointment.findMany).toHaveBeenCalledTimes(1);
|
|
expect(tx.groupUser.findMany).not.toHaveBeenCalled();
|
|
expect(tx.projekt.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("hydrates project principals once and checks project aliases plus signatures in one project batch", async () => {
|
|
const tx = transaction();
|
|
tx.userRole.findMany.mockResolvedValue([{ roleId: "role_1" }]);
|
|
tx.groupUser.findMany.mockResolvedValue([{ groupId: "group_1" }]);
|
|
tx.projektSignatureDocument.findMany.mockResolvedValue([
|
|
{ id: "signature_visible", projektId: "signature_project_visible" },
|
|
{ id: "signature_hidden", projektId: "signature_project_hidden" },
|
|
]);
|
|
tx.projekt.findMany.mockResolvedValue([{ id: "project_visible" }, { id: "deal_visible" }, { id: "signature_project_visible" }]);
|
|
const links = [
|
|
{ entityType: "signature_document", entityId: "signature_visible", linkRole: null },
|
|
{ entityType: "order", entityId: "order_hidden", linkRole: null },
|
|
{ entityType: "deal", entityId: "deal_visible", linkRole: null },
|
|
{ entityType: "signature_document", entityId: "signature_hidden", linkRole: null },
|
|
{ entityType: "project", entityId: "project_visible", linkRole: null },
|
|
];
|
|
const ctx: FileVisibilityContext = {
|
|
userId: "user_1",
|
|
permissions: ["projects.read_own_member", "signature.read"],
|
|
};
|
|
|
|
await expect(filterVisibleFileLinksWithTx(tx as never, "tenant_1", links, ctx)).resolves.toEqual([links[0], links[2], links[4]]);
|
|
expect(tx.userRole.findMany).toHaveBeenCalledTimes(1);
|
|
expect(tx.groupUser.findMany).toHaveBeenCalledTimes(1);
|
|
expect(tx.projektMitglied.findMany).not.toHaveBeenCalled();
|
|
expect(tx.projektSignatureDocument.findMany).toHaveBeenCalledTimes(1);
|
|
expect(tx.projekt.findMany).toHaveBeenCalledTimes(1);
|
|
expect(tx.projekt.findMany.mock.calls[0]?.[0]).not.toHaveProperty("take");
|
|
expect(tx.projekt.findMany.mock.calls[0]?.[0].where.id.in).toEqual([
|
|
"project_visible",
|
|
"deal_visible",
|
|
"order_hidden",
|
|
"signature_project_visible",
|
|
"signature_project_hidden",
|
|
]);
|
|
});
|
|
|
|
it("reuses project principals between file scope and detail-link filtering", async () => {
|
|
const tx = transaction();
|
|
tx.fileEntityLink.findMany.mockResolvedValue([{ entityId: "project_visible" }]);
|
|
tx.userRole.findMany.mockResolvedValue([{ roleId: "role_1" }]);
|
|
tx.groupUser.findMany.mockResolvedValue([{ groupId: "group_1" }]);
|
|
tx.projekt.findMany.mockResolvedValue([{ id: "project_visible" }]);
|
|
const ctx: FileVisibilityContext = {
|
|
userId: "user_1",
|
|
permissions: ["files.read_own", "projects.read_own_member"],
|
|
};
|
|
const queryState = createFileVisibilityQueryState();
|
|
|
|
await fileReadScopeWhereWithTx(tx as never, "tenant_1", ctx, ["file_1"], queryState);
|
|
await filterVisibleFileLinksWithTx(tx as never, "tenant_1", [{ entityType: "project", entityId: "project_visible", linkRole: null }], ctx, queryState);
|
|
|
|
expect(tx.userRole.findMany).toHaveBeenCalledTimes(1);
|
|
expect(tx.groupUser.findMany).toHaveBeenCalledTimes(1);
|
|
expect(tx.projektMitglied.findMany).not.toHaveBeenCalled();
|
|
expect(tx.projekt.findMany).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("does not truncate a large set of links", async () => {
|
|
const tx = transaction();
|
|
const links = Array.from({ length: 600 }, (_, index) => ({
|
|
entityType: "contact",
|
|
entityId: `contact_${index}`,
|
|
linkRole: index % 2 === 0 ? "primary" : null,
|
|
}));
|
|
tx.kontakt.findMany.mockResolvedValue(links.map(({ entityId }) => ({ id: entityId })));
|
|
const ctx: FileVisibilityContext = { userId: "user_1", permissions: ["crm.read_all"] };
|
|
|
|
const result = await filterVisibleFileLinksWithTx(tx as never, "tenant_1", links, ctx);
|
|
|
|
expect(result).toEqual(links);
|
|
expect(tx.kontakt.findMany).toHaveBeenCalledTimes(1);
|
|
expect(tx.kontakt.findMany.mock.calls[0]?.[0]).not.toHaveProperty("take");
|
|
expect(tx.kontakt.findMany.mock.calls[0]?.[0].where.id.in).toHaveLength(600);
|
|
});
|
|
|
|
it("does not keep a reassigned contact visible to its creator", async () => {
|
|
const tx = transaction();
|
|
tx.kontakt.findMany.mockResolvedValue([]);
|
|
const link = { entityType: "contact", entityId: "contact_reassigned", linkRole: null };
|
|
const ctx: FileVisibilityContext = { userId: "former_creator", permissions: ["crm.read_own"] };
|
|
|
|
await expect(filterVisibleFileLinksWithTx(tx as never, "tenant_1", [link], ctx)).resolves.toEqual([]);
|
|
expect(tx.kontakt.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
id: { in: ["contact_reassigned"] },
|
|
tenantId: "tenant_1",
|
|
archivedAt: null,
|
|
kontaktTyp: "privatperson",
|
|
zustaendigerUserId: "former_creator",
|
|
},
|
|
select: { id: true },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("assertFileLinkTargetVisibleWithTx", () => {
|
|
const projectContext: FileVisibilityContext = {
|
|
userId: "user_1",
|
|
permissions: ["projects.read_own_member"],
|
|
roleIds: ["role_1"],
|
|
groupIds: ["group_1"],
|
|
};
|
|
|
|
it.each(["project", "deal", "order"] as const)("uses the central project and PipelineAccess scope for %s targets", async (entityType) => {
|
|
const tx = transaction();
|
|
tx.projekt.findFirst.mockResolvedValue({ id: "project_1" });
|
|
|
|
await expect(assertFileLinkTargetVisibleWithTx(tx as never, "tenant_1", entityType, "project_1", projectContext)).resolves.toBeUndefined();
|
|
expect(tx.projekt.findFirst).toHaveBeenCalledWith({
|
|
where: {
|
|
id: "project_1",
|
|
tenantId: "tenant_1",
|
|
archivedAt: null,
|
|
AND: [
|
|
{
|
|
OR: [
|
|
{
|
|
mitglieder: {
|
|
some: {
|
|
tenantId: "tenant_1",
|
|
archivedAt: null,
|
|
OR: [{ userId: "user_1" }, { groupId: { in: ["group_1"] } }],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
pipeline: {
|
|
accessRules: {
|
|
none: {
|
|
tenantId: "tenant_1",
|
|
kannSehen: false,
|
|
OR: [
|
|
{ principalType: "user", principalId: "user_1" },
|
|
{ principalType: "role", principalId: "role_1" },
|
|
{ principalType: "group", principalId: "group_1" },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
select: { id: true },
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["contact", "privatperson"],
|
|
["company", "organisation"],
|
|
] as const)("requires current CRM assignment and the correct discriminator for %s targets", async (entityType, kontaktTyp) => {
|
|
const tx = transaction();
|
|
tx.kontakt.findFirst.mockResolvedValue({ id: "contact_1" });
|
|
const ctx: FileVisibilityContext = { userId: "user_1", permissions: ["crm.read_own"] };
|
|
|
|
await expect(assertFileLinkTargetVisibleWithTx(tx as never, "tenant_1", entityType, "contact_1", ctx)).resolves.toBeUndefined();
|
|
expect(tx.kontakt.findFirst).toHaveBeenCalledWith({
|
|
where: {
|
|
id: "contact_1",
|
|
tenantId: "tenant_1",
|
|
archivedAt: null,
|
|
kontaktTyp,
|
|
zustaendigerUserId: "user_1",
|
|
},
|
|
select: { id: true },
|
|
});
|
|
});
|
|
|
|
it("enforces lead owner visibility", async () => {
|
|
const tx = transaction();
|
|
tx.inboxLead.findFirst.mockResolvedValue({ id: "lead_1" });
|
|
const ctx: FileVisibilityContext = { userId: "user_1", permissions: ["leads.read_own"] };
|
|
|
|
await expect(assertFileLinkTargetVisibleWithTx(tx as never, "tenant_1", "lead", "lead_1", ctx)).resolves.toBeUndefined();
|
|
expect(tx.inboxLead.findFirst).toHaveBeenCalledWith({
|
|
where: { id: "lead_1", tenantId: "tenant_1", archivedAt: null, zustaendigerUserId: "user_1" },
|
|
select: { id: true },
|
|
});
|
|
});
|
|
|
|
it("enforces task owner, watcher, assignee, and tenant-group visibility", async () => {
|
|
const tx = transaction();
|
|
tx.task.findFirst.mockResolvedValue({ id: "task_1" });
|
|
const ctx: FileVisibilityContext = { userId: "user_1", permissions: ["tasks.read_own"], groupIds: ["group_1"] };
|
|
|
|
await expect(assertFileLinkTargetVisibleWithTx(tx as never, "tenant_1", "task", "task_1", ctx)).resolves.toBeUndefined();
|
|
expect(tx.task.findFirst).toHaveBeenCalledWith({
|
|
where: {
|
|
id: "task_1",
|
|
tenantId: "tenant_1",
|
|
archivedAt: null,
|
|
OR: [
|
|
{ createdByUserId: "user_1" },
|
|
{ assignedToUserId: "user_1" },
|
|
{ watchers: { some: { tenantId: "tenant_1", userId: "user_1" } } },
|
|
{ assignedToGroupId: { in: ["group_1"] } },
|
|
],
|
|
},
|
|
select: { id: true },
|
|
});
|
|
});
|
|
|
|
it("allows linking to the caller's own active tenant membership", async () => {
|
|
const tx = transaction();
|
|
tx.tenantUser.findFirst.mockResolvedValue({ id: "membership_1" });
|
|
const ctx: FileVisibilityContext = { userId: "user_1", permissions: [] };
|
|
|
|
await expect(assertFileLinkTargetVisibleWithTx(tx as never, "tenant_1", "user", "user_1", ctx)).resolves.toBeUndefined();
|
|
expect(tx.tenantUser.findFirst).toHaveBeenCalledWith({
|
|
where: { tenantId: "tenant_1", userId: "user_1", status: TenantUserStatus.ACTIVE, user: { active: true } },
|
|
select: { id: true },
|
|
});
|
|
});
|
|
|
|
it("does not let member-directory access substitute for HR access to another user", async () => {
|
|
const tx = transaction();
|
|
const ctx: FileVisibilityContext = { userId: "user_1", permissions: ["app.settings.members.view"] };
|
|
|
|
await expect(assertFileLinkTargetVisibleWithTx(tx as never, "tenant_1", "user", "user_2", ctx)).rejects.toMatchObject({ status: 404 });
|
|
expect(tx.tenantUser.findFirst).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["hr.read_all", "hr.admin"])("allows %s to link an active employee target", async (permission) => {
|
|
const tx = transaction();
|
|
tx.tenantUser.findFirst.mockResolvedValue({ id: "membership_2" });
|
|
const ctx: FileVisibilityContext = { userId: "user_1", permissions: [permission] };
|
|
|
|
await expect(assertFileLinkTargetVisibleWithTx(tx as never, "tenant_1", "user", "user_2", ctx)).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("does not infer access to another employee from hr.write", async () => {
|
|
const tx = transaction();
|
|
const ctx: FileVisibilityContext = { userId: "user_1", permissions: ["hr.write"] };
|
|
|
|
await expect(assertFileLinkTargetVisibleWithTx(tx as never, "tenant_1", "user", "user_2", ctx)).rejects.toMatchObject({ status: 404 });
|
|
expect(tx.tenantUser.findFirst).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("scopes form submissions through a readable owned form", async () => {
|
|
const tx = transaction();
|
|
tx.formSubmission.findFirst.mockResolvedValue({ id: "submission_1" });
|
|
const ctx: FileVisibilityContext = { userId: "user_1", permissions: ["forms.basic"] };
|
|
|
|
await expect(assertFileLinkTargetVisibleWithTx(tx as never, "tenant_1", "form_submission", "submission_1", ctx)).resolves.toBeUndefined();
|
|
expect(tx.formSubmission.findFirst).toHaveBeenCalledWith({
|
|
where: { id: "submission_1", tenantId: "tenant_1", form: { tenantId: "tenant_1", createdByUserId: "user_1" } },
|
|
select: { id: true },
|
|
});
|
|
});
|
|
|
|
it("owner-scopes appointments unless the caller has tenant-wide booking read access", async () => {
|
|
const tx = transaction();
|
|
tx.appointment.findFirst.mockResolvedValue({ id: "appointment_1" });
|
|
const ctx: FileVisibilityContext = { userId: "user_1", permissions: ["bookings.read_own"] };
|
|
|
|
await expect(assertFileLinkTargetVisibleWithTx(tx as never, "tenant_1", "appointment", "appointment_1", ctx)).resolves.toBeUndefined();
|
|
expect(tx.appointment.findFirst).toHaveBeenCalledWith({
|
|
where: { id: "appointment_1", tenantId: "tenant_1", providerUserId: "user_1" },
|
|
select: { id: true },
|
|
});
|
|
});
|
|
|
|
it("requires both signature and project visibility for signature documents", async () => {
|
|
const tx = transaction();
|
|
tx.projektSignatureDocument.findFirst.mockResolvedValue({ id: "document_1", projektId: "project_1" });
|
|
tx.projekt.findFirst.mockResolvedValue({ id: "project_1" });
|
|
const ctx: FileVisibilityContext = {
|
|
userId: "user_1",
|
|
permissions: ["signature.read", "projects.read_all"],
|
|
roleIds: [],
|
|
groupIds: [],
|
|
};
|
|
|
|
await expect(assertFileLinkTargetVisibleWithTx(tx as never, "tenant_1", "signature_document", "document_1", ctx)).resolves.toBeUndefined();
|
|
expect(tx.projektSignatureDocument.findFirst).toHaveBeenCalledWith({
|
|
where: { id: "document_1", tenantId: "tenant_1", archivedAt: null },
|
|
select: { id: true, projektId: true },
|
|
});
|
|
expect(tx.projekt.findFirst.mock.calls[0]?.[0].where).toMatchObject({ id: "project_1", tenantId: "tenant_1", archivedAt: null });
|
|
});
|
|
|
|
it("fails closed before querying when the target permission is missing", async () => {
|
|
const tx = transaction();
|
|
const ctx: FileVisibilityContext = { userId: "user_1", permissions: [] };
|
|
|
|
await expect(assertFileLinkTargetVisibleWithTx(tx as never, "tenant_1", "contact", "contact_1", ctx)).rejects.toMatchObject({ status: 404 });
|
|
expect(tx.kontakt.findFirst).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails closed when the scoped target does not exist", async () => {
|
|
const tx = transaction();
|
|
const ctx: FileVisibilityContext = { userId: "user_1", permissions: ["bookings.read_all"] };
|
|
|
|
await expect(assertFileLinkTargetVisibleWithTx(tx as never, "tenant_1", "appointment", "foreign_appointment", ctx)).rejects.toMatchObject({
|
|
status: 404,
|
|
});
|
|
});
|
|
|
|
it("authorizes a chat-message target only through active channel membership, even for read-all admins", async () => {
|
|
const tx = transaction();
|
|
tx.teamChatMessage.findFirst.mockResolvedValue({ id: "message_1" });
|
|
const ctx: FileVisibilityContext = { userId: "user_1", permissions: ["chat.read_all_channels"] };
|
|
|
|
await expect(assertFileLinkTargetVisibleWithTx(tx as never, "tenant_1", "chat_message", "message_1", ctx)).resolves.toBeUndefined();
|
|
expect(tx.teamChatMessage.findFirst).toHaveBeenCalledWith({
|
|
where: {
|
|
id: "message_1",
|
|
tenantId: "tenant_1",
|
|
chat: { tenantId: "tenant_1", members: { some: { tenantId: "tenant_1", userId: "user_1", leftAt: null } } },
|
|
},
|
|
select: { id: true },
|
|
});
|
|
});
|
|
|
|
it("fails closed for a runtime entity type unsupported by the typed Files service", async () => {
|
|
const tx = transaction();
|
|
const ctx: FileVisibilityContext = { userId: "user_1", permissions: ["files.admin"] };
|
|
|
|
await expect(assertFileLinkTargetVisibleWithTx(tx as never, "tenant_1", "email_message" as FileEntityType, "message_1", ctx)).rejects.toMatchObject({
|
|
status: 404,
|
|
});
|
|
});
|
|
});
|