167 lines
6.5 KiB
TypeScript
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);
|
|
});
|
|
});
|