Release Helper Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const APP_ORIGIN = "http://127.0.0.1:4173";
|
||||
|
||||
async function keepNetworkLocal(page: Page): Promise<string[]> {
|
||||
const externalRequests: string[] = [];
|
||||
await page.route("**/*", async (route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
if (requestUrl.origin !== APP_ORIGIN) {
|
||||
externalRequests.push(requestUrl.href);
|
||||
await route.abort();
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
return externalRequests;
|
||||
}
|
||||
|
||||
function recordRuntimeErrors(page: Page): string[] {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
test("encodes text and navigates the local workspaces", async ({ page }) => {
|
||||
const runtimeErrors = recordRuntimeErrors(page);
|
||||
const externalRequests = await keepNetworkLocal(page);
|
||||
await page.goto("/deep/nested/helpers/");
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Useful values, without a round trip" }),
|
||||
).toBeVisible();
|
||||
await page.getByLabel("Text", { exact: true }).fill("local ✓");
|
||||
const encodeCard = page
|
||||
.getByRole("heading", { name: "Encode text" })
|
||||
.locator("..");
|
||||
await expect(encodeCard).toContainText("bG9jYWwg4pyT");
|
||||
await page.getByLabel("URL component", { exact: true }).fill("a b/✓");
|
||||
await expect(page.getByText("a%20b%2F%E2%9C%93")).toBeVisible();
|
||||
await page.getByRole("tab", { name: /^Text & Unicode/u }).click();
|
||||
await expect(page).toHaveURL(/#text$/u);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Text & Unicode" }),
|
||||
).toBeVisible();
|
||||
|
||||
expect(externalRequests).toEqual([]);
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("calculates network, structured data and seeded values", async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtimeErrors = recordRuntimeErrors(page);
|
||||
const externalRequests = await keepNetworkLocal(page);
|
||||
await page.goto("/deep/nested/helpers/#network");
|
||||
|
||||
await expect(page.getByText("2001:db8::/64")).toBeVisible();
|
||||
await page.getByLabel("IPv4 or IPv6 CIDR").fill("192.0.2.99/24");
|
||||
await page.getByLabel("Address to test").fill("192.0.3.1");
|
||||
await expect(page.getByText("192.0.2.0/24")).toBeVisible();
|
||||
await expect(page.getByText("No", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole("tab", { name: /^Structured/u }).click();
|
||||
await page.getByLabel("JSON input").fill('{"z":1,"a":2}');
|
||||
await expect(page.getByText(/"a": 2/u)).toBeVisible();
|
||||
await page.getByLabel("CSV input").fill('name,note\nalpha,"a,b"');
|
||||
await expect(
|
||||
page.getByText("Rows", { exact: true }).locator(".."),
|
||||
).toContainText("2");
|
||||
|
||||
await page.getByRole("tab", { name: /^Random/u }).click();
|
||||
await page.getByLabel("Byte count (maximum 4096 here)").fill("8");
|
||||
const generate = page.getByRole("button", { name: "Generate seeded bytes" });
|
||||
await generate.click();
|
||||
const output = page.locator("output").filter({ hasText: /^[0-9a-f]{16}$/u });
|
||||
const first = await output.textContent();
|
||||
await generate.click();
|
||||
await expect(output).toHaveText(first ?? "");
|
||||
|
||||
expect(externalRequests).toEqual([]);
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("produces browser digests without clearing the previous result", async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtimeErrors = recordRuntimeErrors(page);
|
||||
const externalRequests = await keepNetworkLocal(page);
|
||||
await page.goto("/deep/nested/helpers/#checksums");
|
||||
|
||||
await expect(page.getByText("cbf43926")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
"15e2b0d3c33891ebb0f1ef609ec419420c20e320ce94c65fbc8c3312448eb225",
|
||||
),
|
||||
).toBeVisible();
|
||||
await page.getByLabel("UTF-8 input").fill("abc");
|
||||
await expect(
|
||||
page.getByText(
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
expect(externalRequests).toEqual([]);
|
||||
expect(runtimeErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test("serves a relocatable production artifact with hardened headers", async ({
|
||||
request,
|
||||
}) => {
|
||||
const index = await request.get("/deep/nested/helpers/");
|
||||
expect(index.ok()).toBe(true);
|
||||
expect(index.headers()["content-security-policy"]).toContain(
|
||||
"default-src 'self'",
|
||||
);
|
||||
expect(index.headers()["permissions-policy"]).toContain("geolocation=()");
|
||||
expect(index.headers()["x-content-type-options"]).toBe("nosniff");
|
||||
expect(await index.text()).not.toMatch(/\b(?:src|href)=["']\//u);
|
||||
|
||||
const manifest = await request.get("/deep/nested/helpers/toolbox-app.json");
|
||||
expect(manifest.headers()["content-type"]).toContain("application/json");
|
||||
await expect(manifest.json()).resolves.toMatchObject({
|
||||
id: "de.add-ideas.helper-tools",
|
||||
version: "0.1.0",
|
||||
entry: "./",
|
||||
icon: "./favicon.svg",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { Workbench } from "../../src/components/Workbench";
|
||||
|
||||
describe("Helper Tools workbench", () => {
|
||||
beforeEach(() => {
|
||||
history.replaceState(null, "", "/");
|
||||
});
|
||||
|
||||
it("exposes all workspaces and performs strict text encoding", () => {
|
||||
render(<Workbench />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "Useful values, without a round trip",
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
within(
|
||||
screen.getByRole("tablist", { name: "Helper workspaces" }),
|
||||
).getAllByRole("tab"),
|
||||
).toHaveLength(8);
|
||||
const encodeCard = screen
|
||||
.getByRole("heading", { name: "Encode text" })
|
||||
.closest("section");
|
||||
expect(encodeCard).not.toBeNull();
|
||||
expect(
|
||||
within(encodeCard!).getAllByText("SGVsbG8sIGxvY2FsIHRvb2xib3gh"),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("supports direct hashes and canonicalizes an IPv6 CIDR", () => {
|
||||
history.replaceState(null, "", "/#network");
|
||||
render(<Workbench />);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Network" })).toBeVisible();
|
||||
const canonical = screen.getByText("Canonical").parentElement;
|
||||
expect(canonical).not.toBeNull();
|
||||
expect(within(canonical!).getByText("2001:db8::/64")).toBeVisible();
|
||||
expect(screen.getByText("Yes")).toBeVisible();
|
||||
});
|
||||
|
||||
it("moves between workspace tabs with the keyboard", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
const encoding = screen.getByRole("tab", { name: /^Encoding/u });
|
||||
encoding.focus();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
|
||||
expect(
|
||||
screen.getByRole("tab", { name: /^Text & Unicode/u }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
expect(location.hash).toBe("#text");
|
||||
});
|
||||
|
||||
it("keeps unsafe JSON inert and reports the rejected key", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
await user.click(screen.getByRole("tab", { name: /^Structured/u }));
|
||||
const input = screen.getByLabelText("JSON input");
|
||||
fireEvent.change(input, {
|
||||
target: { value: '{"__proto__":{"polluted":true}}' },
|
||||
});
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(/dangerous.*key/iu);
|
||||
expect(({} as { polluted?: boolean }).polluted).toBeUndefined();
|
||||
});
|
||||
|
||||
it("repeats seeded random output and labels it as non-secure", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
await user.click(screen.getByRole("tab", { name: /^Random/u }));
|
||||
const generate = screen.getByRole("button", {
|
||||
name: "Generate seeded bytes",
|
||||
});
|
||||
await user.click(generate);
|
||||
const first = screen.getByText("Seeded hexadecimal (not secure)")
|
||||
.parentElement?.nextElementSibling?.textContent;
|
||||
await user.click(generate);
|
||||
const second = screen.getByText("Seeded hexadecimal (not secure)")
|
||||
.parentElement?.nextElementSibling?.textContent;
|
||||
|
||||
expect(first).toMatch(/^[0-9a-f]{32}$/u);
|
||||
expect(second).toBe(first);
|
||||
expect(screen.getByText(/must never be used for tokens/u)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
adler32,
|
||||
cidrContains,
|
||||
digestHex,
|
||||
encodeText,
|
||||
fnv1a32,
|
||||
formatChecksum,
|
||||
formatIpv4,
|
||||
formatIpv6,
|
||||
parseCidr,
|
||||
parseIpAddress,
|
||||
parseIpv4,
|
||||
parseIpv6,
|
||||
crc32,
|
||||
} from "../../src/helpers";
|
||||
|
||||
describe("checksums and cryptographic digests", () => {
|
||||
it("matches published checksum vectors", async () => {
|
||||
const input = encodeText("123456789");
|
||||
expect(formatChecksum(crc32(input))).toBe("cbf43926");
|
||||
expect(formatChecksum(adler32(input))).toBe("091e01de");
|
||||
expect(formatChecksum(fnv1a32(encodeText("hello")))).toBe("4f9f2cab");
|
||||
await expect(digestHex(encodeText("abc"), "SHA-256")).resolves.toBe(
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies byte ceilings before digest work", async () => {
|
||||
expect(() => crc32(new Uint8Array(2), 1)).toThrow(/limit/u);
|
||||
await expect(digestHex(new Uint8Array(2), "SHA-256", 1)).rejects.toThrow(
|
||||
/limit/u,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IPv4, IPv6, and CIDR", () => {
|
||||
it("parses strict IPv4 and canonical IPv6", () => {
|
||||
expect(parseIpv4("192.0.2.1")).toMatchObject({
|
||||
version: 4,
|
||||
canonical: "192.0.2.1",
|
||||
value: 3221225985n,
|
||||
});
|
||||
expect(formatIpv4(parseIpv4("203.0.113.4").bytes)).toBe("203.0.113.4");
|
||||
expect(parseIpv6("2001:0db8:0:0:0:ff00:0042:8329").canonical).toBe(
|
||||
"2001:db8::ff00:42:8329",
|
||||
);
|
||||
expect(parseIpv6("::ffff:192.0.2.128").canonical).toBe("::ffff:c000:280");
|
||||
expect(formatIpv6(1n)).toBe("::1");
|
||||
expect(parseIpAddress("::1").version).toBe(6);
|
||||
});
|
||||
|
||||
it("computes exact network ranges and containment", () => {
|
||||
const ipv4 = parseCidr("192.0.2.129/25");
|
||||
expect(ipv4).toMatchObject({
|
||||
canonical: "192.0.2.128/25",
|
||||
size: 128n,
|
||||
});
|
||||
expect(ipv4.first.canonical).toBe("192.0.2.128");
|
||||
expect(ipv4.last.canonical).toBe("192.0.2.255");
|
||||
expect(ipv4.broadcast?.canonical).toBe("192.0.2.255");
|
||||
expect(cidrContains(ipv4, "192.0.2.200")).toBe(true);
|
||||
expect(cidrContains(ipv4, "192.0.3.1")).toBe(false);
|
||||
const ipv6 = parseCidr("2001:db8::1/126");
|
||||
expect(ipv6.canonical).toBe("2001:db8::/126");
|
||||
expect(ipv6.last.canonical).toBe("2001:db8::3");
|
||||
expect(cidrContains(ipv6, "2001:db8::2")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects ambiguous and malformed addresses", () => {
|
||||
expect(() => parseIpv4("127.00.0.1")).toThrow(/octet/u);
|
||||
expect(() => parseIpv4("256.0.0.1")).toThrow(/255/u);
|
||||
expect(() => parseIpv6("1::2::3")).toThrow(/IPv6/u);
|
||||
expect(() => parseIpv6("fe80::1%eth0")).toThrow(/scoped/u);
|
||||
expect(() => parseIpv6("::192.0.2.1:192.0.2.2")).toThrow(/final/u);
|
||||
expect(() => parseCidr("192.0.2.1/33")).toThrow(/32/u);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
base64ToBytes,
|
||||
base64UrlToBytes,
|
||||
bytesToBase64,
|
||||
bytesToBase64Url,
|
||||
bytesToHex,
|
||||
convertLineEndings,
|
||||
decodeText,
|
||||
decodeUrlComponent,
|
||||
encodeText,
|
||||
encodeUrlComponent,
|
||||
fromCodePoints,
|
||||
hexToBytes,
|
||||
inspectCodePoints,
|
||||
inspectUrl,
|
||||
normalizeUnicode,
|
||||
parseFormEncoded,
|
||||
segmentGraphemes,
|
||||
stringifyFormEncoded,
|
||||
transformCase,
|
||||
} from "../../src/helpers";
|
||||
|
||||
describe("binary and text encoding", () => {
|
||||
it("round-trips canonical Base64, Base64URL, hex, and text encodings", () => {
|
||||
const bytes = encodeText("Hello, 🌍", "utf-8");
|
||||
expect(bytesToBase64(bytes)).toBe("SGVsbG8sIPCfjI0=");
|
||||
expect(decodeText(base64ToBytes("SGVsbG8sIPCfjI0="))).toBe("Hello, 🌍");
|
||||
expect(bytesToBase64Url(new Uint8Array([251, 255]), false)).toBe("-_8");
|
||||
expect(base64UrlToBytes("-_8")).toEqual(new Uint8Array([251, 255]));
|
||||
expect(base64UrlToBytes("Zg==")).toEqual(new Uint8Array([102]));
|
||||
expect(bytesToHex(bytes).startsWith("48656c6c6f")).toBe(true);
|
||||
expect(hexToBytes("0x00 ff", { allowWhitespace: true })).toEqual(
|
||||
new Uint8Array([0, 255]),
|
||||
);
|
||||
expect(decodeText(encodeText("Aé", "latin1"), "latin1")).toBe("Aé");
|
||||
expect(decodeText(encodeText("A🌍", "utf-16le"), "utf-16le")).toBe("A🌍");
|
||||
expect(decodeText(encodeText("A🌍", "utf-16be"), "utf-16be")).toBe("A🌍");
|
||||
});
|
||||
|
||||
it("rejects ambiguous, malformed, and oversized encodings", () => {
|
||||
expect(() => base64ToBytes("Zg")).toThrow(/padding/u);
|
||||
expect(() => base64ToBytes("Zh==")).toThrow(/unused|canonical/u);
|
||||
expect(() => base64UrlToBytes("+w")).toThrow(/Invalid/u);
|
||||
expect(() => base64UrlToBytes("Zg=")).toThrow(/padding/u);
|
||||
expect(() => base64ToBytes("Z g==")).toThrow(/whitespace/u);
|
||||
expect(() => hexToBytes("abc")).toThrow(/pairs/u);
|
||||
expect(() => hexToBytes("ffff", { maxOutputBytes: 1 })).toThrow(/limit/u);
|
||||
expect(() => encodeText("€", "latin1")).toThrow(/Latin-1/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL, Unicode, case, and line helpers", () => {
|
||||
it("handles URL components and repeated form fields without object coercion", () => {
|
||||
expect(decodeUrlComponent(encodeUrlComponent("a b/✓"))).toBe("a b/✓");
|
||||
const entries = parseFormEncoded("tag=one&tag=two+words&empty=");
|
||||
expect(entries).toEqual([
|
||||
["tag", "one"],
|
||||
["tag", "two words"],
|
||||
["empty", ""],
|
||||
]);
|
||||
expect(stringifyFormEncoded(entries)).toBe("tag=one&tag=two+words&empty=");
|
||||
expect(
|
||||
inspectUrl("../a?q=1#x", "https://example.test/base/"),
|
||||
).toMatchObject({
|
||||
href: "https://example.test/a?q=1#x",
|
||||
protocol: "https:",
|
||||
hostname: "example.test",
|
||||
passwordPresent: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("inspects scalar values and user-perceived graphemes", () => {
|
||||
const points = inspectCodePoints("A🌍");
|
||||
expect(points).toEqual([
|
||||
{
|
||||
character: "A",
|
||||
codePoint: 65,
|
||||
hex: "U+0041",
|
||||
utf16Index: 0,
|
||||
utf16Length: 1,
|
||||
},
|
||||
{
|
||||
character: "🌍",
|
||||
codePoint: 0x1f30d,
|
||||
hex: "U+1F30D",
|
||||
utf16Index: 1,
|
||||
utf16Length: 2,
|
||||
},
|
||||
]);
|
||||
expect(fromCodePoints(points.map((point) => point.codePoint))).toBe("A🌍");
|
||||
expect(segmentGraphemes("👨👩👧👦é")).toHaveLength(2);
|
||||
expect(normalizeUnicode("e\u0301", "NFC")).toBe("é");
|
||||
expect(() => fromCodePoints([0xd800])).toThrow(/scalar/u);
|
||||
expect(fromCodePoints(new Array(100_000).fill(0x61))).toHaveLength(100_000);
|
||||
});
|
||||
|
||||
it("transforms case and line endings deterministically", () => {
|
||||
expect(transformCase("XML http value", "camel")).toBe("xmlHttpValue");
|
||||
expect(transformCase("hello-world", "pascal")).toBe("HelloWorld");
|
||||
expect(transformCase("helloWorld", "snake")).toBe("hello_world");
|
||||
expect(convertLineEndings("a\r\nb\rc\n", "lf")).toBe("a\nb\nc\n");
|
||||
expect(convertLineEndings("a\nb", "crlf")).toBe("a\r\nb");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
convertDataUnit,
|
||||
convertNumberBase,
|
||||
convertUnit,
|
||||
formatBigIntRadix,
|
||||
parseBigIntRadix,
|
||||
unitDimension,
|
||||
} from "../../src/helpers";
|
||||
|
||||
describe("BigInt number bases", () => {
|
||||
it("parses and formats exact large integers from base 2 through 36", () => {
|
||||
expect(parseBigIntRadix("-0xFF_FF", 16)).toBe(-65_535n);
|
||||
expect(convertNumberBase("11111111", 2, 16, { uppercase: true })).toBe(
|
||||
"FF",
|
||||
);
|
||||
expect(
|
||||
formatBigIntRadix(0x1234abcdn, 16, { prefix: true, groupSize: 4 }),
|
||||
).toBe("0x1234_abcd");
|
||||
expect(parseBigIntRadix("zz", 36)).toBe(1295n);
|
||||
});
|
||||
|
||||
it("rejects invalid radices, digits, separators, and digit bombs", () => {
|
||||
expect(() => parseBigIntRadix("2", 2)).toThrow(/Digit/u);
|
||||
expect(() => parseBigIntRadix("10", 1)).toThrow(/Radix/u);
|
||||
expect(() => parseBigIntRadix("_1", 10)).toThrow(/separator/u);
|
||||
expect(() => parseBigIntRadix("1234", 10, { maxDigits: 3 })).toThrow(
|
||||
/exceeds/u,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unit conversion", () => {
|
||||
it("distinguishes decimal and binary data units", () => {
|
||||
expect(convertDataUnit(1, "MiB", "B")).toBe(1_048_576);
|
||||
expect(convertDataUnit(1, "MB", "B")).toBe(1_000_000);
|
||||
expect(convertDataUnit(8, "b", "B")).toBe(1);
|
||||
expect(() => convertDataUnit(Number.MAX_VALUE, "TB", "b")).toThrow(
|
||||
/finite number range/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("converts compatible physical units and affine temperatures", () => {
|
||||
expect(convertUnit(1, "mi", "km")).toBeCloseTo(1.609344, 12);
|
||||
expect(convertUnit(1, "lb", "kg")).toBeCloseTo(0.45359237, 12);
|
||||
expect(convertUnit(32, "F", "C")).toBeCloseTo(0, 12);
|
||||
expect(convertUnit(0, "C", "K")).toBeCloseTo(273.15, 12);
|
||||
expect(convertUnit(2, "week", "day")).toBe(14);
|
||||
expect(unitDimension("cm")).toBe("length");
|
||||
expect(() => convertUnit(1, "m", "kg")).toThrow(/Cannot convert/u);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
HelperLimitError,
|
||||
assertBoundedBytes,
|
||||
assertBoundedItems,
|
||||
assertBoundedText,
|
||||
createObjectUrlLease,
|
||||
createSeededRandom,
|
||||
sanitizeDownloadFilename,
|
||||
secureRandomBytes,
|
||||
secureRandomInt,
|
||||
shuffleSeeded,
|
||||
triggerBlobDownload,
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
formatDuration,
|
||||
formatTimestamp,
|
||||
parseCsv,
|
||||
parseDuration,
|
||||
parseTimestamp,
|
||||
safeJsonParse,
|
||||
stableStringify,
|
||||
stringifyCsv,
|
||||
} from "../../src/helpers";
|
||||
|
||||
describe("bounded structured data", () => {
|
||||
it("parses hardened JSON and serializes deterministic key order", () => {
|
||||
expect(safeJsonParse('{"b":2,"a":[true,null]}')).toEqual({
|
||||
b: 2,
|
||||
a: [true, null],
|
||||
});
|
||||
expect(stableStringify({ z: 1, a: { d: 4, c: 3 } }, 2)).toBe(
|
||||
'{\n "a": {\n "c": 3,\n "d": 4\n },\n "z": 1\n}',
|
||||
);
|
||||
expect(() => safeJsonParse('{"__proto__":{"polluted":true}}')).toThrow(
|
||||
/Dangerous/u,
|
||||
);
|
||||
expect(() => safeJsonParse("[[[0]]]", { maxDepth: 2 })).toThrow(/depth/u);
|
||||
expect(() => safeJsonParse("[1,2,3]", { maxNodes: 3 })).toThrow(/node/u);
|
||||
const cycle: Record<string, unknown> = {};
|
||||
cycle.self = cycle;
|
||||
expect(() => stableStringify(cycle)).toThrow(/cyclic/u);
|
||||
expect(() => stableStringify({ value: 1n })).toThrow(/BigInt/u);
|
||||
});
|
||||
|
||||
it("handles quoted CSV, embedded newlines, and round trips", () => {
|
||||
const rows = [
|
||||
["name", "note"],
|
||||
["Ada", 'comma, quote " and\nnewline'],
|
||||
["", "last"],
|
||||
];
|
||||
const csv = stringifyCsv(rows);
|
||||
expect(csv).toContain('"comma, quote "" and\nnewline"');
|
||||
expect(parseCsv(csv)).toEqual(rows);
|
||||
expect(parseCsv("a;b\r\n1;2", { delimiter: ";" })).toEqual([
|
||||
["a", "b"],
|
||||
["1", "2"],
|
||||
]);
|
||||
expect(() => parseCsv('"unterminated')).toThrow(/unterminated/u);
|
||||
expect(() => parseCsv("a,b,c", { maxColumns: 2 })).toThrow(/column/u);
|
||||
});
|
||||
});
|
||||
|
||||
describe("timestamps and durations", () => {
|
||||
it("normalizes ISO and Unix timestamps", () => {
|
||||
expect(parseTimestamp(0, "seconds")).toMatchObject({
|
||||
epochMilliseconds: 0,
|
||||
epochSeconds: 0,
|
||||
iso: "1970-01-01T00:00:00.000Z",
|
||||
});
|
||||
expect(parseTimestamp("2000-01-01T00:00:00Z").epochSeconds).toBe(946684800);
|
||||
expect(formatTimestamp(0, "UTC")).toMatch(/1970/u);
|
||||
expect(() => parseTimestamp("not a date")).toThrow(/range/u);
|
||||
});
|
||||
|
||||
it("parses ISO, clock, and token durations and formats them", () => {
|
||||
expect(parseDuration("P1DT2H3M4.5S")).toBe(93_784_500);
|
||||
expect(parseDuration("01:02:03.004")).toBe(3_723_004);
|
||||
expect(parseDuration("2h 30m")).toBe(9_000_000);
|
||||
expect(parseDuration("-2h 30m")).toBe(-9_000_000);
|
||||
expect(parseDuration(formatDuration(-9_000_000, "human"))).toBe(-9_000_000);
|
||||
expect(formatDuration(93_784_500, "iso")).toBe("P1DT2H3M4.5S");
|
||||
expect(formatDuration(3_723_004, "clock")).toBe("01:02:03.004");
|
||||
expect(formatDuration(9_000_000, "human")).toBe("2h 30m");
|
||||
expect(formatDuration(86_400_000, "iso")).toBe("P1D");
|
||||
expect(formatDuration(59_999.6, "clock")).toBe("00:01:00.000");
|
||||
expect(() => parseDuration("1 month")).toThrow(/Duration/u);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user