177 lines
7.3 KiB
TypeScript
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");
|
|
});
|
|
});
|