234 lines
7.1 KiB
TypeScript
234 lines
7.1 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
tx: {
|
|
hrEmployeeProfile: {
|
|
findFirst: vi.fn(),
|
|
findMany: vi.fn(),
|
|
},
|
|
hrEmployeeDocument: {
|
|
create: vi.fn(),
|
|
},
|
|
file: {
|
|
findFirst: vi.fn(),
|
|
},
|
|
fileEntityLink: {
|
|
create: vi.fn(),
|
|
},
|
|
activity: {
|
|
create: vi.fn(),
|
|
},
|
|
tenantUser: {
|
|
findFirst: vi.fn(),
|
|
findMany: vi.fn(),
|
|
},
|
|
userRole: {
|
|
findMany: vi.fn(),
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock("~/utils/db/.server/tenantRls.server", () => ({
|
|
withTenantRLS: (_tenantId: string, callback: (tx: typeof mocks.tx) => unknown) => callback(mocks.tx),
|
|
}));
|
|
vi.mock("~/utils/db.server", () => ({
|
|
db: { user: { findMany: vi.fn().mockResolvedValue([]) } },
|
|
}));
|
|
vi.mock("@hivegrid/db/audit-events.server", () => ({ createAuditEvent: vi.fn() }));
|
|
vi.mock("./hrFieldCrypto.server", () => ({ getHrFieldCrypto: vi.fn() }));
|
|
|
|
import {
|
|
createDocumentWithLink,
|
|
listHrMemberOptions,
|
|
listDirectoryForViewer,
|
|
requireActiveHrMember,
|
|
requireEmployeeObjectAccess,
|
|
} from "./hrService.server";
|
|
|
|
describe("HR employee object access", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.tx.hrEmployeeProfile.findMany.mockResolvedValue([]);
|
|
mocks.tx.hrEmployeeProfile.findFirst.mockResolvedValue(null);
|
|
mocks.tx.hrEmployeeDocument.create.mockResolvedValue({
|
|
id: "document-1",
|
|
tenantId: "tenant-1",
|
|
employeeUserId: "employee-1",
|
|
uploadedByUserId: "employee-1",
|
|
title: "Bescheinigung",
|
|
documentKind: "certificate",
|
|
fileId: "file-1",
|
|
visibility: "employee",
|
|
uploadedByOffice: false,
|
|
validUntil: null,
|
|
isDemoData: false,
|
|
createdAt: new Date("2026-07-25T12:00:00.000Z"),
|
|
updatedAt: new Date("2026-07-25T12:00:00.000Z"),
|
|
archivedAt: null,
|
|
});
|
|
mocks.tx.file.findFirst.mockResolvedValue({ id: "file-1" });
|
|
mocks.tx.fileEntityLink.create.mockResolvedValue({ id: "link-1" });
|
|
mocks.tx.activity.create.mockResolvedValue({ id: "activity-1" });
|
|
mocks.tx.tenantUser.findFirst.mockResolvedValue({ id: "membership-1" });
|
|
mocks.tx.tenantUser.findMany.mockResolvedValue([]);
|
|
mocks.tx.userRole.findMany.mockResolvedValue([]);
|
|
});
|
|
|
|
it("limits the regular directory query to self and direct reports", async () => {
|
|
await listDirectoryForViewer({
|
|
tenantId: "tenant-1",
|
|
viewerUserId: "manager-1",
|
|
canReadAll: false,
|
|
});
|
|
|
|
expect(mocks.tx.hrEmployeeProfile.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
tenantId: "tenant-1",
|
|
OR: [{ userId: "manager-1" }, { managerUserId: "manager-1" }],
|
|
},
|
|
select: {
|
|
userId: true,
|
|
position: true,
|
|
team: true,
|
|
locationId: true,
|
|
managerUserId: true,
|
|
},
|
|
orderBy: [{ team: "asc" }, { userId: "asc" }],
|
|
});
|
|
});
|
|
|
|
it("does not infer tenant-wide HR visibility from a generic tenant role", async () => {
|
|
mocks.tx.hrEmployeeProfile.findFirst.mockResolvedValue(null);
|
|
|
|
await expect(
|
|
requireEmployeeObjectAccess({
|
|
tenantId: "tenant-1",
|
|
viewerUserId: "tenant-admin-1",
|
|
employeeUserId: "employee-1",
|
|
canReadAll: false,
|
|
}),
|
|
).rejects.toThrow();
|
|
|
|
expect(mocks.tx.hrEmployeeProfile.findFirst).toHaveBeenCalledWith({
|
|
where: {
|
|
tenantId: "tenant-1",
|
|
userId: "employee-1",
|
|
managerUserId: "tenant-admin-1",
|
|
},
|
|
select: { userId: true, managerUserId: true },
|
|
});
|
|
});
|
|
|
|
it("allows self, direct-report and explicit HR-wide access without changing tenant scope", async () => {
|
|
mocks.tx.hrEmployeeProfile.findFirst
|
|
.mockResolvedValueOnce({ userId: "employee-1", managerUserId: "manager-1" })
|
|
.mockResolvedValueOnce({ userId: "employee-2", managerUserId: "someone-else" });
|
|
|
|
await expect(
|
|
requireEmployeeObjectAccess({
|
|
tenantId: "tenant-1",
|
|
viewerUserId: "manager-1",
|
|
employeeUserId: "manager-1",
|
|
canReadAll: false,
|
|
}),
|
|
).resolves.toEqual({ isSelf: true, isDirectReport: false });
|
|
expect(mocks.tx.hrEmployeeProfile.findFirst).not.toHaveBeenCalled();
|
|
await expect(
|
|
requireEmployeeObjectAccess({
|
|
tenantId: "tenant-1",
|
|
viewerUserId: "manager-1",
|
|
employeeUserId: "employee-1",
|
|
canReadAll: false,
|
|
}),
|
|
).resolves.toEqual({ isSelf: false, isDirectReport: true });
|
|
await expect(
|
|
requireEmployeeObjectAccess({
|
|
tenantId: "tenant-1",
|
|
viewerUserId: "hr-1",
|
|
employeeUserId: "employee-2",
|
|
canReadAll: true,
|
|
}),
|
|
).resolves.toEqual({ isSelf: false, isDirectReport: false });
|
|
});
|
|
|
|
it("creates the HR document and user link inside one tenant transaction", async () => {
|
|
await createDocumentWithLink({
|
|
tenantId: "tenant-1",
|
|
employeeUserId: "employee-1",
|
|
uploadedByUserId: "employee-1",
|
|
title: " Bescheinigung ",
|
|
documentKind: "certificate",
|
|
fileId: "file-1",
|
|
});
|
|
|
|
expect(mocks.tx.hrEmployeeDocument.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
tenantId: "tenant-1",
|
|
employeeUserId: "employee-1",
|
|
uploadedByUserId: "employee-1",
|
|
title: "Bescheinigung",
|
|
fileId: "file-1",
|
|
}),
|
|
});
|
|
expect(mocks.tx.fileEntityLink.create).toHaveBeenCalledWith({
|
|
data: {
|
|
tenantId: "tenant-1",
|
|
createdByUserId: "employee-1",
|
|
fileId: "file-1",
|
|
entityType: "user",
|
|
entityId: "employee-1",
|
|
linkRole: "hr_employee_document",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects a file from outside the tenant before creating a document", async () => {
|
|
mocks.tx.file.findFirst.mockResolvedValue(null);
|
|
|
|
await expect(
|
|
createDocumentWithLink({
|
|
tenantId: "tenant-1",
|
|
employeeUserId: "employee-1",
|
|
uploadedByUserId: "employee-1",
|
|
title: "Bescheinigung",
|
|
documentKind: "certificate",
|
|
fileId: "foreign-file",
|
|
}),
|
|
).rejects.toThrow();
|
|
|
|
expect(mocks.tx.hrEmployeeDocument.create).not.toHaveBeenCalled();
|
|
expect(mocks.tx.fileEntityLink.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("resolves picker labels with team, position and roles", async () => {
|
|
mocks.tx.tenantUser.findMany.mockResolvedValue([
|
|
{
|
|
userId: "employee-1",
|
|
user: { firstName: "Mira", lastName: "Beispiel", email: "mira@example.test" },
|
|
},
|
|
]);
|
|
mocks.tx.hrEmployeeProfile.findMany.mockResolvedValue([
|
|
{ userId: "employee-1", team: "Montage", position: "Teamleitung" },
|
|
]);
|
|
mocks.tx.userRole.findMany.mockResolvedValue([
|
|
{ userId: "employee-1", role: { name: "Projektleiter" } },
|
|
]);
|
|
|
|
await expect(listHrMemberOptions("tenant-1")).resolves.toEqual([
|
|
{
|
|
userId: "employee-1",
|
|
displayName: "Mira Beispiel",
|
|
email: "mira@example.test",
|
|
team: "Montage",
|
|
position: "Teamleitung",
|
|
roleNames: ["Projektleiter"],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("rejects a posted picker target outside the active tenant", async () => {
|
|
mocks.tx.tenantUser.findFirst.mockResolvedValue(null);
|
|
await expect(requireActiveHrMember("tenant-1", "foreign-user")).rejects.toThrow();
|
|
});
|
|
});
|