From ea5ea4a716e9e0bd24a81e084d5a11bc859bea02 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 25 Aug 2026 02:09:32 +0300 Subject: [PATCH] feat(article): add cover image upload Stage 4 of the article feature. POST /api/v1/articles/cover auth required, SENSITIVE, one file, 5 MB max The endpoint returns a storage key, not a URL, and the article body accepts only that key - validated in stage 2 against the caller's own prefix. Keeping URLs out of the request is what closes javascript: and data: stored XSS, external tracking pixels, and one user attaching another user's upload, all with one rule instead of a sanitizer. The file type is decided by reading magic bytes. The multipart part's mimetype and filename are never consulted and never forwarded: both are attacker controlled, so a request can claim image/png while carrying an SVG, and a name like cover.png.html reads as an image to an extension check. The bytes are the only statement about a file the uploader cannot forge. SVG has no signature to match and is rejected for free, which is the intended outcome - it is a scriptable document rather than a raster image, and serving one from the CDN would be stored XSS. The stored file name is generated rather than derived from the upload, so a path traversal in the client name cannot reach a storage key. Size is checked twice: the byte length, and the transport truncation flag, because the multipart limit truncates silently on some paths. CryptoPort gains generateUuid so the use case stays free of node imports, in keeping with how the rest of core reaches randomness. The happy path is not covered by e2e: a real upload needs a live R2 connection, matching how the post media suite is written. Every case that is covered is rejected before storage is reached, which is where the security-relevant behaviour lives. Co-Authored-By: Claude Opus 5 --- .../errors/common/payload-too-large.error.ts | 17 ++ src/core/errors/index.ts | 1 + src/core/ports/services/crypto.port.ts | 7 + .../upload-article-cover/detect-image-type.ts | 76 +++++++ .../article/upload-article-cover/index.ts | 7 + .../upload-article-cover-usecase.input.ts | 16 ++ .../upload-article-cover.usecase.ts | 71 +++++++ src/http/controllers/article.controller.ts | 47 +++++ src/http/plugins/di/use-cases.di.ts | 6 + src/http/routes/article/article.routes.ts | 16 ++ .../schemas/article/upload-cover.schema.ts | 15 ++ src/infrastructure/security/crypto.service.ts | 5 + tests/e2e/article/upload-cover.test.ts | 143 +++++++++++++ .../upload-article-cover.usecase.test.ts | 195 ++++++++++++++++++ 14 files changed, 622 insertions(+) create mode 100644 src/core/errors/common/payload-too-large.error.ts create mode 100644 src/core/use-cases/article/upload-article-cover/detect-image-type.ts create mode 100644 src/core/use-cases/article/upload-article-cover/index.ts create mode 100644 src/core/use-cases/article/upload-article-cover/upload-article-cover-usecase.input.ts create mode 100644 src/core/use-cases/article/upload-article-cover/upload-article-cover.usecase.ts create mode 100644 src/http/types/schemas/article/upload-cover.schema.ts create mode 100644 tests/e2e/article/upload-cover.test.ts create mode 100644 tests/unit/core/use-cases/article/upload-article-cover.usecase.test.ts diff --git a/src/core/errors/common/payload-too-large.error.ts b/src/core/errors/common/payload-too-large.error.ts new file mode 100644 index 00000000..5292a951 --- /dev/null +++ b/src/core/errors/common/payload-too-large.error.ts @@ -0,0 +1,17 @@ +import { CustomError } from "./custom.error"; + +/** + * Error thrown when an upload exceeds the size the endpoint accepts. + * + * @extends CustomError + */ +export class PayloadTooLargeError extends CustomError { + /** + * Creates a new PayloadTooLargeError instance. + * + * @param message - Description of the limit that was exceeded + */ + constructor(message: string) { + super(message, 413); + } +} diff --git a/src/core/errors/index.ts b/src/core/errors/index.ts index 6d82dedf..d9243bbc 100644 --- a/src/core/errors/index.ts +++ b/src/core/errors/index.ts @@ -33,5 +33,6 @@ export * from "./common/bad-request.error"; export * from "./common/conflict.error"; export * from "./common/custom.error"; export * from "./common/not-found.error"; +export * from "./common/payload-too-large.error"; export * from "./common/forbidden.error"; export * from "./common/translation-failed.error"; diff --git a/src/core/ports/services/crypto.port.ts b/src/core/ports/services/crypto.port.ts index 1d3435bc..03b94c32 100644 --- a/src/core/ports/services/crypto.port.ts +++ b/src/core/ports/services/crypto.port.ts @@ -26,6 +26,13 @@ export interface CryptoPort { */ generateRandomHex(bytes: number): string; + /** + * Generates a random UUID. + * + * @returns A version 4 UUID string. + */ + generateUuid(): string; + /** * Compares two strings in constant time to prevent timing attacks. * diff --git a/src/core/use-cases/article/upload-article-cover/detect-image-type.ts b/src/core/use-cases/article/upload-article-cover/detect-image-type.ts new file mode 100644 index 00000000..9839ff64 --- /dev/null +++ b/src/core/use-cases/article/upload-article-cover/detect-image-type.ts @@ -0,0 +1,76 @@ +/** + * A raster image format this API accepts for article covers. + */ +export interface DetectedImageType { + /** File extension to use in the storage key */ + extension: "jpg" | "png" | "gif" | "webp" | "avif"; + + /** MIME type derived from the bytes, not from the client */ + mimeType: string; +} + +/** + * Compares a run of bytes against an expected signature. + * + * @param buffer - The uploaded bytes + * @param offset - Where the signature should start + * @param signature - The expected byte values + * @returns True when every byte matches + */ +function matches(buffer: Buffer, offset: number, signature: number[]): boolean { + if (buffer.length < offset + signature.length) return false; + + for (let i = 0; i < signature.length; i++) { + if (buffer[offset + i] !== signature[i]) return false; + } + + return true; +} + +/** + * Identifies an image by its magic bytes. + * + * The client-supplied MIME type and file name are deliberately not consulted. + * Both are attacker-controlled: a request can claim `image/png` while carrying + * an SVG, and a name like `cover.png.html` reads as an image to a naive + * extension check. Reading the bytes is the only statement about the file the + * uploader cannot forge. + * + * SVG has no signature to match and is therefore rejected for free, which is + * the intended outcome: it is a scriptable document format rather than a + * raster image, and serving one from the CDN would be a stored XSS. + * + * @param buffer - The uploaded bytes + * @returns The detected type, or null when the bytes are not a supported image + */ +export function detectImageType(buffer: Buffer): DetectedImageType | null { + // JPEG: FF D8 FF + if (matches(buffer, 0, [0xff, 0xd8, 0xff])) { + return { extension: "jpg", mimeType: "image/jpeg" }; + } + + // PNG: 89 50 4E 47 0D 0A 1A 0A + if (matches(buffer, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { + return { extension: "png", mimeType: "image/png" }; + } + + // GIF: "GIF8" + if (matches(buffer, 0, [0x47, 0x49, 0x46, 0x38])) { + return { extension: "gif", mimeType: "image/gif" }; + } + + // WEBP: "RIFF" then "WEBP" at byte 8 + if ( + matches(buffer, 0, [0x52, 0x49, 0x46, 0x46]) && + matches(buffer, 8, [0x57, 0x45, 0x42, 0x50]) + ) { + return { extension: "webp", mimeType: "image/webp" }; + } + + // AVIF: "ftypavif" at byte 4 + if (matches(buffer, 4, [0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66])) { + return { extension: "avif", mimeType: "image/avif" }; + } + + return null; +} diff --git a/src/core/use-cases/article/upload-article-cover/index.ts b/src/core/use-cases/article/upload-article-cover/index.ts new file mode 100644 index 00000000..609dc3ae --- /dev/null +++ b/src/core/use-cases/article/upload-article-cover/index.ts @@ -0,0 +1,7 @@ +/** + * Article cover upload module exports. + */ + +export * from "./upload-article-cover.usecase"; +export * from "./upload-article-cover-usecase.input"; +export * from "./detect-image-type"; diff --git a/src/core/use-cases/article/upload-article-cover/upload-article-cover-usecase.input.ts b/src/core/use-cases/article/upload-article-cover/upload-article-cover-usecase.input.ts new file mode 100644 index 00000000..e782c990 --- /dev/null +++ b/src/core/use-cases/article/upload-article-cover/upload-article-cover-usecase.input.ts @@ -0,0 +1,16 @@ +/** + * Input for uploading an article cover image. + * + * There is no mimeType or file name field on purpose: both are supplied by the + * client and neither is trusted. The type is read from the bytes. + */ +export interface UploadArticleCoverUseCaseInput { + /** The authenticated uploader; the key is scoped to them */ + userId: string; + + /** The uploaded bytes */ + fileBuffer: Buffer; + + /** Whether the transport truncated the stream at its size limit */ + truncated?: boolean; +} diff --git a/src/core/use-cases/article/upload-article-cover/upload-article-cover.usecase.ts b/src/core/use-cases/article/upload-article-cover/upload-article-cover.usecase.ts new file mode 100644 index 00000000..3054c6ee --- /dev/null +++ b/src/core/use-cases/article/upload-article-cover/upload-article-cover.usecase.ts @@ -0,0 +1,71 @@ +import type { CryptoPort } from "@core/ports/services/crypto.port"; +import type { StoragePort } from "@core/ports/services/storage.port"; +import { InvalidFileTypeError, PayloadTooLargeError } from "@core/errors"; +import type { UploadArticleCoverUseCaseInput } from "./upload-article-cover-usecase.input"; +import { detectImageType } from "./detect-image-type"; + +/** Largest cover image accepted, in bytes. */ +const MAX_COVER_BYTES = 5 * 1024 * 1024; + +/** + * Use case for uploading an article cover image. + * + * Returns a storage key rather than a URL. The article body accepts only that + * key, validated against the uploader's own prefix, which is what keeps + * arbitrary client-supplied URLs out of stored content. + */ +export class UploadArticleCoverUseCase { + /** + * Creates a new instance of UploadArticleCoverUseCase. + * + * @param storageService - Object storage receiving the image + * @param cryptoService - Source of the random file name + */ + constructor( + private readonly storageService: StoragePort, + private readonly cryptoService: CryptoPort, + ) {} + + /** + * Executes the upload. + * + * The file name is generated, never derived from the upload: a client name + * can carry a path traversal or a second extension, and neither can survive + * a name the server invents. + * + * @param input - The uploader and the raw bytes + * @returns The storage key of the stored image + * + * @throws PayloadTooLargeError - When the image exceeds the size limit + * @throws InvalidFileTypeError - When the bytes are not a supported image + */ + async execute(input: UploadArticleCoverUseCaseInput): Promise { + if (input.truncated || input.fileBuffer.byteLength > MAX_COVER_BYTES) { + throw new PayloadTooLargeError( + "Cover image must be 5 MB or smaller.", + ); + } + + const detected = detectImageType(input.fileBuffer); + + if (!detected) { + throw new InvalidFileTypeError( + "Cover image must be a JPEG, PNG, GIF, WEBP or AVIF file.", + ); + } + + const key = + "articles/covers/" + + input.userId + + "/" + + this.cryptoService.generateUuid() + + "." + + detected.extension; + + return await this.storageService.upload( + key, + input.fileBuffer, + detected.mimeType, + ); + } +} diff --git a/src/http/controllers/article.controller.ts b/src/http/controllers/article.controller.ts index 40751969..62cf230a 100644 --- a/src/http/controllers/article.controller.ts +++ b/src/http/controllers/article.controller.ts @@ -7,6 +7,8 @@ import type { DeleteArticleUseCase } from "@core/use-cases/article/delete-articl import type { GetArticlesUseCase } from "@core/use-cases/article/get-articles"; import type { GetArticleUseCase } from "@core/use-cases/article/get-article"; import type { GetMyArticlesUseCase } from "@core/use-cases/article/get-my-articles"; +import type { UploadArticleCoverUseCase } from "@core/use-cases/article/upload-article-cover"; +import { NoMediaProvidedError } from "@core/errors"; import { ArticlePrismaMapper } from "@infrastructure/persistence/mappers/article-prisma.mapper"; import type { CreateArticleBody } from "@typings/schemas/article/create-article.schema"; import type { UpdateArticleBody } from "@typings/schemas/article/update-article.schema"; @@ -33,6 +35,7 @@ export class ArticleController { * @param getArticlesUseCase - Use case for the public article list * @param getArticleUseCase - Use case for reading one article by slug * @param getMyArticlesUseCase - Use case for an author's own articles + * @param uploadArticleCoverUseCase - Use case for storing a cover image */ constructor( private readonly createArticleUseCase: CreateArticleUseCase, @@ -43,6 +46,7 @@ export class ArticleController { private readonly getArticlesUseCase: GetArticlesUseCase, private readonly getArticleUseCase: GetArticleUseCase, private readonly getMyArticlesUseCase: GetMyArticlesUseCase, + private readonly uploadArticleCoverUseCase: UploadArticleCoverUseCase, ) {} /** @@ -283,6 +287,49 @@ export class ArticleController { }); } + /** + * Stores a cover image and returns the key the article body accepts. + * + * Only the bytes are passed on: the multipart part's mimetype and filename + * are both client-controlled and neither is forwarded anywhere. + * + * @param request - A multipart request carrying exactly one file + * @param reply - The Fastify reply object + * @returns A 200 response with the storage key and its public URL + */ + async uploadCover( + request: FastifyRequest, + reply: FastifyReply, + ): Promise { + if (!request.isMultipart()) { + throw new NoMediaProvidedError( + "Please send a multipart/form-data request with one image file.", + ); + } + + const file = await request.file(); + + if (!file) { + throw new NoMediaProvidedError("No cover image was provided."); + } + + const fileBuffer = await file.toBuffer(); + + const coverImageKey = await this.uploadArticleCoverUseCase.execute({ + userId: request.user.id, + fileBuffer, + truncated: file.file.truncated, + }); + + return reply.status(200).send({ + data: { + coverImageKey, + coverImageUrl: this.cdnUrl(request) + "/" + coverImageKey, + }, + meta: { timestamp: new Date().toISOString() }, + }); + } + /** * Resolves the CDN base URL, without a trailing slash. * diff --git a/src/http/plugins/di/use-cases.di.ts b/src/http/plugins/di/use-cases.di.ts index 40fca655..c9c0a2e0 100644 --- a/src/http/plugins/di/use-cases.di.ts +++ b/src/http/plugins/di/use-cases.di.ts @@ -64,6 +64,7 @@ import { DeleteArticleUseCase } from "@core/use-cases/article/delete-article"; import { GetArticlesUseCase } from "@core/use-cases/article/get-articles"; import { GetArticleUseCase } from "@core/use-cases/article/get-article"; import { GetMyArticlesUseCase } from "@core/use-cases/article/get-my-articles"; +import { UploadArticleCoverUseCase } from "@core/use-cases/article/upload-article-cover"; /** * Dependency injection module for use cases @@ -452,4 +453,9 @@ export const useCasesModule = { * Use case for an author's own article list */ getMyArticlesUseCase: asClass(GetMyArticlesUseCase).singleton(), + + /** + * Use case for storing an article cover image + */ + uploadArticleCoverUseCase: asClass(UploadArticleCoverUseCase).singleton(), }; diff --git a/src/http/routes/article/article.routes.ts b/src/http/routes/article/article.routes.ts index 0d6f0728..a348ee88 100644 --- a/src/http/routes/article/article.routes.ts +++ b/src/http/routes/article/article.routes.ts @@ -36,6 +36,7 @@ import { getMyArticlesQuerySchema, type GetMyArticlesQuery, } from "@typings/schemas/article/get-my-articles.schema"; +import { UploadCoverResponseSchema } from "@typings/schemas/article/upload-cover.schema"; /** * Registers the article write endpoints. @@ -117,6 +118,21 @@ export function articleRoutes(fastify: FastifyInstance): void { articleController.create.bind(articleController), ); + // No body schema: declaring one would make Fastify try to validate a + // multipart stream. The file is validated by its bytes in the use case. + fastify.post( + "/articles/cover", + { + onRequest: [fastify.authenticate], + schema: { + response: { 200: UploadCoverResponseSchema }, + tags: ["Article"], + }, + config: { rateLimit: RateLimitPolicies.SENSITIVE }, + }, + articleController.uploadCover.bind(articleController), + ); + fastify.patch<{ Params: ArticleIdParams; Body: UpdateArticleBody; diff --git a/src/http/types/schemas/article/upload-cover.schema.ts b/src/http/types/schemas/article/upload-cover.schema.ts new file mode 100644 index 00000000..3f773085 --- /dev/null +++ b/src/http/types/schemas/article/upload-cover.schema.ts @@ -0,0 +1,15 @@ +import { Type as FBType, type Static } from "@fastify/type-provider-typebox"; +import { ResponseSchema } from "../create-response-schema"; + +/** + * The upload returns both forms: the key, which is what the article body + * accepts, and the URL, which is what a client renders. + */ +export const UploadCoverResponseSchema = ResponseSchema( + FBType.Object({ + coverImageKey: FBType.String(), + coverImageUrl: FBType.String(), + }), +); + +export type UploadCoverResponse = Static; diff --git a/src/infrastructure/security/crypto.service.ts b/src/infrastructure/security/crypto.service.ts index e91cdf5b..ca93cb15 100644 --- a/src/infrastructure/security/crypto.service.ts +++ b/src/infrastructure/security/crypto.service.ts @@ -2,6 +2,7 @@ import { randomInt, createHash, randomBytes, + randomUUID, timingSafeEqual as cryptoTimingSafeEqual, } from "crypto"; import type { CryptoPort } from "@core/ports/services/crypto.port"; @@ -11,6 +12,10 @@ export class CryptoService implements CryptoPort { return randomBytes(bytes).toString("hex"); } + generateUuid(): string { + return randomUUID(); + } + generateOtp(length: number = 8): string { const max = Math.pow(10, length); const otp = randomInt(0, max).toString(); diff --git a/tests/e2e/article/upload-cover.test.ts b/tests/e2e/article/upload-cover.test.ts new file mode 100644 index 00000000..b981e24f --- /dev/null +++ b/tests/e2e/article/upload-cover.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { request, authRequest, parseBody } from "../setup"; + +type ErrorEnvelope = { title: string; status: number; detail: string }; + +const ts = Date.now(); +const user = { + email: `cover-${ts}@article-cover-test.com`, + password: "password123", + username: `cv${ts}`, +}; + +let accessToken: string; + +const BOUNDARY = "----articlecoverboundary"; + +/** + * Builds a multipart body carrying one file. + * + * The filename and content type are supplied separately from the bytes so a + * test can lie about both, which is the whole point: the API must decide from + * the bytes alone. + */ +function multipart( + bytes: Buffer, + filename: string, + contentType: string, +): Buffer { + const header = Buffer.from( + `--${BOUNDARY}\r\n` + + `Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` + + `Content-Type: ${contentType}\r\n\r\n`, + ); + const footer = Buffer.from(`\r\n--${BOUNDARY}--\r\n`); + return Buffer.concat([header, bytes, footer]); +} + +const MULTIPART_HEADERS = { + "content-type": `multipart/form-data; boundary=${BOUNDARY}`, +}; + +beforeAll(async () => { + await request({ method: "POST", url: "/auth/register", payload: user }); + const login = await request({ + method: "POST", + url: "/auth/login", + payload: { identifier: user.email, password: user.password }, + }); + accessToken = parseBody<{ data: { accessToken: string } }>(login).data + .accessToken; +}); + +/** + * Note: the happy path is not covered here. A successful upload needs a live + * R2 connection, which CI does not have, matching how the post media upload + * suite is written. Every case below is rejected before storage is reached, + * which is exactly where the security-relevant behaviour lives. + */ +describe("POST /articles/cover", () => { + it("should require authentication", async () => { + const response = await request({ + method: "POST", + url: "/articles/cover", + headers: MULTIPART_HEADERS, + payload: multipart( + Buffer.from([0xff, 0xd8, 0xff, 0x00]), + "cover.jpg", + "image/jpeg", + ), + }); + + expect(response.statusCode).toBe(401); + }); + + it("should reject a request that is not multipart", async () => { + const response = await authRequest(accessToken, { + method: "POST", + url: "/articles/cover", + payload: { file: "not-multipart" }, + }); + + expect(response.statusCode).toBe(400); + expect(parseBody(response).title).toBe( + "NoMediaProvidedError", + ); + }); + + it("should reject an SVG that claims to be a PNG", async () => { + const svg = Buffer.from( + '', + ); + + const response = await authRequest(accessToken, { + method: "POST", + url: "/articles/cover", + headers: MULTIPART_HEADERS, + payload: multipart(svg, "cover.png", "image/png"), + }); + + expect(response.statusCode).toBe(415); + expect(parseBody(response).title).toBe( + "InvalidFileTypeError", + ); + }); + + it("should reject HTML behind an image filename and content type", async () => { + const html = Buffer.from(""); + + const response = await authRequest(accessToken, { + method: "POST", + url: "/articles/cover", + headers: MULTIPART_HEADERS, + payload: multipart(html, "cover.jpeg", "image/jpeg"), + }); + + expect(response.statusCode).toBe(415); + }); + + it("should reject an empty file", async () => { + const response = await authRequest(accessToken, { + method: "POST", + url: "/articles/cover", + headers: MULTIPART_HEADERS, + payload: multipart(Buffer.alloc(0), "cover.png", "image/png"), + }); + + expect(response.statusCode).toBe(415); + }); + + it("should reject a file whose name carries a path traversal", async () => { + const html = Buffer.from(""); + + const response = await authRequest(accessToken, { + method: "POST", + url: "/articles/cover", + headers: MULTIPART_HEADERS, + payload: multipart(html, "../../../etc/passwd.png", "image/png"), + }); + + // Rejected on its bytes; the name never reaches a storage key at all. + expect(response.statusCode).toBe(415); + }); +}); diff --git a/tests/unit/core/use-cases/article/upload-article-cover.usecase.test.ts b/tests/unit/core/use-cases/article/upload-article-cover.usecase.test.ts new file mode 100644 index 00000000..ec0f6fe7 --- /dev/null +++ b/tests/unit/core/use-cases/article/upload-article-cover.usecase.test.ts @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + UploadArticleCoverUseCase, + detectImageType, +} from "@core/use-cases/article/upload-article-cover"; +import type { CryptoPort } from "@core/ports/services/crypto.port"; +import type { StoragePort } from "@core/ports/services/storage.port"; +import { InvalidFileTypeError, PayloadTooLargeError } from "@core/errors"; + +const USER = "11111111-1111-4111-8111-111111111111"; +const UUID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + +/** + * Builds a buffer beginning with the given signature bytes. + */ +function withSignature(signature: number[], totalLength = 32): Buffer { + const buffer = Buffer.alloc(totalLength); + for (let i = 0; i < signature.length; i++) buffer[i] = signature[i]; + return buffer; +} + +const JPEG = withSignature([0xff, 0xd8, 0xff]); +const PNG = withSignature([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const GIF = withSignature([0x47, 0x49, 0x46, 0x38]); +const SVG = Buffer.from(''); +const HTML = Buffer.from(""); + +/** + * WEBP needs "RIFF" at 0 and "WEBP" at 8. + */ +function webp(): Buffer { + const buffer = withSignature([0x52, 0x49, 0x46, 0x46]); + const marker = [0x57, 0x45, 0x42, 0x50]; + for (let i = 0; i < marker.length; i++) buffer[8 + i] = marker[i]; + return buffer; +} + +/** + * AVIF needs "ftypavif" starting at byte 4. + */ +function avif(): Buffer { + const buffer = Buffer.alloc(32); + const marker = [0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66]; + for (let i = 0; i < marker.length; i++) buffer[4 + i] = marker[i]; + return buffer; +} + +describe("detectImageType()", () => { + it("should recognise every accepted raster format", () => { + expect(detectImageType(JPEG)).toEqual({ + extension: "jpg", + mimeType: "image/jpeg", + }); + expect(detectImageType(PNG)).toEqual({ + extension: "png", + mimeType: "image/png", + }); + expect(detectImageType(GIF)).toEqual({ + extension: "gif", + mimeType: "image/gif", + }); + expect(detectImageType(webp())).toEqual({ + extension: "webp", + mimeType: "image/webp", + }); + expect(detectImageType(avif())).toEqual({ + extension: "avif", + mimeType: "image/avif", + }); + }); + + it("should reject SVG, which is a scriptable document rather than an image", () => { + expect(detectImageType(SVG)).toBeNull(); + }); + + it("should reject HTML", () => { + expect(detectImageType(HTML)).toBeNull(); + }); + + it("should reject an empty or truncated buffer", () => { + expect(detectImageType(Buffer.alloc(0))).toBeNull(); + expect(detectImageType(Buffer.from([0xff, 0xd8]))).toBeNull(); + }); + + it("should not accept a RIFF container that is not WEBP", () => { + const wav = withSignature([0x52, 0x49, 0x46, 0x46]); + const marker = [0x57, 0x41, 0x56, 0x45]; + for (let i = 0; i < marker.length; i++) wav[8 + i] = marker[i]; + + expect(detectImageType(wav)).toBeNull(); + }); +}); + +describe("UploadArticleCoverUseCase", () => { + let useCase: UploadArticleCoverUseCase; + let storageService: Pick; + let cryptoService: Pick; + + beforeEach(() => { + storageService = { + upload: vi + .fn() + .mockImplementation((key: string) => Promise.resolve(key)), + }; + cryptoService = { generateUuid: vi.fn().mockReturnValue(UUID) }; + + useCase = new UploadArticleCoverUseCase( + storageService as StoragePort, + cryptoService as CryptoPort, + ); + }); + + it("should store the image under the uploader's own prefix", async () => { + const key = await useCase.execute({ userId: USER, fileBuffer: PNG }); + + expect(key).toBe(`articles/covers/${USER}/${UUID}.png`); + }); + + it("should pass the sniffed mime type to storage, not a client-supplied one", async () => { + await useCase.execute({ userId: USER, fileBuffer: JPEG }); + + expect(storageService.upload).toHaveBeenCalledWith( + `articles/covers/${USER}/${UUID}.jpg`, + JPEG, + "image/jpeg", + ); + }); + + it("should derive the extension from the bytes", async () => { + const cases: Array<[Buffer, string]> = [ + [JPEG, "jpg"], + [PNG, "png"], + [GIF, "gif"], + [webp(), "webp"], + [avif(), "avif"], + ]; + + for (const [buffer, extension] of cases) { + const key = await useCase.execute({ + userId: USER, + fileBuffer: buffer, + }); + expect(key.endsWith("." + extension)).toBe(true); + } + }); + + it("should reject an SVG uploaded as if it were a PNG", async () => { + await expect( + useCase.execute({ userId: USER, fileBuffer: SVG }), + ).rejects.toThrow(InvalidFileTypeError); + + expect(storageService.upload).not.toHaveBeenCalled(); + }); + + it("should reject HTML", async () => { + await expect( + useCase.execute({ userId: USER, fileBuffer: HTML }), + ).rejects.toThrow(InvalidFileTypeError); + }); + + it("should reject a buffer over the size limit", async () => { + const tooBig = Buffer.alloc(5 * 1024 * 1024 + 1); + tooBig[0] = 0xff; + tooBig[1] = 0xd8; + tooBig[2] = 0xff; + + await expect( + useCase.execute({ userId: USER, fileBuffer: tooBig }), + ).rejects.toThrow(PayloadTooLargeError); + + expect(storageService.upload).not.toHaveBeenCalled(); + }); + + it("should reject a stream the transport truncated", async () => { + await expect( + useCase.execute({ + userId: USER, + fileBuffer: PNG, + truncated: true, + }), + ).rejects.toThrow(PayloadTooLargeError); + }); + + it("should produce a key that the article body validator accepts", async () => { + const key = await useCase.execute({ userId: USER, fileBuffer: PNG }); + + expect(key).toMatch( + new RegExp( + "^articles/covers/" + + USER + + "/[0-9a-f-]{36}[.](jpg|jpeg|png|webp|gif|avif)$", + ), + ); + }); +});