Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/scripts/demo-pro-seed/persistence.test.ts
T

177 lines
7.3 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { type DbClient } from "@hivegrid/db/client.server";
import { verifySupportedPassword } from "../../apps/api/src/auth/password";
import { hashDemoProUserPassword, renewDemoProSeedLease, resetDemoProTenant, startDemoProSeedLeaseHeartbeat, type DemoProSeedLease } from "./persistence";
const lease: DemoProSeedLease = {
tenantId: "tenant_staging_demo_pro",
tenantSlug: "demo-pro-crm",
lockId: "demo-pro-seed-lock-tenant_staging_demo_pro",
token: "lease-token",
};
function leaseDb(updateMany: ReturnType<typeof vi.fn>): DbClient {
return { outboxEvent: { updateMany } } as unknown as DbClient;
}
afterEach(() => {
vi.useRealTimers();
});
describe("demo-pro seed lease heartbeat", () => {
it("renews only the lock owned by the current run", async () => {
const updateMany = vi.fn().mockResolvedValue({ count: 1 });
await renewDemoProSeedLease(leaseDb(updateMany), lease);
expect(updateMany).toHaveBeenCalledWith({
where: {
id: lease.lockId,
tenantId: lease.tenantId,
lastError: lease.token,
status: "PROCESSING",
},
data: { lockedUntil: expect.any(Date) },
});
});
it("keeps renewing until stopped", async () => {
vi.useFakeTimers();
const updateMany = vi.fn().mockResolvedValue({ count: 1 });
const heartbeat = startDemoProSeedLeaseHeartbeat(leaseDb(updateMany), lease, 1_000);
await heartbeat.assertHealthy();
await vi.advanceTimersByTimeAsync(3_000);
await heartbeat.assertHealthy();
expect(updateMany).toHaveBeenCalledTimes(4);
await heartbeat.stop();
await vi.advanceTimersByTimeAsync(3_000);
expect(updateMany).toHaveBeenCalledTimes(4);
});
it("fails closed when lease ownership is lost", async () => {
vi.useFakeTimers();
const updateMany = vi.fn().mockResolvedValueOnce({ count: 1 }).mockResolvedValueOnce({ count: 0 });
const heartbeat = startDemoProSeedLeaseHeartbeat(leaseDb(updateMany), lease, 1_000);
await heartbeat.assertHealthy();
await vi.advanceTimersByTimeAsync(1_000);
await expect(heartbeat.assertHealthy()).rejects.toThrow("tenant lease was lost during execution");
await expect(heartbeat.stop()).rejects.toThrow("tenant lease was lost during execution");
});
});
describe("demo-pro seed password hashing", () => {
it("uses the app-standard Argon2id hash format", async () => {
const password = "R7!qL2#vN9@xP4$kT8&m";
const hash = await hashDemoProUserPassword(password);
expect(hash).toMatch(/^\$argon2id\$/u);
await expect(verifySupportedPassword(password, hash)).resolves.toBe(true);
});
});
describe("demo-pro tenant identity reset", () => {
it("revokes John, Luna and admin sessions while preserving other memberships and global account state", async () => {
const targetTenantId = "tenant_staging_demo_pro";
const previousMemberIds = ["old-john", "old-luna", "old-admin", "seed-existing"];
const seedEmployeeIds = ["seed-existing", "seed-new"];
const order: string[] = [];
const tenantUserDeleteMany = vi.fn(async () => {
order.push("purge-target-members");
return { count: previousMemberIds.length };
});
const userSessionDeleteMany = vi.fn().mockResolvedValue({ count: 5 });
const trustedDeviceDeleteMany = vi.fn().mockResolvedValue({ count: 5 });
const magicLinkTokenDeleteMany = vi.fn().mockResolvedValue({ count: 5 });
const deviceTokenDeleteMany = vi.fn().mockResolvedValue({ count: 5 });
const roleDeleteMany = vi.fn().mockResolvedValue({ count: 7 });
const userUpdateMany = vi.fn().mockResolvedValue({ count: 1 });
const fallbackDelegates = new Map<string, { deleteMany: ReturnType<typeof vi.fn> }>();
const txTarget = {
$executeRaw: vi.fn().mockResolvedValue(undefined),
tenantUser: {
findMany: vi.fn(async () => {
order.push("capture-members");
return previousMemberIds.map((userId) => ({ userId }));
}),
deleteMany: tenantUserDeleteMany,
},
knowledgeBase: {
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
knowledgeBaseArticle: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) },
knowledgeBaseCategory: { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) },
outboxEvent: {
createMany: vi.fn(async ({ data }: { data: unknown[] }) => ({ count: data.length })),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
userSession: { deleteMany: userSessionDeleteMany },
trustedDevice: { deleteMany: trustedDeviceDeleteMany },
magicLinkToken: { deleteMany: magicLinkTokenDeleteMany },
deviceToken: { deleteMany: deviceTokenDeleteMany },
role: { deleteMany: roleDeleteMany },
user: { updateMany: userUpdateMany },
};
const tx = new Proxy(txTarget as Record<string, unknown>, {
get(target, property, receiver) {
if (Reflect.has(target, property)) return Reflect.get(target, property, receiver);
const key = String(property);
const delegate = fallbackDelegates.get(key) ?? { deleteMany: vi.fn().mockResolvedValue({ count: 0 }) };
fallbackDelegates.set(key, delegate);
return delegate;
},
});
const db = {
tenant: {
findUnique: vi.fn().mockResolvedValue({ id: targetTenantId, slug: "demo-pro-crm", name: "Demo Pro CRM" }),
},
outboxEvent: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
count: vi.fn().mockResolvedValue(0),
},
$transaction: vi.fn(async (callback: (transaction: unknown) => Promise<unknown>) => callback(tx)),
} as unknown as DbClient;
await resetDemoProTenant(db, "demo-pro-crm", seedEmployeeIds);
expect(order).toEqual(["capture-members", "purge-target-members"]);
expect(tenantUserDeleteMany).toHaveBeenCalledTimes(1);
expect(tenantUserDeleteMany).toHaveBeenCalledWith({ where: { tenantId: targetTenantId } });
expect(roleDeleteMany).toHaveBeenCalledWith({
where: {
tenantId: targetTenantId,
OR: [{ isTemplateOrigin: true }, { name: { in: expect.arrayContaining(["Geschäftsführer", "Monteur"]) } }],
},
});
for (const deleteMany of [userSessionDeleteMany, trustedDeviceDeleteMany, magicLinkTokenDeleteMany]) {
expect(deleteMany).toHaveBeenCalledWith({ where: { userId: { in: seedEmployeeIds } } });
}
expect(deviceTokenDeleteMany).toHaveBeenCalledWith({ where: { tenantId: targetTenantId } });
expect(deviceTokenDeleteMany).toHaveBeenCalledWith({ where: { userId: { in: seedEmployeeIds } } });
expect(userUpdateMany).toHaveBeenNthCalledWith(1, {
where: { id: { in: previousMemberIds }, defaultTenantId: targetTenantId },
data: { defaultTenantId: null },
});
expect(userUpdateMany).toHaveBeenNthCalledWith(2, {
where: { id: { in: seedEmployeeIds } },
data: expect.objectContaining({
active: false,
defaultTenantId: null,
passwordHash: null,
totpSecretEncrypted: null,
mfaRecoveryCodeHashes: expect.anything(),
}),
});
const seedResetIds = userUpdateMany.mock.calls[1]![0].where.id.in as string[];
expect(seedResetIds).not.toContain("old-john");
expect(seedResetIds).not.toContain("old-luna");
expect(seedResetIds).not.toContain("old-admin");
});
});