diff --git a/lib/share/migration.test.ts b/lib/share/migration.test.ts new file mode 100644 index 0000000..70f3767 --- /dev/null +++ b/lib/share/migration.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; + +describe("share migration", () => { + test("backfills Discord IDs before creating the unique index", async () => { + const migration = await Bun.file( + new URL("../../drizzle/0018_authenticated_share.sql", import.meta.url) + ).text(); + const backfill = migration.indexOf('SET "discord_id"'); + const uniqueIndex = migration.indexOf( + 'CREATE UNIQUE INDEX "user_discord_id_unique"' + ); + + expect(backfill).toBeGreaterThan(-1); + expect(uniqueIndex).toBeGreaterThan(backfill); + expect(migration).toContain('CREATE TABLE "share"."text"'); + expect(migration).toContain('CREATE TABLE "share"."comment"'); + expect(migration).toContain("ON DELETE cascade"); + }); +}); diff --git a/lib/share/validation.test.ts b/lib/share/validation.test.ts new file mode 100644 index 0000000..206eb57 --- /dev/null +++ b/lib/share/validation.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; +import { createShareId } from "@/db/schema/share"; +import { ShareHttpError } from "@/lib/share/http-error"; +import { + MAX_SHARE_COMMENT_LENGTH, + MAX_SHARE_DESCRIPTION_LENGTH, + MAX_SHARE_TEXT_BYTES, +} from "@/lib/share/limits"; +import { + validateOptionalImage, + validateShareComment, + validateShareDescription, + validateShareText, + validateTextFile, +} from "@/lib/share/validation"; + +describe("share validation", () => { + test("preserves exact text while rejecting blank content", () => { + const content = " first line\r\n\tsecond line \n"; + expect(validateShareText(content)).toBe(content); + + try { + validateShareText(" \n\t "); + throw new Error("Expected blank text to fail"); + } catch (error) { + expect(error).toBeInstanceOf(ShareHttpError); + expect((error as ShareHttpError).status).toBe(422); + } + }); + + test("measures the text limit in UTF-8 bytes", () => { + expect(validateShareText("a".repeat(MAX_SHARE_TEXT_BYTES))).toHaveLength( + MAX_SHARE_TEXT_BYTES + ); + expect(() => validateShareText("ก".repeat(Math.ceil(MAX_SHARE_TEXT_BYTES / 3) + 1))) + .toThrow("5 MiB"); + }); + + test("enforces description and comment character limits", () => { + expect(validateShareDescription("")).toBeNull(); + expect(validateShareDescription("d".repeat(MAX_SHARE_DESCRIPTION_LENGTH))) + .toHaveLength(MAX_SHARE_DESCRIPTION_LENGTH); + expect(() => + validateShareDescription("d".repeat(MAX_SHARE_DESCRIPTION_LENGTH + 1)) + ).toThrow("5,000"); + + expect(validateShareComment(" comment ")).toBe(" comment "); + expect(() => validateShareComment(" ")).toThrow("blank"); + expect(() => + validateShareComment("c".repeat(MAX_SHARE_COMMENT_LENGTH + 1)) + ).toThrow("2,000"); + }); + + test("accepts only non-empty UTF-8 .txt files", async () => { + const content = "สวัสดี\nexact whitespace "; + await expect( + validateTextFile(new File([content], "message.txt", { type: "text/plain" })) + ).resolves.toBe(content); + await expect( + validateTextFile(new File([content], "message.log", { type: "text/plain" })) + ).rejects.toMatchObject({ status: 415 }); + await expect( + validateTextFile( + new File([new Uint8Array([0xc3, 0x28])], "broken.txt", { + type: "text/plain", + }) + ) + ).rejects.toMatchObject({ status: 422 }); + }); + + test("validates image contents instead of trusting the MIME type", async () => { + const pngHeader = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); + await expect( + validateOptionalImage( + new File([pngHeader], "image.png", { type: "image/png" }) + ) + ).resolves.toMatchObject({ type: "image/png", size: pngHeader.length }); + await expect( + validateOptionalImage( + new File(["not an image"], "image.png", { type: "image/png" }) + ) + ).rejects.toMatchObject({ status: 415 }); + }); +}); + +describe("share IDs", () => { + test("creates collision-resistant eight-character public IDs", () => { + const ids = new Set(Array.from({ length: 1_000 }, createShareId)); + expect(ids.size).toBe(1_000); + for (const id of ids) expect(id).toMatch(/^[a-z0-9]{8}$/); + }); +});