245 lines
8.2 KiB
TypeScript
245 lines
8.2 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
HelperLimitError,
|
|
assertBoundedBytes,
|
|
assertBoundedItems,
|
|
assertBoundedText,
|
|
createObjectUrlLease,
|
|
createObjectUrlLeasePool,
|
|
createSeededRandom,
|
|
formatBytes,
|
|
planBlobDownloads,
|
|
sanitizeDownloadFilename,
|
|
secureRandomBytes,
|
|
secureRandomInt,
|
|
shuffleSeeded,
|
|
triggerBlobDownload,
|
|
triggerBlobDownloads,
|
|
} from "../../src/helpers";
|
|
|
|
describe("shared ceilings and download safety", () => {
|
|
it("reports actual and configured bounds", () => {
|
|
expect(() => assertBoundedText("123", 2)).toThrow(HelperLimitError);
|
|
expect(() => assertBoundedBytes(new Uint8Array(3), 2)).toThrow(/limit/u);
|
|
expect(() => assertBoundedItems(3, 2)).toThrow(/limit/u);
|
|
try {
|
|
assertBoundedText("123", 2, "Sample");
|
|
} catch (error) {
|
|
expect(error).toMatchObject({ actual: 3, limit: 2 });
|
|
}
|
|
});
|
|
|
|
it("sanitizes filenames and revokes object URLs exactly once", () => {
|
|
expect(sanitizeDownloadFilename("../bad:\0name?.txt")).toBe(
|
|
"_bad__name_.txt",
|
|
);
|
|
expect(sanitizeDownloadFilename("...", "../fallback?.txt")).toBe(
|
|
"_fallback_.txt",
|
|
);
|
|
expect(sanitizeDownloadFilename("CON.txt")).toBe("_CON.txt");
|
|
expect(sanitizeDownloadFilename("report\u202egnp.exe")).toBe(
|
|
"report_gnp.exe",
|
|
);
|
|
const emojiBoundary = sanitizeDownloadFilename(
|
|
`${"a".repeat(179)}😀.txt`,
|
|
"fallback.txt",
|
|
180,
|
|
);
|
|
expect(emojiBoundary).not.toMatch(/[\uD800-\uDBFF]$/u);
|
|
expect(emojiBoundary.length).toBeLessThanOrEqual(180);
|
|
expect(() => sanitizeDownloadFilename("x", "fallback", 0)).toThrow(
|
|
/positive/u,
|
|
);
|
|
const createObjectURL = vi.fn(() => "blob:test");
|
|
const revokeObjectURL = vi.fn();
|
|
const lease = createObjectUrlLease(new Blob(["x"]), {
|
|
createObjectURL,
|
|
revokeObjectURL,
|
|
});
|
|
expect(lease).toMatchObject({ url: "blob:test", revoked: false });
|
|
lease.revoke();
|
|
lease.revoke();
|
|
expect(lease.revoked).toBe(true);
|
|
expect(revokeObjectURL).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("removes download anchors and revokes their URL on the next task", () => {
|
|
const originalUrl = globalThis.URL;
|
|
const createObjectURL = vi.fn(() => "blob:download");
|
|
const revokeObjectURL = vi.fn();
|
|
const click = vi
|
|
.spyOn(HTMLAnchorElement.prototype, "click")
|
|
.mockImplementation(() => undefined);
|
|
vi.stubGlobal("URL", { createObjectURL, revokeObjectURL });
|
|
vi.useFakeTimers();
|
|
try {
|
|
const lease = triggerBlobDownload(new Blob(["local"]), "../safe?.txt");
|
|
expect(click).toHaveBeenCalledOnce();
|
|
expect(click.mock.instances[0]).toMatchObject({
|
|
download: "_safe_.txt",
|
|
});
|
|
expect(document.querySelector('a[href="blob:download"]')).toBeNull();
|
|
expect(lease.revoked).toBe(false);
|
|
vi.runAllTimers();
|
|
expect(lease.revoked).toBe(true);
|
|
expect(revokeObjectURL).toHaveBeenCalledWith("blob:download");
|
|
} finally {
|
|
vi.useRealTimers();
|
|
vi.stubGlobal("URL", originalUrl);
|
|
}
|
|
});
|
|
|
|
it("formats IEC and SI byte quantities deterministically", () => {
|
|
expect(formatBytes(0)).toBe("0 B");
|
|
expect(formatBytes(1536)).toBe("1.50 KiB");
|
|
expect(formatBytes(1500, { system: "si", fractionDigits: 1 })).toBe(
|
|
"1.5 kB",
|
|
);
|
|
expect(formatBytes(-1024)).toBe("-1.00 KiB");
|
|
expect(formatBytes(Number.NaN)).toBe("unknown");
|
|
});
|
|
|
|
it("replaces and revokes named object URL leases", () => {
|
|
let sequence = 0;
|
|
const revokeObjectURL = vi.fn();
|
|
const pool = createObjectUrlLeasePool({
|
|
createObjectURL: vi.fn(() => `blob:${++sequence}`),
|
|
revokeObjectURL,
|
|
});
|
|
const first = pool.create("preview", new Blob(["one"]));
|
|
const second = pool.create("preview", new Blob(["two"]));
|
|
expect(first.revoked).toBe(true);
|
|
expect(second.url).toBe("blob:2");
|
|
expect(pool.size).toBe(1);
|
|
expect(pool.revoke("missing")).toBe(false);
|
|
pool.revokeAll();
|
|
expect(second.revoked).toBe(true);
|
|
expect(pool.size).toBe(0);
|
|
expect(revokeObjectURL).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("plans collision-free batch names and revokes every URL", () => {
|
|
const items = [
|
|
{ blob: new Blob(["a"]), filename: "Report.txt" },
|
|
{ blob: new Blob(["b"]), filename: "report.txt" },
|
|
{ blob: new Blob(["c"]), filename: "../unsafe?.txt" },
|
|
];
|
|
expect(planBlobDownloads(items).map((item) => item.filename)).toEqual([
|
|
"Report.txt",
|
|
"report (2).txt",
|
|
"_unsafe_.txt",
|
|
]);
|
|
|
|
const revokeObjectURL = vi.fn();
|
|
let index = 0;
|
|
const click = vi
|
|
.spyOn(HTMLAnchorElement.prototype, "click")
|
|
.mockImplementation(() => undefined);
|
|
let scheduled: (() => void) | undefined;
|
|
const batch = triggerBlobDownloads(items, {
|
|
ownerDocument: document,
|
|
urlApi: {
|
|
createObjectURL: () => `blob:batch-${++index}`,
|
|
revokeObjectURL,
|
|
},
|
|
schedule: (callback) => {
|
|
scheduled = callback;
|
|
},
|
|
});
|
|
expect(click).toHaveBeenCalledTimes(3);
|
|
expect(batch.leases.every((lease) => !lease.revoked)).toBe(true);
|
|
scheduled?.();
|
|
expect(batch.leases.every((lease) => lease.revoked)).toBe(true);
|
|
expect(revokeObjectURL).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it("bounds lazy download plans before consuming the whole iterable", () => {
|
|
let yielded = 0;
|
|
function* many() {
|
|
while (true) {
|
|
yielded += 1;
|
|
yield { blob: new Blob(["x"]), filename: "same.txt" };
|
|
}
|
|
}
|
|
expect(() => planBlobDownloads(many(), { maximumFiles: 2 })).toThrow(
|
|
HelperLimitError,
|
|
);
|
|
expect(yielded).toBe(3);
|
|
expect(() => planBlobDownloads([], { maximumFilenameLength: 7 })).toThrow(
|
|
/at least 8/u,
|
|
);
|
|
});
|
|
|
|
it("revokes already-created batch URLs when a click fails", () => {
|
|
const revokeObjectURL = vi.fn();
|
|
const click = vi
|
|
.spyOn(HTMLAnchorElement.prototype, "click")
|
|
.mockImplementationOnce(() => undefined)
|
|
.mockImplementationOnce(() => {
|
|
throw new Error("blocked");
|
|
});
|
|
expect(() =>
|
|
triggerBlobDownloads(
|
|
[
|
|
{ blob: new Blob(["a"]), filename: "a.txt" },
|
|
{ blob: new Blob(["b"]), filename: "b.txt" },
|
|
],
|
|
{
|
|
ownerDocument: document,
|
|
urlApi: {
|
|
createObjectURL: () => `blob:test:${click.mock.calls.length}`,
|
|
revokeObjectURL,
|
|
},
|
|
},
|
|
),
|
|
).toThrow("blocked");
|
|
expect(revokeObjectURL).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe("secure and seeded randomness", () => {
|
|
it("fills secure byte requests in Web Crypto-sized chunks", () => {
|
|
const getRandomValues = vi.fn(<T extends ArrayBufferView>(array: T) => {
|
|
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(7);
|
|
return array;
|
|
});
|
|
const cryptoSource = { getRandomValues } as unknown as Pick<
|
|
Crypto,
|
|
"getRandomValues"
|
|
>;
|
|
expect(secureRandomBytes(70_000, cryptoSource)).toEqual(
|
|
new Uint8Array(70_000).fill(7),
|
|
);
|
|
expect(getRandomValues).toHaveBeenCalledTimes(2);
|
|
expect(() => secureRandomBytes(2, cryptoSource, 1)).toThrow(/exceeds/u);
|
|
});
|
|
|
|
it("uses rejection sampling for bounded secure integers", () => {
|
|
const samples = [0xffffffff, 7];
|
|
const getRandomValues = <T extends ArrayBufferView>(array: T): T => {
|
|
new Uint32Array(array.buffer, array.byteOffset, 1)[0] =
|
|
samples.shift() ?? 0;
|
|
return array;
|
|
};
|
|
expect(secureRandomInt(10, 20, { getRandomValues })).toBe(17);
|
|
});
|
|
|
|
it("produces reproducible seeded primitives without mutating inputs", () => {
|
|
const first = createSeededRandom("repeatable");
|
|
const second = createSeededRandom("repeatable");
|
|
expect(Array.from({ length: 8 }, () => first.nextUint32())).toEqual(
|
|
Array.from({ length: 8 }, () => second.nextUint32()),
|
|
);
|
|
const input = [1, 2, 3, 4, 5];
|
|
const shuffled = shuffleSeeded(input, 42);
|
|
expect(shuffled).toEqual(shuffleSeeded(input, 42));
|
|
expect(shuffled).not.toEqual(input);
|
|
expect(input).toEqual([1, 2, 3, 4, 5]);
|
|
expect(createSeededRandom("bytes").bytes(7)).toHaveLength(7);
|
|
const value = createSeededRandom("float").nextFloat();
|
|
expect(value).toBeGreaterThanOrEqual(0);
|
|
expect(value).toBeLessThan(1);
|
|
});
|
|
});
|