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

326 lines
12 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { TenantUserStatus } from "~/application/enums/tenants/TenantUserStatus";
const mocks = vi.hoisted(() => ({
tx: {
tenantUser: { findFirst: vi.fn() },
kontakt: { findFirst: vi.fn(), findMany: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
kontaktEmail: { findFirst: vi.fn(), findMany: vi.fn(), updateMany: vi.fn(), deleteMany: vi.fn() },
kontaktTelefon: { findFirst: vi.fn(), findMany: vi.fn(), updateMany: vi.fn(), deleteMany: vi.fn() },
kontaktRelation: { findFirst: vi.fn(), findMany: vi.fn(), create: vi.fn(), deleteMany: vi.fn() },
adresse: { findFirst: vi.fn() },
},
publishKontaktSearchUpsert: 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("~/utils/db.server", () => ({ db: {} }));
vi.mock("~/services/search/searchIndexProjection.server", () => ({
publishKontaktSearchUpsert: mocks.publishKontaktSearchUpsert,
publishSearchProjectionDelete: vi.fn(),
}));
import {
addKontaktRelation,
bulkReassignKontakte,
CRM_BULK_MAX_CONTACTS,
getKontaktDetail,
getVisibleKontaktOrThrow,
listRelationsForKontakt,
listVisibleKontakteByIds,
normalizeBulkKontaktIds,
reassignKontakt,
removeKontaktEmail,
removeKontaktRelation,
removeKontaktTelefon,
setPrimaryEmail,
setPrimaryTelefon,
} from "./crmService.server";
describe("CRM assignee tenant boundary", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.tx.kontakt.findFirst.mockResolvedValue({ id: "contact-a" });
mocks.tx.kontakt.findMany.mockResolvedValue([]);
mocks.tx.kontakt.update.mockResolvedValue({ id: "contact-a" });
mocks.tx.kontakt.updateMany.mockResolvedValue({ count: 1 });
mocks.tx.kontaktEmail.findMany.mockResolvedValue([]);
mocks.tx.kontaktTelefon.findMany.mockResolvedValue([]);
mocks.tx.kontaktRelation.findMany.mockResolvedValue([]);
});
it("deduplicates bulk IDs and rejects payloads above the hard cap", () => {
expect(normalizeBulkKontaktIds([" contact-a ", "contact-a", "", "contact-b"])).toEqual(["contact-a", "contact-b"]);
expect(() => normalizeBulkKontaktIds(Array.from({ length: CRM_BULK_MAX_CONTACTS + 1 }, (_, index) => `contact-${index}`))).toThrow(
"foundation.mass_action_exceeds_limit"
);
});
it("batch-loads visible contacts once and preserves the requested order", async () => {
mocks.tx.kontakt.findMany.mockResolvedValue([
{ id: "contact-b", tenantId: "tenant-a" },
{ id: "contact-a", tenantId: "tenant-a" },
]);
await expect(
listVisibleKontakteByIds({
tenantId: "tenant-a",
ctx: { userId: "actor-a", permissions: ["crm.read_all"] },
kontaktIds: ["contact-a", "contact-b", "contact-a"],
})
).resolves.toEqual([
{ id: "contact-a", tenantId: "tenant-a" },
{ id: "contact-b", tenantId: "tenant-a" },
]);
expect(mocks.tx.kontakt.findMany).toHaveBeenCalledOnce();
expect(mocks.tx.kontakt.findMany).toHaveBeenCalledWith({
where: { id: { in: ["contact-a", "contact-b"] }, tenantId: "tenant-a", archivedAt: null },
});
});
it("rejects a foreign or inactive assignee before changing a contact", async () => {
mocks.tx.tenantUser.findFirst.mockResolvedValue(null);
await expect(reassignKontakt({ tenantId: "tenant-a", kontaktId: "contact-a", zustaendigerUserId: "foreign-user" })).rejects.toMatchObject({ status: 404 });
expect(mocks.tx.tenantUser.findFirst).toHaveBeenCalledWith({
where: {
tenantId: "tenant-a",
userId: "foreign-user",
status: TenantUserStatus.ACTIVE,
user: { active: true },
},
select: { id: true },
});
expect(mocks.tx.kontakt.findFirst).not.toHaveBeenCalled();
expect(mocks.tx.kontakt.update).not.toHaveBeenCalled();
});
it("applies the same active tenant membership check to bulk reassignment", async () => {
mocks.tx.tenantUser.findFirst.mockResolvedValue(null);
await expect(
bulkReassignKontakte({
tenantId: "tenant-a",
ctx: { userId: "actor-a", permissions: ["crm.read_all"] },
kontaktIds: ["contact-a"],
zustaendigerUserId: "foreign-user",
})
).rejects.toMatchObject({ status: 404 });
expect(mocks.tx.kontakt.updateMany).not.toHaveBeenCalled();
});
it("does not let a creator retain crm.read_own visibility after reassignment", async () => {
const reassignedContact = {
id: "contact-b",
tenantId: "tenant-a",
createdByUserId: "actor-a",
zustaendigerUserId: "actor-b",
};
mocks.tx.kontakt.findFirst.mockImplementation(async (args: { where?: { id?: string; tenantId?: string; zustaendigerUserId?: string } }) =>
args.where?.id === reassignedContact.id &&
args.where?.tenantId === reassignedContact.tenantId &&
args.where?.zustaendigerUserId === reassignedContact.zustaendigerUserId
? reassignedContact
: null
);
await expect(
getVisibleKontaktOrThrow("tenant-a", "contact-b", {
userId: "actor-a",
permissions: ["crm.read_own"],
})
).rejects.toMatchObject({ status: 404 });
expect(mocks.tx.kontakt.findFirst).toHaveBeenCalledWith({
where: {
id: "contact-b",
tenantId: "tenant-a",
zustaendigerUserId: "actor-a",
},
});
});
it("filters detail relations when the other contact is outside crm.read_own", async () => {
mocks.tx.kontakt.findFirst.mockResolvedValue({
id: "contact-a",
tenantId: "tenant-a",
adresseId: null,
zustaendigerUserId: "actor-a",
});
mocks.tx.kontaktRelation.findMany.mockResolvedValue([
{ id: "relation-a-b", vonKontaktId: "contact-a", zuKontaktId: "contact-b" },
{ id: "relation-c-a", vonKontaktId: "contact-c", zuKontaktId: "contact-a" },
]);
mocks.tx.kontakt.findMany.mockResolvedValue([{ id: "contact-b" }]);
const detail = await getKontaktDetail("tenant-a", "contact-a", {
userId: "actor-a",
permissions: ["crm.read_own"],
});
expect(detail.relationen).toEqual([{ id: "relation-a-b", vonKontaktId: "contact-a", zuKontaktId: "contact-b" }]);
expect(mocks.tx.kontakt.findMany).toHaveBeenCalledWith({
where: {
id: { in: ["contact-b", "contact-c"] },
tenantId: "tenant-a",
zustaendigerUserId: "actor-a",
},
select: { id: true },
});
});
it("requires context and filters both relation directions in the direct relation API", async () => {
mocks.tx.kontaktRelation.findMany.mockResolvedValue([
{ id: "relation-a-b", vonKontaktId: "contact-a", zuKontaktId: "contact-b" },
{ id: "relation-c-a", vonKontaktId: "contact-c", zuKontaktId: "contact-a" },
]);
mocks.tx.kontakt.findMany.mockResolvedValue([{ id: "contact-c" }]);
await expect(
listRelationsForKontakt("tenant-a", "contact-a", {
userId: "actor-a",
permissions: ["crm.read_own"],
})
).resolves.toEqual([{ id: "relation-c-a", vonKontaktId: "contact-c", zuKontaktId: "contact-a" }]);
expect(mocks.tx.kontakt.findFirst).toHaveBeenCalledWith({
where: { id: "contact-a", tenantId: "tenant-a", zustaendigerUserId: "actor-a" },
});
expect(mocks.tx.kontakt.findMany).toHaveBeenCalledWith({
where: {
id: { in: ["contact-b", "contact-c"] },
tenantId: "tenant-a",
zustaendigerUserId: "actor-a",
},
select: { id: true },
});
});
it("returns 404 before listing relations when the requested contact is outside the caller scope", async () => {
mocks.tx.kontakt.findFirst.mockResolvedValueOnce(null);
await expect(
listRelationsForKontakt("tenant-a", "contact-from-tenant-b", {
userId: "actor-a",
permissions: ["crm.read_own"],
})
).rejects.toMatchObject({ status: 404 });
expect(mocks.tx.kontaktRelation.findMany).not.toHaveBeenCalled();
});
it("rejects email IDs from another contact before changing or deleting them", async () => {
mocks.tx.kontaktEmail.findFirst.mockResolvedValue(null);
const input = {
tenantId: "tenant-a",
kontaktId: "contact-a",
ctx: { userId: "actor-a", permissions: ["crm.read_own"] },
emailId: "email-from-contact-b",
};
await expect(setPrimaryEmail(input)).rejects.toMatchObject({ status: 404 });
await expect(removeKontaktEmail(input)).rejects.toMatchObject({ status: 404 });
const scopedLookup = {
where: {
id: "email-from-contact-b",
tenantId: "tenant-a",
kontaktId: "contact-a",
},
select: { id: true, kontaktId: true },
};
expect(mocks.tx.kontaktEmail.findFirst).toHaveBeenNthCalledWith(1, scopedLookup);
expect(mocks.tx.kontaktEmail.findFirst).toHaveBeenNthCalledWith(2, scopedLookup);
expect(mocks.tx.kontaktEmail.updateMany).not.toHaveBeenCalled();
expect(mocks.tx.kontaktEmail.deleteMany).not.toHaveBeenCalled();
});
it("rejects telephone IDs from another contact before changing or deleting them", async () => {
mocks.tx.kontaktTelefon.findFirst.mockResolvedValue(null);
const input = {
tenantId: "tenant-a",
kontaktId: "contact-a",
ctx: { userId: "actor-a", permissions: ["crm.read_own"] },
telefonId: "telephone-from-contact-b",
};
await expect(setPrimaryTelefon(input)).rejects.toMatchObject({ status: 404 });
await expect(removeKontaktTelefon(input)).rejects.toMatchObject({ status: 404 });
const scopedLookup = {
where: {
id: "telephone-from-contact-b",
tenantId: "tenant-a",
kontaktId: "contact-a",
},
select: { id: true, kontaktId: true },
};
expect(mocks.tx.kontaktTelefon.findFirst).toHaveBeenNthCalledWith(1, scopedLookup);
expect(mocks.tx.kontaktTelefon.findFirst).toHaveBeenNthCalledWith(2, scopedLookup);
expect(mocks.tx.kontaktTelefon.updateMany).not.toHaveBeenCalled();
expect(mocks.tx.kontaktTelefon.deleteMany).not.toHaveBeenCalled();
});
it("rejects adding a relation when the target contact is not visible", async () => {
mocks.tx.kontakt.findFirst.mockImplementation(async (args: { where?: { id?: string; tenantId?: string; zustaendigerUserId?: string } }) =>
args.where?.id === "contact-a" && args.where?.tenantId === "tenant-a" && args.where?.zustaendigerUserId === "actor-a" ? { id: "contact-a" } : null
);
await expect(
addKontaktRelation({
tenantId: "tenant-a",
kontaktId: "contact-a",
ctx: { userId: "actor-a", permissions: ["crm.read_own"] },
zuKontaktId: "contact-b",
rolle: "hauptkontakt",
})
).rejects.toMatchObject({ status: 404 });
expect(mocks.tx.kontakt.findFirst).toHaveBeenNthCalledWith(1, {
where: { id: "contact-a", tenantId: "tenant-a", zustaendigerUserId: "actor-a" },
});
expect(mocks.tx.kontakt.findFirst).toHaveBeenNthCalledWith(2, {
where: { id: "contact-b", tenantId: "tenant-a", zustaendigerUserId: "actor-a" },
});
expect(mocks.tx.kontaktRelation.create).not.toHaveBeenCalled();
});
it("rejects removing a URL-contact relation when its other endpoint is not visible", async () => {
mocks.tx.kontakt.findFirst.mockImplementation(async (args: { where?: { id?: string; tenantId?: string; zustaendigerUserId?: string } }) =>
args.where?.id === "contact-a" && args.where?.tenantId === "tenant-a" && args.where?.zustaendigerUserId === "actor-a" ? { id: "contact-a" } : null
);
mocks.tx.kontaktRelation.findFirst.mockResolvedValue({
id: "relation-a-b",
vonKontaktId: "contact-a",
zuKontaktId: "contact-b",
});
await expect(
removeKontaktRelation({
tenantId: "tenant-a",
kontaktId: "contact-a",
ctx: { userId: "actor-a", permissions: ["crm.read_own"] },
relationId: "relation-a-b",
})
).rejects.toMatchObject({ status: 404 });
expect(mocks.tx.kontaktRelation.findFirst).toHaveBeenCalledWith({
where: {
id: "relation-a-b",
tenantId: "tenant-a",
OR: [{ vonKontaktId: "contact-a" }, { zuKontaktId: "contact-a" }],
},
select: { id: true, vonKontaktId: true, zuKontaktId: true },
});
expect(mocks.tx.kontakt.findFirst).toHaveBeenLastCalledWith({
where: { id: "contact-b", tenantId: "tenant-a", zustaendigerUserId: "actor-a" },
});
expect(mocks.tx.kontaktRelation.deleteMany).not.toHaveBeenCalled();
});
});