const test = require("node:test"); const assert = require("node:assert/strict"); const crypto = require("node:crypto"); const fs = require("node:fs"); const http = require("node:http"); const os = require("node:os"); const path = require("node:path"); const { Readable } = require("node:stream"); const sharp = require("sharp"); const { createApp } = require("../src/app"); const { loadConfig } = require("../src/config"); const { openDatabase } = require("../src/database"); const { runMigrations } = require("../src/database/migrate"); const { createImageProcessor } = require("../src/image-processing"); const { StorageObjectNotFoundError } = require("../src/storage"); async function readBody(body) { if (Buffer.isBuffer(body)) return body; const chunks = []; for await (const chunk of body) chunks.push(Buffer.from(chunk)); return Buffer.concat(chunks); } function memoryStorage() { const objects = new Map(); return { objects, health() { return { status: "healthy", checkedAt: "2033-05-18T03:33:20.000Z", lastSuccessAt: "2033-05-18T03:33:20.000Z", }; }, async putObject(input) { objects.set(input.key, { body: await readBody(input.body), contentType: input.contentType, metadata: input.metadata, }); return { key: input.key }; }, async getObject(key) { const object = objects.get(key); if (!object) throw new StorageObjectNotFoundError(key); return { body: Readable.from([object.body]), contentLength: object.body.length, contentType: object.contentType, }; }, async deleteObject(key) { objects.delete(key); return { key, deleted: true }; }, }; } async function createHarness(t, { maxFileBytes = 1024 * 1024 } = {}) { const tempDirectory = fs.mkdtempSync( path.join(os.tmpdir(), "announcements-api-uploads-"), ); const config = loadConfig({ NODE_ENV: "test", HOST: "127.0.0.1", PORT: "0", DATABASE_PATH: ":memory:", APP_TIMEZONE: "America/Chicago", S3_ENDPOINT: "http://private-seaweedfs:8333", S3_BUCKET: "announcements-test", UPLOAD_TEMP_DIRECTORY: tempDirectory, UPLOAD_MAX_FILE_BYTES: String(maxFileBytes), UPLOAD_MAX_FILES: "5", IMAGE_MAX_INPUT_PIXELS: "1000000", }); const database = openDatabase({ path: ":memory:" }); const migrations = runMigrations(database); const storage = memoryStorage(); const app = createApp({ config, database, imageProcessor: createImageProcessor({ maxInputPixels: config.processing.maxInputPixels, }), lifecycle: { ready: true }, migrations, storage, }); const server = app.listen(0, "127.0.0.1"); await new Promise((resolve, reject) => { server.once("listening", resolve); server.once("error", reject); }); t.after(async () => { await new Promise((resolve) => server.close(resolve)); database.close(); fs.rmSync(tempDirectory, { recursive: true, force: true }); }); return { baseUrl: `http://127.0.0.1:${server.address().port}`, database, storage, tempDirectory, }; } function appendFile(form, field, buffer, filename, contentType) { form.append(field, new Blob([buffer], { type: contentType }), filename); } async function jsonResponse(baseUrl, route, init) { const response = await fetch(`${baseUrl}${route}`, init); return { response, body: await response.json() }; } async function eventuallyEmpty(directory) { for (let attempt = 0; attempt < 50; attempt += 1) { if (fs.readdirSync(directory).length === 0) return; await new Promise((resolve) => setTimeout(resolve, 10)); } assert.deepEqual(fs.readdirSync(directory), []); } async function eventuallyContainsFile(directory) { for (let attempt = 0; attempt < 50; attempt += 1) { if (fs.readdirSync(directory).length > 0) return; await new Promise((resolve) => setTimeout(resolve, 10)); } assert.fail("The upload did not create its temporary file"); } async function sampleImages() { const source = sharp({ create: { width: 80, height: 60, channels: 4, background: { r: 31, g: 72, b: 120, alpha: 0.5 }, }, }); return { jpeg: await source.clone().jpeg().toBuffer(), png: await source.clone().png().toBuffer(), webp: await source.clone().webp().toBuffer(), }; } test("asset HTTP API accepts JPEG, PNG, and WebP and serves private immutable media", async (t) => { const harness = await createHarness(t); const images = await sampleImages(); const form = new FormData(); appendFile(form, "files", images.jpeg, "welcome.jpg", "image/jpeg"); appendFile(form, "files", images.png, "../../bulletin.png", "image/png"); appendFile(form, "files", images.webp, "events.webp", "image/webp"); const uploaded = await jsonResponse(harness.baseUrl, "/api/assets", { method: "POST", body: form, }); assert.equal(uploaded.response.status, 201); assert.equal(uploaded.body.createdCount, 3); assert.equal(uploaded.body.duplicateCount, 0); assert.equal(uploaded.body.results.length, 3); assert.deepEqual( uploaded.body.results.map((result) => result.asset.original.contentType), ["image/jpeg", "image/png", "image/webp"], ); assert.equal(uploaded.body.results[1].asset.originalFilename, "bulletin.png"); assert.doesNotMatch(JSON.stringify(uploaded.body), /objectKey|object_key|private-seaweedfs/); assert.equal(harness.storage.objects.size, 9); await eventuallyEmpty(harness.tempDirectory); const asset = uploaded.body.results[0].asset; const listed = await jsonResponse(harness.baseUrl, "/api/assets"); assert.equal(listed.response.status, 200); assert.equal(listed.body.assets.length, 3); assert.deepEqual( (await jsonResponse(harness.baseUrl, `/api/assets/${asset.id}`)).body, asset, ); for (const media of [asset.display, asset.thumbnail]) { const response = await fetch(`${harness.baseUrl}${media.url}`); const body = Buffer.from(await response.arrayBuffer()); assert.equal(response.status, 200); assert.equal(response.headers.get("content-type"), "image/webp"); assert.equal( response.headers.get("cache-control"), "public, max-age=31536000, immutable", ); assert.equal(response.headers.get("content-length"), String(body.length)); assert.equal(response.headers.get("etag"), `"${media.sha256}"`); assert.equal(crypto.createHash("sha256").update(body).digest("hex"), media.sha256); } const original = await fetch(`${harness.baseUrl}${asset.original.url}`); assert.equal(original.status, 200); assert.equal(original.headers.get("content-type"), "image/jpeg"); assert.equal(original.headers.get("cache-control"), "private, no-store"); assert.match(original.headers.get("content-disposition"), /^attachment;/); assert.deepEqual(Buffer.from(await original.arrayBuffer()), images.jpeg); const wrongVersion = await jsonResponse( harness.baseUrl, `/media/assets/${asset.id}/display/${"a".repeat(64)}.webp`, ); assert.equal(wrongVersion.response.status, 404); assert.equal(wrongVersion.body.error.code, "MEDIA_NOT_FOUND"); const arbitraryKey = await jsonResponse( harness.baseUrl, `/media/assets/${asset.id}/display/${encodeURIComponent("../../original/key")}.webp`, ); assert.equal(arbitraryKey.response.status, 404); assert.equal(arbitraryKey.body.error.code, "MEDIA_NOT_FOUND"); const displayKey = [...harness.storage.objects.keys()].find( (key) => key.includes(`assets/${asset.id}/display/`), ); harness.storage.objects.delete(displayKey); const missingStoredBytes = await jsonResponse( harness.baseUrl, asset.display.url, ); assert.equal(missingStoredBytes.response.status, 503); assert.equal(missingStoredBytes.body.error.code, "STORAGE_INCONSISTENCY"); assert.equal( (await jsonResponse(harness.baseUrl, "/health/live")).response.status, 200, ); }); test("asset HTTP API reports duplicates, name conflicts, and persists editable metadata", async (t) => { const harness = await createHarness(t); const { png, jpeg } = await sampleImages(); const firstForm = new FormData(); appendFile(firstForm, "file", png, "weekly.png", "application/octet-stream"); firstForm.append("display_name", "Weekly Announcement"); const first = await jsonResponse(harness.baseUrl, "/api/assets", { method: "POST", body: firstForm, }); assert.equal(first.response.status, 201); assert.equal(first.body.created, true); const assetId = first.body.asset.id; const duplicateForm = new FormData(); appendFile(duplicateForm, "file", png, "unrelated-name.png", "image/png"); duplicateForm.append("display_name", "Ignored duplicate proposal"); const duplicate = await jsonResponse(harness.baseUrl, "/api/assets", { method: "POST", body: duplicateForm, }); assert.equal(duplicate.response.status, 200); assert.equal(duplicate.body.created, false); assert.equal(duplicate.body.duplicate, true); assert.equal(duplicate.body.asset.id, assetId); assert.equal(harness.storage.objects.size, 3); const conflictForm = new FormData(); appendFile(conflictForm, "file", jpeg, "different.jpg", "image/jpeg"); conflictForm.append("displayName", "weekly announcement"); const conflict = await jsonResponse(harness.baseUrl, "/api/assets", { method: "POST", body: conflictForm, }); assert.equal(conflict.response.status, 409); assert.equal(conflict.body.error.code, "ASSET_NAME_CONFLICT"); const expiresAt = Date.parse("2034-01-02T03:04:05.000Z"); const edited = await jsonResponse(harness.baseUrl, `/api/assets/${assetId}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ display_name: "Renamed announcement", expiration_at: expiresAt, }), }); assert.equal(edited.response.status, 200); assert.equal(edited.body.displayName, "Renamed announcement"); assert.equal(edited.body.expirationAt, expiresAt); const permanent = await jsonResponse(harness.baseUrl, `/api/assets/${assetId}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ expiration_at: null }), }); assert.equal(permanent.body.expirationAt, null); assert.equal(harness.storage.objects.size, 3); await eventuallyEmpty(harness.tempDirectory); const timestamp = Date.now(); harness.database .prepare( "INSERT INTO playlists (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)", ) .run("api-playlist", "API playlist", timestamp, timestamp); harness.database .prepare( `INSERT INTO playlist_entries (id, playlist_id, asset_id, position, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?)`, ) .run("api-entry", "api-playlist", assetId, timestamp, timestamp); const references = await jsonResponse( harness.baseUrl, `/api/assets/${assetId}/references`, ); assert.equal(references.response.status, 200); assert.equal(references.body.canDelete, false); assert.equal(references.body.playlists[0].playlistEntryId, "api-entry"); const blockedDelete = await jsonResponse( harness.baseUrl, `/api/assets/${assetId}`, { method: "DELETE" }, ); assert.equal(blockedDelete.response.status, 409); assert.equal(blockedDelete.body.error.code, "ASSET_REFERENCED"); assert.equal(harness.storage.objects.size, 3); harness.database .prepare("DELETE FROM playlist_entries WHERE id = ?") .run("api-entry"); const removed = await jsonResponse(harness.baseUrl, `/api/assets/${assetId}`, { method: "DELETE", }); assert.equal(removed.response.status, 200); assert.deepEqual(removed.body, { id: assetId, deleted: true }); assert.equal(harness.storage.objects.size, 0); assert.equal( (await jsonResponse(harness.baseUrl, `/api/assets/${assetId}`)).response.status, 404, ); }); test("upload validation returns structured errors and always cleans temporary files", async (t) => { const harness = await createHarness(t, { maxFileBytes: 1024 }); const malformed = new FormData(); appendFile(malformed, "file", Buffer.from("not an image"), "broken.png", "image/png"); const malformedResult = await jsonResponse(harness.baseUrl, "/api/assets", { method: "POST", body: malformed, }); assert.equal(malformedResult.response.status, 422); assert.equal(malformedResult.body.error.code, "MALFORMED_IMAGE"); await eventuallyEmpty(harness.tempDirectory); const unsupportedBuffer = await sharp({ create: { width: 20, height: 20, channels: 3, background: "red", }, }) .gif() .toBuffer(); const unsupported = new FormData(); appendFile(unsupported, "file", unsupportedBuffer, "still.gif", "image/png"); const unsupportedResult = await jsonResponse(harness.baseUrl, "/api/assets", { method: "POST", body: unsupported, }); assert.equal(unsupportedResult.response.status, 415); assert.equal(unsupportedResult.body.error.code, "UNSUPPORTED_IMAGE_FORMAT"); await eventuallyEmpty(harness.tempDirectory); const oversized = new FormData(); appendFile( oversized, "file", crypto.randomBytes(1025), "too-large.png", "image/png", ); const oversizedResult = await jsonResponse(harness.baseUrl, "/api/assets", { method: "POST", body: oversized, }); assert.equal(oversizedResult.response.status, 413); assert.equal(oversizedResult.body.error.code, "UPLOAD_FILE_TOO_LARGE"); assert.deepEqual(oversizedResult.body.error.details, { maxFileBytes: 1024 }); await eventuallyEmpty(harness.tempDirectory); const tooMany = new FormData(); for (let index = 0; index < 6; index += 1) { appendFile(tooMany, "files", Buffer.from("x"), `${index}.png`, "image/png"); } const tooManyResult = await jsonResponse(harness.baseUrl, "/api/assets", { method: "POST", body: tooMany, }); assert.equal(tooManyResult.response.status, 413); assert.equal(tooManyResult.body.error.code, "UPLOAD_FILE_COUNT_EXCEEDED"); assert.deepEqual(tooManyResult.body.error.details, { maxFiles: 5 }); await eventuallyEmpty(harness.tempDirectory); const absent = await jsonResponse(harness.baseUrl, "/api/assets", { method: "POST", body: new FormData(), }); assert.equal(absent.response.status, 400); assert.equal(absent.body.error.code, "UPLOAD_FILE_REQUIRED"); assert.equal(harness.storage.objects.size, 0); assert.equal( harness.database.prepare("SELECT COUNT(*) AS count FROM assets").get().count, 0, ); }); test("an aborted multipart request removes its in-flight temporary file", async (t) => { const harness = await createHarness(t); const boundary = `announcements-${crypto.randomUUID()}`; const preamble = Buffer.from( `--${boundary}\r\n` + 'Content-Disposition: form-data; name="file"; filename="aborted.png"\r\n' + "Content-Type: image/png\r\n\r\n", ); const declaredLength = preamble.length + 512 * 1024; const request = http.request(`${harness.baseUrl}/api/assets`, { method: "POST", headers: { "content-type": `multipart/form-data; boundary=${boundary}`, "content-length": String(declaredLength), }, }); request.on("error", () => {}); request.write(preamble); request.write(Buffer.alloc(64 * 1024, 1)); await eventuallyContainsFile(harness.tempDirectory); const closed = new Promise((resolve) => request.once("close", resolve)); request.destroy(); await closed; await eventuallyEmpty(harness.tempDirectory); assert.equal(harness.storage.objects.size, 0); assert.equal( harness.database.prepare("SELECT COUNT(*) AS count FROM assets").get().count, 0, ); });