242 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|