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(); }); });