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");
+ });
+});