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

242 lines
10 KiB
TypeScript

// WF-P8 Subframe delta — pipelineAccessRequest.server unit test. The Prisma
// client is mocked, but NOT as an answer-everything stub: the withTenantRLS
// mock binds a tenant context and every tx method re-checks the queried
// tenantId against the bound context (mirroring the hg_*_all RLS policy), so
// a query for a foreign tenant yields no rows instead of leaking fixtures.
import { beforeEach, describe, expect, it, vi } from "vitest";
const TENANT = "tenant-a";
const mocks = vi.hoisted(() => {
const state = {
boundTenantId: null as string | null,
pipelines: [] as Array<Record<string, unknown>>,
requests: [] as Array<Record<string, unknown>>,
admins: [] as Array<{ userId: string }>,
projectCounts: [] as Array<{ pipelineId: string; _count: { _all: number } }>,
};
// RLS-shaped guard: rows only exist when the bound context matches the
// tenant in the query (no context / wrong tenant -> zero rows, like Postgres).
const visible = (whereTenantId: unknown) => state.boundTenantId !== null && whereTenantId === state.boundTenantId;
const ownRows = <T extends { tenantId?: unknown }>(rows: T[]) => rows.filter((row) => row.tenantId === state.boundTenantId);
const tx = {
pipeline: {
findMany: vi.fn(async (args: { where: { tenantId: string } }) => (visible(args.where.tenantId) ? ownRows(state.pipelines) : [])),
findFirst: vi.fn(async (args: { where: { tenantId: string; id: string } }) => {
if (!visible(args.where.tenantId)) return null;
return ownRows(state.pipelines).find((pipeline) => pipeline.id === args.where.id) ?? null;
}),
},
projekt: {
groupBy: vi.fn(async (args: { where: { tenantId: string } }) => (visible(args.where.tenantId) ? state.projectCounts : [])),
},
pipelineAccessRequest: {
findMany: vi.fn(async (args: { where: { tenantId: string; requestedByUserId: string; status: string } }) => {
if (!visible(args.where.tenantId)) return [];
return state.requests.filter(
(request) => request.requestedByUserId === args.where.requestedByUserId && request.status === args.where.status
);
}),
findFirst: vi.fn(
async (args: { where: { tenantId: string; pipelineId: string; requestedByUserId: string; status: string } }) => {
if (!visible(args.where.tenantId)) return null;
return (
state.requests.find(
(request) =>
request.pipelineId === args.where.pipelineId &&
request.requestedByUserId === args.where.requestedByUserId &&
request.status === args.where.status
) ?? null
);
}
),
create: vi.fn(async (args: { data: Record<string, unknown> }) => {
if (!visible(args.data.tenantId)) throw new Error("rls: insert with unbound/foreign tenant context denied");
const row = { id: "req-new", status: "open", createdAt: new Date(), resolvedAt: null, resolvedByUserId: null, ...args.data };
state.requests.push(row);
return row;
}),
updateMany: vi.fn(async (args: { where: { tenantId: string } }) => {
if (!visible(args.where.tenantId)) return { count: 0 };
return { count: 1 };
}),
},
userRole: {
findMany: vi.fn(async (args: { where: { tenantId: string } }) => (visible(args.where.tenantId) ? state.admins : [])),
},
};
return {
state,
tx,
notify: vi.fn(async () => undefined),
createAuditEvent: vi.fn(async () => ({ id: "audit-1" })),
withTenantRLS: vi.fn(async (tenantId: string, fn: (tx: unknown) => Promise<unknown>) => {
state.boundTenantId = tenantId;
try {
return await fn(tx);
} finally {
state.boundTenantId = null;
}
}),
};
});
vi.mock("~/utils/db/.server/tenantRls.server", () => ({ withTenantRLS: mocks.withTenantRLS }));
vi.mock("@hivegrid/db/audit-events.server", () => ({ createAuditEvent: mocks.createAuditEvent }));
vi.mock("@hivegrid/foundation", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
notify: mocks.notify,
}));
import { CatalogError } from "@hivegrid/foundation";
import {
listLockedPipelinesForViewer,
requestPipelineAccess,
resolveOpenAccessRequests,
} from "./pipelineAccessRequest.server";
import type { ProjectRouteContext } from "./projectAccess.server";
function ctx(overrides: Partial<ProjectRouteContext> = {}): ProjectRouteContext {
return {
tenantId: TENANT,
userId: "user-a",
permissions: ["projects.read_own"],
roleIds: ["role-1"],
groupIds: ["group-1"],
hasModule: true,
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
mocks.state.boundTenantId = null;
mocks.state.pipelines = [
{ id: "pl-locked", tenantId: TENANT, name: "Geschaeftsleitung", typHint: "sales", isDefault: false, stages: [{ id: "s1" }, { id: "s2" }] },
];
mocks.state.requests = [];
mocks.state.admins = [{ userId: "admin-1" }, { userId: "admin-1" }, { userId: "admin-2" }];
mocks.state.projectCounts = [{ pipelineId: "pl-locked", _count: { _all: 7 } }];
});
describe("listLockedPipelinesForViewer", () => {
it("lists locked pipelines with counts and the deny-rule scope", async () => {
const rows = await listLockedPipelinesForViewer({ tenantId: TENANT, ctx: ctx() });
expect(rows).toEqual([
{
id: "pl-locked",
name: "Geschaeftsleitung",
typHint: "sales",
isDefault: false,
stageCount: 2,
projectCount: 7,
requestPending: false,
},
]);
// The query must match pipelines with an explicit deny rule for one of the
// viewer's effective principals (inverse of the visibility scope).
const where = mocks.tx.pipeline.findMany.mock.calls[0][0].where as Record<string, unknown>;
expect(where.archivedAt).toBeNull();
expect(where.accessRules).toEqual({
some: {
tenantId: TENANT,
kannSehen: false,
OR: [
{ principalType: "user", principalId: "user-a" },
{ principalType: "role", principalId: "role-1" },
{ principalType: "group", principalId: "group-1" },
],
},
});
});
it("flags pipelines with an open request by the viewer as pending", async () => {
mocks.state.requests = [{ id: "req-1", pipelineId: "pl-locked", requestedByUserId: "user-a", status: "open" }];
const rows = await listLockedPipelinesForViewer({ tenantId: TENANT, ctx: ctx() });
expect(rows[0]?.requestPending).toBe(true);
});
it("yields no rows for a foreign tenant (RLS context mismatch)", async () => {
// The fixture pipelines belong to tenant-a; binding tenant-b's context
// must answer with zero rows, like the RLS policy in Postgres.
const rows = await listLockedPipelinesForViewer({ tenantId: "tenant-b", ctx: ctx({ tenantId: "tenant-b" }) });
expect(rows).toEqual([]);
expect(mocks.withTenantRLS).toHaveBeenCalledWith("tenant-b", expect.any(Function));
});
});
describe("requestPipelineAccess", () => {
it("rejects a pipeline that is not locked for the viewer with catalog notFound", async () => {
mocks.tx.pipeline.findFirst.mockResolvedValueOnce(null);
await expect(
requestPipelineAccess({ tenantId: TENANT, pipelineId: "pl-open", userId: "user-a", ctx: ctx() })
).rejects.toBeInstanceOf(CatalogError);
expect(mocks.tx.pipelineAccessRequest.create).not.toHaveBeenCalled();
expect(mocks.notify).not.toHaveBeenCalled();
});
it("creates a request, notifies deduped projects.admin users and audits", async () => {
const result = await requestPipelineAccess({ tenantId: TENANT, pipelineId: "pl-locked", userId: "user-a", ctx: ctx() });
expect(result.created).toBe(true);
expect(result.request.pipelineId).toBe("pl-locked");
// Fan-out deduped: admin-1 appears twice in the role rows but is notified once.
expect(mocks.notify).toHaveBeenCalledTimes(2);
expect(mocks.notify.mock.calls.map((call) => (call[0] as { userId: string }).userId).sort()).toEqual(["admin-1", "admin-2"]);
expect(mocks.notify.mock.calls[0][0]).toMatchObject({
tenantId: TENANT,
channel: "IN_APP",
category: "projects",
messageKey: "projects.notification.pipelineAccessRequested",
sourceKey: "pipeline-access-request:req-new",
payloadMetadata: { pipelineId: "pl-locked", pipelineName: "Geschaeftsleitung", requesterUserId: "user-a" },
});
expect(mocks.createAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
tenantId: TENANT,
actorId: "user-a",
action: "pipeline.access_requested",
targetType: "pipeline",
targetId: "pl-locked",
})
);
});
it("is idempotent for an existing open request of the same user", async () => {
mocks.state.requests = [{ id: "req-1", pipelineId: "pl-locked", requestedByUserId: "user-a", status: "open" }];
const result = await requestPipelineAccess({ tenantId: TENANT, pipelineId: "pl-locked", userId: "user-a", ctx: ctx() });
expect(result.created).toBe(false);
expect(result.request.id).toBe("req-1");
expect(mocks.tx.pipelineAccessRequest.create).not.toHaveBeenCalled();
expect(mocks.notify).not.toHaveBeenCalled();
expect(mocks.createAuditEvent).not.toHaveBeenCalled();
});
it("stays best-effort when notify or audit fail", async () => {
mocks.notify.mockRejectedValueOnce(new Error("smtp down"));
mocks.createAuditEvent.mockRejectedValueOnce(new Error("audit down"));
const result = await requestPipelineAccess({ tenantId: TENANT, pipelineId: "pl-locked", userId: "user-a", ctx: ctx() });
expect(result.created).toBe(true);
});
});
describe("resolveOpenAccessRequests", () => {
it("marks the user's open requests for the pipeline as approved", async () => {
const count = await resolveOpenAccessRequests({
tenantId: TENANT,
pipelineId: "pl-locked",
userId: "user-a",
resolvedByUserId: "admin-1",
status: "approved",
});
expect(count).toBe(1);
const args = mocks.tx.pipelineAccessRequest.updateMany.mock.calls[0][0] as {
where: Record<string, unknown>;
data: Record<string, unknown>;
};
expect(args.where).toEqual({ tenantId: TENANT, pipelineId: "pl-locked", requestedByUserId: "user-a", status: "open" });
expect(args.data).toMatchObject({ status: "approved", resolvedByUserId: "admin-1" });
expect(args.data.resolvedAt).toBeInstanceOf(Date);
});
});