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

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"],
})
);
});
});