200 lines
9.2 KiB
TypeScript
200 lines
9.2 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
audit: vi.fn(),
|
|
notify: vi.fn(),
|
|
tx: {
|
|
employeePasswordSafeVault: { findFirst: vi.fn(), findMany: vi.fn() },
|
|
employeePasswordSafeAccess: {
|
|
findFirst: vi.fn(),
|
|
findMany: vi.fn(),
|
|
count: vi.fn(),
|
|
upsert: vi.fn(),
|
|
deleteMany: vi.fn(),
|
|
},
|
|
employeePasswordSafeEntry: { findFirst: vi.fn(), findMany: vi.fn(), updateMany: vi.fn() },
|
|
tenantUser: { findFirst: vi.fn(), findMany: vi.fn() },
|
|
userRole: { findMany: vi.fn() },
|
|
auditEvent: { findMany: vi.fn() },
|
|
},
|
|
}));
|
|
|
|
vi.mock("~/utils/db/.server/tenantRls.server", () => ({
|
|
withTenantRLS: (_tenantId: string, callback: (tx: typeof mocks.tx) => unknown) => callback(mocks.tx),
|
|
}));
|
|
vi.mock("@hivegrid/db/audit-events.server", () => ({ createAuditEvent: mocks.audit }));
|
|
vi.mock("@hivegrid/foundation", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("@hivegrid/foundation")>()),
|
|
notify: mocks.notify,
|
|
}));
|
|
|
|
import {
|
|
archiveEntry,
|
|
copyEntryValue,
|
|
exportVault,
|
|
getVaultForUser,
|
|
grantAccess,
|
|
listAccessibleVaults,
|
|
listPasswordSafeActivity,
|
|
revokeAccess,
|
|
} from "./hrPasswordSafeService.server";
|
|
|
|
const crypto = {
|
|
encrypt: vi.fn(async (_tenantId: string, value: string) => `cipher:${value}`),
|
|
decrypt: vi.fn(async (_tenantId: string, value: string) => value.replace("cipher:", "")),
|
|
};
|
|
|
|
describe("password-safe object permissions and command auditing", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.tx.employeePasswordSafeVault.findFirst.mockResolvedValue({ id: "vault-1", tenantId: "tenant-1", name: "Ops", archivedAt: null });
|
|
mocks.tx.employeePasswordSafeVault.findMany.mockResolvedValue([]);
|
|
mocks.tx.employeePasswordSafeAccess.findFirst.mockResolvedValue({ permissionLevel: "admin" });
|
|
mocks.tx.employeePasswordSafeAccess.findMany.mockResolvedValue([]);
|
|
mocks.tx.employeePasswordSafeAccess.count.mockResolvedValue(2);
|
|
mocks.tx.employeePasswordSafeAccess.upsert.mockResolvedValue({ id: "access-1" });
|
|
mocks.tx.employeePasswordSafeAccess.deleteMany.mockResolvedValue({ count: 1 });
|
|
mocks.tx.employeePasswordSafeEntry.findMany.mockResolvedValue([]);
|
|
mocks.tx.employeePasswordSafeEntry.updateMany.mockResolvedValue({ count: 1 });
|
|
mocks.tx.tenantUser.findFirst.mockResolvedValue({ id: "member-1" });
|
|
mocks.tx.tenantUser.findMany.mockResolvedValue([]);
|
|
mocks.tx.userRole.findMany.mockResolvedValue([]);
|
|
mocks.tx.auditEvent.findMany.mockResolvedValue([]);
|
|
mocks.audit.mockResolvedValue({ id: "audit-1" });
|
|
mocks.notify.mockResolvedValue({ id: "notification-1" });
|
|
});
|
|
|
|
it("rejects a grant target outside the active tenant membership", async () => {
|
|
mocks.tx.tenantUser.findFirst.mockResolvedValue(null);
|
|
await expect(
|
|
grantAccess({ tenantId: "tenant-1", vaultId: "vault-1", targetUserId: "outsider", permissionLevel: "read", actorUserId: "admin-1" })
|
|
).rejects.toThrow();
|
|
expect(mocks.tx.employeePasswordSafeAccess.upsert).not.toHaveBeenCalled();
|
|
expect(mocks.audit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("prevents downgrading or revoking the last vault admin", async () => {
|
|
mocks.tx.employeePasswordSafeAccess.count.mockResolvedValue(1);
|
|
await expect(
|
|
grantAccess({ tenantId: "tenant-1", vaultId: "vault-1", targetUserId: "admin-1", permissionLevel: "write", actorUserId: "admin-1" })
|
|
).rejects.toMatchObject({ messageKey: "hr.error.lastVaultAdmin" });
|
|
expect(mocks.tx.employeePasswordSafeAccess.upsert).not.toHaveBeenCalled();
|
|
|
|
mocks.tx.employeePasswordSafeAccess.findFirst.mockResolvedValue({ permissionLevel: "admin" });
|
|
await expect(
|
|
revokeAccess({ tenantId: "tenant-1", vaultId: "vault-1", targetUserId: "admin-1", actorUserId: "admin-1" })
|
|
).rejects.toMatchObject({ messageKey: "hr.error.lastVaultAdmin" });
|
|
expect(mocks.tx.employeePasswordSafeAccess.deleteMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("audits and notifies a named access change without secret material", async () => {
|
|
mocks.tx.employeePasswordSafeAccess.findFirst
|
|
.mockResolvedValueOnce({ permissionLevel: "admin" })
|
|
.mockResolvedValueOnce(null);
|
|
await grantAccess({ tenantId: "tenant-1", vaultId: "vault-1", targetUserId: "member-1", permissionLevel: "read", actorUserId: "admin-1" });
|
|
expect(mocks.audit).toHaveBeenCalledWith(expect.objectContaining({ action: "HR_PASSWORD_SAFE_ACCESS_GRANT", dataSensitivity: "internal" }));
|
|
expect(mocks.notify).toHaveBeenCalledWith(
|
|
expect.objectContaining({ userId: "member-1", messageKey: "hr.passwordSafe.notification.accessGranted" }),
|
|
mocks.tx
|
|
);
|
|
expect(JSON.stringify(mocks.audit.mock.calls)).not.toMatch(/passwordCiphertext|usernameCiphertext|notesCiphertext/u);
|
|
});
|
|
|
|
it("copy re-checks read access and writes a distinct confidential audit event", async () => {
|
|
mocks.tx.employeePasswordSafeAccess.findFirst.mockResolvedValue({ permissionLevel: "read" });
|
|
mocks.tx.employeePasswordSafeEntry.findFirst.mockResolvedValue({
|
|
id: "entry-1",
|
|
usernameCiphertext: "cipher:alice",
|
|
passwordCiphertext: "cipher:secret",
|
|
notesCiphertext: null,
|
|
});
|
|
await expect(
|
|
copyEntryValue({ tenantId: "tenant-1", vaultId: "vault-1", entryId: "entry-1", userId: "member-1", field: "password" }, crypto)
|
|
).resolves.toBe("secret");
|
|
expect(mocks.audit).toHaveBeenCalledWith(
|
|
expect.objectContaining({ action: "HR_PASSWORD_SAFE_COPY", dataSensitivity: "confidential", metadata: { vaultId: "vault-1", field: "password" } })
|
|
);
|
|
});
|
|
|
|
it("enforces read, write and admin as separate object roles and grants no content access to a metadata admin", async () => {
|
|
mocks.tx.employeePasswordSafeAccess.findFirst.mockResolvedValue({ permissionLevel: "read" });
|
|
await expect(
|
|
archiveEntry({ tenantId: "tenant-1", vaultId: "vault-1", entryId: "entry-1", userId: "reader-1" })
|
|
).rejects.toThrow();
|
|
expect(mocks.tx.employeePasswordSafeEntry.updateMany).not.toHaveBeenCalled();
|
|
|
|
mocks.tx.employeePasswordSafeAccess.findFirst.mockResolvedValue({ permissionLevel: "write" });
|
|
await expect(
|
|
archiveEntry({ tenantId: "tenant-1", vaultId: "vault-1", entryId: "entry-1", userId: "writer-1" })
|
|
).resolves.toBeUndefined();
|
|
await expect(
|
|
grantAccess({ tenantId: "tenant-1", vaultId: "vault-1", targetUserId: "member-1", permissionLevel: "read", actorUserId: "writer-1" })
|
|
).rejects.toThrow();
|
|
|
|
mocks.tx.employeePasswordSafeAccess.findFirst.mockResolvedValue(null);
|
|
await expect(getVaultForUser("tenant-1", "vault-1", "metadata-admin-1")).resolves.toBeNull();
|
|
});
|
|
|
|
it("keeps metadata search access-scoped and never queries entry secrets", async () => {
|
|
await listAccessibleVaults("tenant-1", "member-1", { search: "router" });
|
|
expect(mocks.tx.employeePasswordSafeVault.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: {
|
|
tenantId: "tenant-1",
|
|
archivedAt: null,
|
|
access: { some: { tenantId: "tenant-1", userId: "member-1" } },
|
|
OR: [
|
|
{ name: { contains: "router", mode: "insensitive" } },
|
|
{ description: { contains: "router", mode: "insensitive" } },
|
|
],
|
|
},
|
|
})
|
|
);
|
|
expect(mocks.tx.employeePasswordSafeEntry.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns a redacted activity timeline without audit metadata or raw actor ids", async () => {
|
|
mocks.tx.auditEvent.findMany.mockResolvedValue([{
|
|
id: "audit-1",
|
|
createdAt: new Date("2026-07-25T10:00:00Z"),
|
|
action: "HR_PASSWORD_SAFE_REVEAL",
|
|
actorId: "opaque-user-id",
|
|
outcome: "success",
|
|
metadata: { field: "password", entryId: "secret-entry-id" },
|
|
}]);
|
|
mocks.tx.tenantUser.findMany.mockResolvedValue([{
|
|
userId: "opaque-user-id",
|
|
user: { firstName: "Mira", lastName: "Beispiel", email: "mira@example.test" },
|
|
}]);
|
|
const timeline = await listPasswordSafeActivity({
|
|
tenantId: "tenant-1",
|
|
userId: "metadata-admin-1",
|
|
isTenantPasswordSafeAdmin: true,
|
|
});
|
|
expect(timeline).toEqual([expect.objectContaining({ action: "HR_PASSWORD_SAFE_REVEAL", actorLabel: "Mira Beispiel" })]);
|
|
expect(JSON.stringify(timeline)).not.toMatch(/opaque-user-id|secret-entry-id|entryId|field/u);
|
|
});
|
|
|
|
it("fails closed in the service when the separate export permission is absent", async () => {
|
|
await expect(
|
|
exportVault({ tenantId: "tenant-1", vaultId: "vault-1", userId: "member-1", hasExportPermission: false }, crypto)
|
|
).rejects.toThrow();
|
|
expect(mocks.tx.employeePasswordSafeEntry.findMany).not.toHaveBeenCalled();
|
|
expect(mocks.audit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("exports only with the separate permission and records a confidential GDPR audit event", async () => {
|
|
mocks.tx.employeePasswordSafeAccess.findFirst.mockResolvedValue({ permissionLevel: "read" });
|
|
await expect(
|
|
exportVault({ tenantId: "tenant-1", vaultId: "vault-1", userId: "member-1", hasExportPermission: true }, crypto)
|
|
).resolves.toEqual({ vaultName: "Ops", entries: [] });
|
|
expect(mocks.audit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
action: "HR_PASSWORD_SAFE_EXPORT",
|
|
dataSensitivity: "confidential",
|
|
regulatoryScope: ["gdpr"],
|
|
})
|
|
);
|
|
});
|
|
});
|