Files
Announcements/server/test/assets-api.test.js
T

443 lines
15 KiB
JavaScript

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,
);
});