192 lines
6.6 KiB
TypeScript
192 lines
6.6 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { forgetTenantTrustedDevice, getTenantSecuritySnapshot, revokeTenantUserSession, TenantSecurityError, type TenantSecurityDeps } from "./tenant-security.server";
|
|
import { getRlsExecutionContext } from "@hivegrid/db/rls-context.server";
|
|
|
|
function makeDb() {
|
|
return {
|
|
tenant: {
|
|
findUnique: vi.fn(async () => ({
|
|
id: "tenant_1",
|
|
name: "Acme",
|
|
slug: "acme",
|
|
active: true,
|
|
users: [
|
|
{
|
|
status: 1,
|
|
user: {
|
|
id: "user_1",
|
|
email: "owner@example.test",
|
|
firstName: "Owner",
|
|
lastName: "Example",
|
|
active: true,
|
|
totpEnabledAt: new Date("2026-07-01T00:00:00.000Z"),
|
|
totpPendingCreatedAt: null,
|
|
mfaRecoveryCodeHashes: [{ hash: "x" }],
|
|
},
|
|
},
|
|
],
|
|
})),
|
|
},
|
|
userSession: {
|
|
findMany: vi.fn(async () => [
|
|
{
|
|
id: "session_1",
|
|
userId: "user_1",
|
|
deviceLabel: "MacBook",
|
|
userAgent: "Safari",
|
|
approxLocation: "DE",
|
|
createdAt: new Date("2026-07-09T08:00:00.000Z"),
|
|
lastSeenAt: new Date("2026-07-09T09:00:00.000Z"),
|
|
expiresAt: new Date("2026-07-10T09:00:00.000Z"),
|
|
revokedAt: null,
|
|
trustedAt: null,
|
|
user: { email: "owner@example.test" },
|
|
},
|
|
]),
|
|
findFirst: vi.fn(async () => ({ id: "session_1", revokedAt: null, userId: "user_1", user: { email: "owner@example.test" } })),
|
|
update: vi.fn(async () => ({})),
|
|
},
|
|
trustedDevice: {
|
|
findMany: vi.fn(async () => [
|
|
{
|
|
id: "device_1",
|
|
userId: "user_1",
|
|
deviceLabel: "Safari",
|
|
approxLocation: "DE",
|
|
lastUsedAt: new Date("2026-07-09T08:00:00.000Z"),
|
|
expiresAt: new Date("2026-08-09T08:00:00.000Z"),
|
|
revokedAt: null,
|
|
user: { email: "owner@example.test" },
|
|
},
|
|
]),
|
|
findFirst: vi.fn(async () => ({ id: "device_1", revokedAt: null, userId: "user_1", user: { email: "owner@example.test" } })),
|
|
update: vi.fn(async () => ({})),
|
|
},
|
|
magicLinkToken: {
|
|
findMany: vi.fn(async () => []),
|
|
},
|
|
tenantIpAddress: {
|
|
findMany: vi.fn(async () => [
|
|
{
|
|
id: "ip_1",
|
|
ip: "192.168.10.25",
|
|
fromUrl: "/login",
|
|
createdAt: new Date("2026-07-09T08:00:00.000Z"),
|
|
user: { email: "owner@example.test" },
|
|
apiKey: null,
|
|
},
|
|
]),
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeDeps(db = makeDb()) {
|
|
return {
|
|
db,
|
|
now: () => new Date("2026-07-09T10:00:00.000Z"),
|
|
listAuditEvents: vi.fn(async () => ({
|
|
rows: [
|
|
{
|
|
id: "audit_1",
|
|
createdAt: new Date("2026-07-09T09:00:00.000Z"),
|
|
action: "auth.login.success",
|
|
outcome: "success",
|
|
actorEmail: "owner@example.test",
|
|
actorId: "user_1",
|
|
summaryText: "Login erfolgreich",
|
|
},
|
|
],
|
|
total: 1,
|
|
take: 25,
|
|
skip: 0,
|
|
})),
|
|
recordAdminAction: vi.fn(async () => {}),
|
|
} as unknown as TenantSecurityDeps & { db: ReturnType<typeof makeDb>; recordAdminAction: ReturnType<typeof vi.fn>; listAuditEvents: ReturnType<typeof vi.fn> };
|
|
}
|
|
|
|
describe("getTenantSecuritySnapshot", () => {
|
|
it("loads security diagnostics for one tenant", async () => {
|
|
const deps = makeDeps();
|
|
deps.db.tenant.findUnique.mockImplementationOnce(async () => {
|
|
expect(getRlsExecutionContext()?.tenantId).toBe("tenant_1");
|
|
return makeDb().tenant.findUnique();
|
|
});
|
|
|
|
const snapshot = await getTenantSecuritySnapshot("tenant_1", deps);
|
|
|
|
expect(deps.db.tenant.findUnique).toHaveBeenCalledWith(expect.objectContaining({ where: { id: "tenant_1" } }));
|
|
expect(deps.db.userSession.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { userId: { in: ["user_1"] } } }));
|
|
expect(deps.listAuditEvents).toHaveBeenCalledWith(expect.objectContaining({ take: 25 }));
|
|
expect(snapshot.users[0]).toMatchObject({ email: "owner@example.test", mfaEnabled: true, recoveryCodesConfigured: true });
|
|
expect(snapshot.sessions[0]).toMatchObject({ id: "session_1", userEmail: "owner@example.test" });
|
|
expect(snapshot.ipLogs[0]).toMatchObject({ ip: "192.168.10.25" });
|
|
});
|
|
|
|
it("raises a typed error when the tenant does not exist", async () => {
|
|
const db = makeDb();
|
|
db.tenant.findUnique.mockResolvedValueOnce(null);
|
|
|
|
await expect(getTenantSecuritySnapshot("missing", makeDeps(db))).rejects.toMatchObject({ code: "TENANT_NOT_FOUND" });
|
|
});
|
|
});
|
|
|
|
describe("tenant security operator actions", () => {
|
|
it("revokes a session and records an admin audit event", async () => {
|
|
const deps = makeDeps();
|
|
deps.db.userSession.update.mockImplementationOnce(async () => {
|
|
expect(getRlsExecutionContext()?.tenantId).toBe("tenant_1");
|
|
return {};
|
|
});
|
|
|
|
await revokeTenantUserSession(
|
|
{
|
|
tenantId: "tenant_1",
|
|
sessionId: "session_1",
|
|
reason: "Supportfall MFA Reset",
|
|
actorUserId: "admin_1",
|
|
actorEmail: "admin@hivegrid.de",
|
|
},
|
|
deps
|
|
);
|
|
|
|
expect(deps.db.userSession.update).toHaveBeenCalledWith({
|
|
where: { id: "session_1" },
|
|
data: { revokedAt: new Date("2026-07-09T10:00:00.000Z") },
|
|
});
|
|
expect(deps.recordAdminAction).toHaveBeenCalledWith(expect.objectContaining({ action: "security.sessionRevoked", targetId: "session_1" }));
|
|
});
|
|
|
|
it("forgets a trusted device and records an admin audit event", async () => {
|
|
const deps = makeDeps();
|
|
deps.db.trustedDevice.update.mockImplementationOnce(async () => {
|
|
expect(getRlsExecutionContext()?.tenantId).toBe("tenant_1");
|
|
return {};
|
|
});
|
|
|
|
await forgetTenantTrustedDevice(
|
|
{
|
|
tenantId: "tenant_1",
|
|
trustedDeviceId: "device_1",
|
|
reason: "Geraet verloren",
|
|
actorUserId: "admin_1",
|
|
},
|
|
deps
|
|
);
|
|
|
|
expect(deps.db.trustedDevice.update).toHaveBeenCalledWith({
|
|
where: { id: "device_1" },
|
|
data: { revokedAt: new Date("2026-07-09T10:00:00.000Z") },
|
|
});
|
|
expect(deps.recordAdminAction).toHaveBeenCalledWith(expect.objectContaining({ action: "security.trustedDeviceForgotten", targetId: "device_1" }));
|
|
});
|
|
|
|
it("returns a typed error for unknown sessions", async () => {
|
|
const db = makeDb();
|
|
db.userSession.findFirst.mockResolvedValueOnce(null);
|
|
|
|
await expect(
|
|
revokeTenantUserSession({ tenantId: "tenant_1", sessionId: "missing", reason: "Supportfall MFA Reset", actorUserId: "admin_1" }, makeDeps(db))
|
|
).rejects.toBeInstanceOf(TenantSecurityError);
|
|
});
|
|
});
|