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