316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
withTenantRLS: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/utils/db/.server/tenantRls.server", () => ({
|
|
withTenantRLS: mocks.withTenantRLS,
|
|
}));
|
|
|
|
import {
|
|
exportProjectListCsv,
|
|
filterAggregateReadablePipelineIdsWithTx,
|
|
normalizeProjectListPage,
|
|
ownerScopeWhere,
|
|
projectEditScopeWhere,
|
|
projectReadScopeWhere,
|
|
PROJECT_CSV_EXPORT_MAX_ROWS,
|
|
} from "./projectService.server";
|
|
|
|
type ProjectFixture = {
|
|
id: string;
|
|
tenantId: string;
|
|
pipelineId: string;
|
|
stageId: string;
|
|
projektNummer: string;
|
|
projektname: string;
|
|
potenziellerAuftragswert: string | null;
|
|
auftragsvolumen: string | null;
|
|
zustaendigePersonId: string | null;
|
|
projektleiterId: string | null;
|
|
updatedAt: Date;
|
|
pipeline: { id: string; name: string };
|
|
stage: { id: string; name: string };
|
|
};
|
|
|
|
function projectFixture(index: number, overrides: Partial<ProjectFixture> = {}): ProjectFixture {
|
|
const ordinal = String(index + 1).padStart(4, "0");
|
|
return {
|
|
id: `project-${ordinal}`,
|
|
tenantId: "tenant-1",
|
|
pipelineId: "pipeline-1",
|
|
stageId: "stage-1",
|
|
projektNummer: `HG-${ordinal}`,
|
|
projektname: `Projekt ${ordinal}`,
|
|
potenziellerAuftragswert: null,
|
|
auftragsvolumen: `${index + 1}.00`,
|
|
zustaendigePersonId: "owner-1",
|
|
projektleiterId: "manager-1",
|
|
updatedAt: new Date("2026-07-13T12:00:00.000Z"),
|
|
pipeline: { id: "pipeline-1", name: "Vertrieb" },
|
|
stage: { id: "stage-1", name: "Neu" },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function installTenantTransaction(projects: readonly ProjectFixture[]) {
|
|
const findMany = vi.fn().mockResolvedValue(projects);
|
|
const pipelineFieldConfigFindMany = vi.fn().mockResolvedValue([]);
|
|
const tx = {
|
|
projekt: { findMany },
|
|
pipelineFieldConfig: { findMany: pipelineFieldConfigFindMany },
|
|
};
|
|
mocks.withTenantRLS.mockImplementation(async (_tenantId: string, callback: (client: typeof tx) => Promise<unknown>) => callback(tx));
|
|
return { findMany, pipelineFieldConfigFindMany };
|
|
}
|
|
|
|
const context = {
|
|
userId: "user-1",
|
|
permissions: ["projects.read_all", "deals.read_volume_all"],
|
|
roleIds: ["role-1"],
|
|
groupIds: ["group-1"],
|
|
};
|
|
|
|
describe("project CSV export", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
test("exports all 537 matching projects without applying the table pagination clamp", async () => {
|
|
const projects = Array.from({ length: 537 }, (_, index) => projectFixture(index));
|
|
const { findMany, pipelineFieldConfigFindMany } = installTenantTransaction(projects);
|
|
|
|
const csv = await exportProjectListCsv({ tenantId: "tenant-1", ctx: context });
|
|
|
|
expect(mocks.withTenantRLS).toHaveBeenCalledWith("tenant-1", expect.any(Function));
|
|
expect(findMany).toHaveBeenCalledOnce();
|
|
expect(findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
tenantId: "tenant-1",
|
|
archivedAt: null,
|
|
isDemoData: false,
|
|
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: expect.objectContaining({
|
|
id: true,
|
|
projektNummer: true,
|
|
pipeline: { select: { name: true } },
|
|
stage: { select: { name: true } },
|
|
}),
|
|
orderBy: { updatedAt: "desc" },
|
|
take: PROJECT_CSV_EXPORT_MAX_ROWS + 1,
|
|
});
|
|
expect(pipelineFieldConfigFindMany).toHaveBeenCalledOnce();
|
|
|
|
const lines = csv.trimEnd().split("\n");
|
|
expect(lines).toHaveLength(538);
|
|
expect(lines[1]).toContain('"HG-0001"');
|
|
expect(lines.at(-1)).toContain('"HG-0537"');
|
|
});
|
|
|
|
test("neutralizes spreadsheet formulas in every user-controlled CSV cell", async () => {
|
|
installTenantTransaction([
|
|
projectFixture(0, {
|
|
projektNummer: "+SUM(1,1)",
|
|
projektname: '=HYPERLINK("https://evil.example","click")',
|
|
auftragsvolumen: "-5",
|
|
zustaendigePersonId: "@OWNER",
|
|
pipeline: { id: "pipeline-1", name: "\t=CMD()" },
|
|
stage: { id: "stage-1", name: " -1+2" },
|
|
}),
|
|
]);
|
|
|
|
const csv = await exportProjectListCsv({ tenantId: "tenant-1", ctx: context });
|
|
const row = csv.trimEnd().split("\n")[1];
|
|
|
|
expect(row).toContain('"\'+SUM(1,1)"');
|
|
expect(row).toContain('"\'=HYPERLINK(""https://evil.example"",""click"")"');
|
|
expect(row).toContain('"\'\t=CMD()"');
|
|
expect(row).toContain('"\' -1+2"');
|
|
expect(row).toContain('"\'-5.00"');
|
|
expect(row).toContain('"\'@OWNER"');
|
|
expect(row).toContain('"2026-07-13T12:00:00.000Z"');
|
|
});
|
|
|
|
test("hides both monetary columns when the potential-value field is denied", async () => {
|
|
const { pipelineFieldConfigFindMany } = installTenantTransaction([
|
|
projectFixture(0, { potenziellerAuftragswert: "95000", auftragsvolumen: null }),
|
|
]);
|
|
pipelineFieldConfigFindMany.mockResolvedValue([
|
|
{
|
|
pipelineId: "pipeline-1",
|
|
stageId: "stage-1",
|
|
fieldKey: "potenziellerAuftragswert",
|
|
sichtbar: false,
|
|
visibilities: [],
|
|
},
|
|
]);
|
|
|
|
const csv = await exportProjectListCsv({ tenantId: "tenant-1", ctx: context });
|
|
|
|
expect(csv.trimEnd().split("\n")[1]).toContain('"—"');
|
|
expect(csv).not.toContain("95000.00");
|
|
});
|
|
|
|
test("keeps volume fields hidden when duplicate legacy configs contain an explicit deny", async () => {
|
|
const { pipelineFieldConfigFindMany } = installTenantTransaction([
|
|
projectFixture(0, { potenziellerAuftragswert: "95000", auftragsvolumen: null }),
|
|
]);
|
|
pipelineFieldConfigFindMany.mockResolvedValue([
|
|
{
|
|
pipelineId: "pipeline-1",
|
|
stageId: "stage-1",
|
|
fieldKey: "potenziellerAuftragswert",
|
|
sichtbar: true,
|
|
visibilities: [{ kannSehen: true, kannBearbeiten: true }],
|
|
},
|
|
{
|
|
pipelineId: "pipeline-1",
|
|
stageId: "stage-1",
|
|
fieldKey: "potenziellerAuftragswert",
|
|
sichtbar: true,
|
|
visibilities: [{ kannSehen: false, kannBearbeiten: false }],
|
|
},
|
|
]);
|
|
|
|
const csv = await exportProjectListCsv({ tenantId: "tenant-1", ctx: context });
|
|
|
|
expect(csv.trimEnd().split("\n")[1]).toContain('"—"');
|
|
expect(csv).not.toContain("95000.00");
|
|
});
|
|
|
|
test("rejects an unbounded export before building field-visibility rows", async () => {
|
|
const projects = Array.from({ length: PROJECT_CSV_EXPORT_MAX_ROWS + 1 }, (_, index) => projectFixture(index));
|
|
const { pipelineFieldConfigFindMany } = installTenantTransaction(projects);
|
|
|
|
await expect(exportProjectListCsv({ tenantId: "tenant-1", ctx: context })).rejects.toMatchObject({
|
|
messageKey: "projects.error.exportTooLarge",
|
|
});
|
|
expect(pipelineFieldConfigFindMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("keeps owner and pipeline scopes when a search query adds another AND clause", async () => {
|
|
const { findMany } = installTenantTransaction([]);
|
|
await exportProjectListCsv({
|
|
tenantId: "tenant-1",
|
|
ctx: { userId: "user-1", permissions: ["projects.read_own"], roleIds: [], groupIds: [] },
|
|
filters: { q: "Dach" },
|
|
});
|
|
|
|
const where = findMany.mock.calls[0]![0].where;
|
|
expect(where.AND).toEqual(
|
|
expect.arrayContaining([
|
|
{ OR: [{ zustaendigePersonId: "user-1" }] },
|
|
expect.objectContaining({ pipeline: expect.any(Object) }),
|
|
{ OR: [{ projektname: { contains: "Dach", mode: "insensitive" } }, { projektNummer: { contains: "Dach", mode: "insensitive" } }] },
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("project list visibility helpers", () => {
|
|
test("keeps owner, project-lead, and membership scopes additive without broadening", () => {
|
|
expect(ownerScopeWhere("tenant-1", { userId: "user-1", permissions: ["projects.read_own"] })).toEqual({
|
|
OR: [{ zustaendigePersonId: "user-1" }],
|
|
});
|
|
expect(ownerScopeWhere("tenant-1", { userId: "user-1", permissions: ["projects.read_own_pl"] })).toEqual({
|
|
OR: [{ projektleiterId: "user-1" }],
|
|
});
|
|
expect(
|
|
ownerScopeWhere("tenant-1", {
|
|
userId: "user-1",
|
|
permissions: ["projects.read_own_member"],
|
|
groupIds: ["group-1"],
|
|
}),
|
|
).toEqual({
|
|
OR: [
|
|
{
|
|
mitglieder: {
|
|
some: {
|
|
tenantId: "tenant-1",
|
|
archivedAt: null,
|
|
OR: [{ userId: "user-1" }, { groupId: { in: ["group-1"] } }],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
});
|
|
expect(ownerScopeWhere("tenant-1", { userId: "user-1", permissions: [] })).toEqual({ id: { in: [] } });
|
|
expect(ownerScopeWhere("tenant-1", { userId: "user-1", permissions: ["projects.read_all"] })).toEqual({});
|
|
});
|
|
|
|
test("resolves aggregate field visibility in one query with explicit deny precedence", async () => {
|
|
const findMany = vi.fn().mockResolvedValue([
|
|
{
|
|
pipelineId: "pipeline-1",
|
|
sichtbar: true,
|
|
visibilities: [{ kannSehen: true }],
|
|
},
|
|
{
|
|
pipelineId: "pipeline-1",
|
|
sichtbar: true,
|
|
visibilities: [{ kannSehen: false }],
|
|
},
|
|
{
|
|
pipelineId: "pipeline-2",
|
|
sichtbar: true,
|
|
visibilities: [],
|
|
},
|
|
]);
|
|
|
|
await expect(
|
|
filterAggregateReadablePipelineIdsWithTx(
|
|
{ pipelineFieldConfig: { findMany } } as never,
|
|
{
|
|
tenantId: "tenant-1",
|
|
pipelineIds: ["pipeline-1", "pipeline-2", "pipeline-2"],
|
|
ctx: context,
|
|
backstopPermissions: ["deals.read_volume_all"],
|
|
},
|
|
),
|
|
).resolves.toEqual(["pipeline-2"]);
|
|
expect(findMany).toHaveBeenCalledOnce();
|
|
expect(findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: expect.objectContaining({
|
|
tenantId: "tenant-1",
|
|
pipelineId: { in: ["pipeline-1", "pipeline-2"] },
|
|
fieldKey: "aggregate:forecast",
|
|
stageId: null,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("normalizes invalid and abusive page values", () => {
|
|
expect(normalizeProjectListPage("foo")).toBe(1);
|
|
expect(normalizeProjectListPage(Number.POSITIVE_INFINITY)).toBe(1);
|
|
expect(normalizeProjectListPage(-5)).toBe(1);
|
|
expect(normalizeProjectListPage("2")).toBe(2);
|
|
expect(normalizeProjectListPage(999_999)).toBe(10_000);
|
|
});
|
|
|
|
test("keeps project access scopes inside AND so they cannot replace a concrete project id", () => {
|
|
const ctx = { userId: "user-1", permissions: [] as string[] };
|
|
expect(projectReadScopeWhere("tenant-1", ctx)).toMatchObject({
|
|
AND: [{ id: { in: [] } }, { pipeline: expect.any(Object) }],
|
|
});
|
|
expect(projectEditScopeWhere("tenant-1", ctx)).toMatchObject({
|
|
AND: [{ id: { in: [] } }, { pipeline: expect.any(Object) }],
|
|
});
|
|
});
|
|
});
|