Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/privacy/gdprExportDownloadGate.server.test.ts
T

167 lines
6.5 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSharedStore, MemoryAdapter, type SharedStore } from "@hivegrid/shared-store";
import {
__resetGdprExportDownloadGateStoreForTests,
__setGdprExportDownloadGateStoreForTests,
acquireGdprExportDownloadGate,
GDPR_EXPORT_DOWNLOAD_LEASE_RENEW_SECONDS,
GDPR_EXPORT_DOWNLOAD_LEASE_TTL_SECONDS,
GDPR_EXPORT_DOWNLOAD_RATE_LIMIT_MAX,
holdGdprExportDownloadGateUntilResponseFinished,
} from "./gdprExportDownloadGate.server";
const secret = "gdpr-download-gate-secret-with-at-least-32-bytes";
const base = {
scope: "portal",
tenantId: "tenant-a1",
principalId: "portal-user-a",
artifactId: "export-a1",
secret,
} as const;
let store: SharedStore;
describe("GDPR export distributed download gate", () => {
beforeEach(() => {
store = createSharedStore({ namespace: "gdpr-download-gate-test", adapter: new MemoryAdapter() });
__setGdprExportDownloadGateStoreForTests(store);
});
afterEach(() => {
__resetGdprExportDownloadGateStoreForTests();
});
it("allows only one active lease per principal and per artifact", async () => {
const first = await acquireGdprExportDownloadGate(base);
expect(first).not.toBeNull();
await expect(acquireGdprExportDownloadGate({ ...base, artifactId: "export-b1" })).resolves.toBeNull();
await expect(acquireGdprExportDownloadGate({ ...base, principalId: "portal-user-b" })).resolves.toBeNull();
const independent = await acquireGdprExportDownloadGate({
...base,
principalId: "portal-user-b",
artifactId: "export-b1",
});
expect(independent).not.toBeNull();
await first?.release();
const afterRelease = await acquireGdprExportDownloadGate(base);
expect(afterRelease).not.toBeNull();
await independent?.release();
await afterRelease?.release();
});
it("does not expose tenant, principal or artifact identifiers in shared-store keys", async () => {
const lease = await acquireGdprExportDownloadGate(base);
const keys: string[] = [];
for await (const key of store.scan("*")) keys.push(key);
expect(keys).toHaveLength(4);
expect(keys.join("\n")).not.toContain(base.tenantId);
expect(keys.join("\n")).not.toContain(base.principalId);
expect(keys.join("\n")).not.toContain(base.artifactId);
await lease?.release();
});
it("lets an expired owner release without deleting a newer lease", async () => {
let now = 1_000;
store = createSharedStore({
namespace: "gdpr-download-gate-expiry-test",
adapter: new MemoryAdapter({ now: () => now }),
});
__setGdprExportDownloadGateStoreForTests(store);
const expiredOwner = await acquireGdprExportDownloadGate(base);
expect(expiredOwner).not.toBeNull();
now += GDPR_EXPORT_DOWNLOAD_LEASE_TTL_SECONDS * 1_000 + 1;
const newOwner = await acquireGdprExportDownloadGate(base);
expect(newOwner).not.toBeNull();
await expiredOwner?.release();
await expect(acquireGdprExportDownloadGate(base)).resolves.toBeNull();
await newOwner?.release();
const afterRelease = await acquireGdprExportDownloadGate(base);
expect(afterRelease).not.toBeNull();
await afterRelease?.release();
});
it("rate-limits quick sequential reads per principal and artifact", async () => {
for (let index = 0; index < GDPR_EXPORT_DOWNLOAD_RATE_LIMIT_MAX; index += 1) {
const lease = await acquireGdprExportDownloadGate(base);
expect(lease).not.toBeNull();
await lease?.release();
}
await expect(acquireGdprExportDownloadGate(base)).resolves.toBeNull();
const independent = await acquireGdprExportDownloadGate({
...base,
principalId: "portal-user-b",
artifactId: "export-b1",
});
expect(independent).not.toBeNull();
await independent?.release();
});
it("does not allocate attacker-controlled artifact rate keys after the principal limit is exhausted", async () => {
for (let index = 0; index < GDPR_EXPORT_DOWNLOAD_RATE_LIMIT_MAX; index += 1) {
const lease = await acquireGdprExportDownloadGate(base);
expect(lease).not.toBeNull();
await lease?.release();
}
const keysBefore: string[] = [];
for await (const key of store.scan("*")) keysBefore.push(key);
for (let index = 0; index < 20; index += 1) {
await expect(acquireGdprExportDownloadGate({ ...base, artifactId: `export-random-${index}` })).resolves.toBeNull();
}
const keysAfter: string[] = [];
for await (const key of store.scan("*")) keysAfter.push(key);
expect(keysAfter.sort()).toEqual(keysBefore.sort());
});
it("renews an active response lease before its crash-recovery TTL", async () => {
vi.useFakeTimers();
try {
store = createSharedStore({
namespace: "gdpr-download-gate-renew-test",
adapter: new MemoryAdapter({ now: () => Date.now() }),
});
__setGdprExportDownloadGateStoreForTests(store);
const lease = await acquireGdprExportDownloadGate(base);
expect(lease).not.toBeNull();
await vi.advanceTimersByTimeAsync(GDPR_EXPORT_DOWNLOAD_LEASE_RENEW_SECONDS * 1_000);
await vi.advanceTimersByTimeAsync(
(GDPR_EXPORT_DOWNLOAD_LEASE_TTL_SECONDS - GDPR_EXPORT_DOWNLOAD_LEASE_RENEW_SECONDS + 1) * 1_000,
);
await expect(acquireGdprExportDownloadGate(base)).resolves.toBeNull();
await lease?.release();
} finally {
vi.useRealTimers();
}
});
it("fails closed for weak identifiers, weak secrets and shared-store failures", async () => {
await expect(acquireGdprExportDownloadGate({ ...base, artifactId: "bad" })).resolves.toBeNull();
await expect(acquireGdprExportDownloadGate({ ...base, secret: "short" })).resolves.toBeNull();
vi.spyOn(store, "eval").mockRejectedValueOnce(new Error("redis unavailable"));
await expect(acquireGdprExportDownloadGate(base)).resolves.toBeNull();
});
it("holds the lease until the response body finishes or is cancelled", async () => {
const released = vi.fn().mockResolvedValue(undefined);
const response = holdGdprExportDownloadGateUntilResponseFinished(new Response("export-body"), { release: released });
expect(released).not.toHaveBeenCalled();
await expect(response.text()).resolves.toBe("export-body");
expect(released).toHaveBeenCalledTimes(1);
const cancelled = vi.fn().mockResolvedValue(undefined);
const cancelledResponse = holdGdprExportDownloadGateUntilResponseFinished(new Response("cancelled-body"), { release: cancelled });
await cancelledResponse.body?.cancel("client disconnected");
expect(cancelled).toHaveBeenCalledTimes(1);
});
});