From d7c8d056a44e917fd06b409119f3cda5af874425 Mon Sep 17 00:00:00 2001 From: Juliet Date: Tue, 19 May 2026 22:13:14 +0200 Subject: [PATCH] add tests --- package.json | 5 +- tests/file.test.ts | 136 +++++++++++++++++++++++++++++++++++++ tsconfig.json | 2 +- web/src/lib/crypto.test.ts | 84 +++++++++++++++++++++++ web/src/lib/utils.test.ts | 35 ++++++++++ 5 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 tests/file.test.ts create mode 100644 web/src/lib/crypto.test.ts create mode 100644 web/src/lib/utils.test.ts diff --git a/package.json b/package.json index 9fcb000..f295d20 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,10 @@ "install:all": "bun install && bun install --cwd web", "dev": "bun run install:all && bun run --cwd web dev & bun run --hot src/index.ts", "build": "bun run --cwd web build", - "start": "bun run install:all && bun run build && bun run src/index.ts" + "start": "bun run install:all && bun run build && bun run src/index.ts", + "test": "bun test", + "test:coverage": "bun test --coverage", + "typecheck": "bunx tsc --noEmit && bunx tsc --noEmit --project web/tsconfig.json" }, "dependencies": { "hono": "^4.12.19" diff --git a/tests/file.test.ts b/tests/file.test.ts new file mode 100644 index 0000000..7c4edb2 --- /dev/null +++ b/tests/file.test.ts @@ -0,0 +1,136 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +const dataDir = mkdtempSync(join(tmpdir(), "drop-test-")); +process.env.DATA_DIR = dataDir; +process.env.MAX_FILE_SIZE = "64"; +process.env.MAX_TTL = "1h"; + +const { default: fileRoute } = await import("../src/file.ts"); +const { cleanExpired, createFile, getFile, peekFile } = await import("../src/db.ts"); + +afterAll(() => { + rmSync(dataDir, { recursive: true, force: true }); +}); + +function uploadForm( + contents: string | Uint8Array, + options: { expiresIn?: string; burnAfterRead?: boolean; name?: string } = {}, +) { + const form = new FormData(); + form.set("file", new File([contents], options.name ?? "note.txt")); + if (options.expiresIn !== undefined) form.set("expiresIn", options.expiresIn); + if (options.burnAfterRead !== undefined) { + form.set("burnAfterRead", String(options.burnAfterRead)); + } + return form; +} + +async function upload(contents: string, options: { burnAfterRead?: boolean } = {}) { + const response = await fileRoute.request("/", { + method: "POST", + body: uploadForm(contents, { expiresIn: "5m", ...options }), + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { id: string }; + expect(body.id).toBeTruthy(); + return body.id; +} + +describe("file route", () => { + test("uploads a file, exposes metadata, and downloads the bytes", async () => { + const id = await upload("hello drop"); + + const infoResponse = await fileRoute.request(`/${id}/info`); + expect(infoResponse.status).toBe(200); + expect(await infoResponse.json()).toMatchObject({ + id, + burnAfterRead: false, + size: 10, + }); + + const downloadResponse = await fileRoute.request(`/${id}`); + expect(downloadResponse.status).toBe(200); + expect(downloadResponse.headers.get("Content-Type")).toBe("application/octet-stream"); + expect(downloadResponse.headers.get("Content-Length")).toBe("10"); + expect(await downloadResponse.text()).toBe("hello drop"); + + expect((await fileRoute.request(`/${id}/info`)).status).toBe(200); + }); + + test("burn-after-read files are unavailable after the first download", async () => { + const id = await upload("secret", { burnAfterRead: true }); + + const firstDownload = await fileRoute.request(`/${id}`); + expect(firstDownload.status).toBe(200); + expect(await firstDownload.text()).toBe("secret"); + + expect((await fileRoute.request(`/${id}/info`)).status).toBe(404); + expect((await fileRoute.request(`/${id}`)).status).toBe(404); + }); + + test("rejects uploads without a file", async () => { + const response = await fileRoute.request("/", { + method: "POST", + body: new FormData(), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "file field is required" }); + }); + + test("rejects uploads above the configured size limit", async () => { + const response = await fileRoute.request("/", { + method: "POST", + body: uploadForm(new Uint8Array(65), { expiresIn: "5m" }), + }); + + expect(response.status).toBe(413); + expect(await response.json()).toEqual({ error: "File too large" }); + }); + + test("rejects invalid and excessive lifetimes", async () => { + const invalidResponse = await fileRoute.request("/", { + method: "POST", + body: uploadForm("hello", { expiresIn: "15x" }), + }); + expect(invalidResponse.status).toBe(400); + expect(await invalidResponse.json()).toEqual({ + error: "Invalid lifetime. Use a duration like 30m, 24h, 7d", + }); + + const excessiveResponse = await fileRoute.request("/", { + method: "POST", + body: uploadForm("hello", { expiresIn: "2h" }), + }); + expect(excessiveResponse.status).toBe(400); + expect(await excessiveResponse.json()).toEqual({ + error: "expiresIn exceeds maximum allowed TTL", + }); + }); +}); + +describe("file database", () => { + test("treats expired rows as unavailable and cleans up expired files", async () => { + const now = Math.floor(Date.now() / 1000); + const lookupId = `expired-lookup-${crypto.randomUUID()}`; + const cleanupId = `expired-cleanup-${crypto.randomUUID()}`; + const cleanupPath = join(dataDir, "files", cleanupId); + + createFile(lookupId, now - 1, false); + expect(peekFile(lookupId)).toBeNull(); + expect(getFile(lookupId)).toBeNull(); + expect(peekFile(lookupId)).toBeNull(); + + await Bun.write(cleanupPath, "stale"); + createFile(cleanupId, now - 1, false); + + cleanExpired(); + + expect(peekFile(cleanupId)).toBeNull(); + expect(existsSync(cleanupPath)).toBe(false); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 5d0724d..90214b3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,5 +21,5 @@ "noUncheckedIndexedAccess": true, "noImplicitOverride": true }, - "include": ["src"] + "include": ["src", "tests"] } diff --git a/web/src/lib/crypto.test.ts b/web/src/lib/crypto.test.ts new file mode 100644 index 0000000..c719a26 --- /dev/null +++ b/web/src/lib/crypto.test.ts @@ -0,0 +1,84 @@ +/// + +import { describe, expect, test } from "bun:test"; + +import { decrypt, encrypt, encryptFiles, generateKey, importKey } from "./crypto"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function bytes(text: string) { + return encoder.encode(text).buffer; +} + +function text(data: Uint8Array) { + return decoder.decode(data); +} + +async function encryptPlaintext(plaintext: Uint8Array, key: CryptoKey) { + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: new Uint8Array(12) }, + key, + plaintext, + ); + return new Uint8Array(ciphertext); +} + +describe("crypto helpers", () => { + test("generates an importable AES key", async () => { + const generated = await generateKey(); + const imported = await importKey(generated.encoded); + const ciphertext = await encrypt("hello.txt", bytes("hello"), imported); + + const decrypted = await decrypt(ciphertext, generated.key); + expect(decrypted.files).toHaveLength(1); + expect(decrypted.files[0]).toMatchObject({ fileName: "hello.txt" }); + expect(text(decrypted.files[0]!.fileData)).toBe("hello"); + }); + + test("pads single-file payloads to the fixed block size before encryption", async () => { + const { key } = await generateKey(); + + const shortCiphertext = await encrypt("a.txt", bytes("a"), key); + const longerCiphertext = await encrypt("longer-name.txt", bytes("still small"), key); + + expect(shortCiphertext.byteLength).toBe(4096 + 16); + expect(longerCiphertext.byteLength).toBe(shortCiphertext.byteLength); + }); + + test("round-trips multi-file bundles", async () => { + const { key } = await generateKey(); + + const ciphertext = await encryptFiles( + [ + { fileName: "one.txt", fileBuffer: bytes("first") }, + { fileName: "nested/two.md", fileBuffer: bytes("# second") }, + ], + key, + ); + + const decrypted = await decrypt(ciphertext, key); + expect(decrypted.files.map((file) => file.fileName)).toEqual(["one.txt", "nested/two.md"]); + expect(decrypted.files.map((file) => text(file.fileData))).toEqual(["first", "# second"]); + }); + + test("rejects empty bundles and oversized filenames", async () => { + const { key } = await generateKey(); + + expect(encryptFiles([], key)).rejects.toThrow("No files selected"); + await expect( + encryptFiles([{ fileName: "x".repeat(0x10000), fileBuffer: bytes("body") }], key), + ).rejects.toThrow("Filename too long"); + }); + + test("rejects bundles with an invalid file count", async () => { + const { key } = await generateKey(); + const plaintext = new Uint8Array(12); + plaintext.set(encoder.encode("DROPBND1"), 0); + new DataView(plaintext.buffer).setUint32(8, 1001, false); + + await expect(decrypt(await encryptPlaintext(plaintext, key), key)).rejects.toThrow( + "Too many files in bundle", + ); + }); +}); diff --git a/web/src/lib/utils.test.ts b/web/src/lib/utils.test.ts new file mode 100644 index 0000000..dcefcd8 --- /dev/null +++ b/web/src/lib/utils.test.ts @@ -0,0 +1,35 @@ +/// + +import { afterEach, describe, expect, test } from "bun:test"; + +import { formatBytes, formatExpiry, getExt } from "./utils"; + +const realDateNow = Date.now; + +afterEach(() => { + Date.now = realDateNow; +}); + +describe("utils", () => { + test("extracts lowercase file extensions", () => { + expect(getExt("photo.JPG")).toBe("jpg"); + expect(getExt("archive.tar.gz")).toBe("gz"); + expect(getExt("README")).toBe(""); + }); + + test("formats byte counts for display", () => { + expect(formatBytes(999)).toBe("999 B"); + expect(formatBytes(1530)).toBe("1.5 KB"); + expect(formatBytes(2_500_000)).toBe("2.5 MB"); + expect(formatBytes(1_234_000_000)).toBe("1.23 GB"); + }); + + test("formats expiry relative to the current time", () => { + Date.now = () => 1_700_000_000_000; + + expect(formatExpiry(1_700_000_030)).toBe("expires in less than a minute"); + expect(formatExpiry(1_700_000_120)).toBe("expires in 2 minutes"); + expect(formatExpiry(1_700_003_600)).toBe("expires in 1 hour"); + expect(formatExpiry(1_700_172_800)).toBe("expires in 2 days"); + }); +}); -- 2.51.2