From 60a3c59158e120debacdce8410c62991273b1553 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 25 Aug 2026 08:46:24 +0300 Subject: [PATCH] fix(article): reject cover uploads carrying more than one file An article holds exactly one cover: there is a single cover_image_key column, so the limit already existed in the data model. The upload endpoint did not honour it honestly, though - request.file() keeps the first part and discards the rest without a word, so a client that posted three images got a 200 and no way to tell which one was stored. Measured before the change: 1 file -> 200 3 files -> 200 (first kept, rest silently dropped) 5 files -> 200 (same) The handler now iterates the parts, consumes the first, and refuses if a second one follows. The check runs before anything reaches storage, so a rejected request leaves no orphaned object in the bucket. After: 1 file -> 200 2 files -> 400 MediaLimitExceededError 5 files -> 400 MediaLimitExceededError 0 files -> 400 NoMediaProvidedError Co-Authored-By: Claude Opus 5 --- src/http/controllers/article.controller.ts | 19 ++++++-- tests/e2e/article/upload-cover.test.ts | 56 ++++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/http/controllers/article.controller.ts b/src/http/controllers/article.controller.ts index 6cf87d6..ebc3570 100644 --- a/src/http/controllers/article.controller.ts +++ b/src/http/controllers/article.controller.ts @@ -12,7 +12,7 @@ import type { LikeArticleUseCase } from "@core/use-cases/article/like-article"; import type { UnlikeArticleUseCase } from "@core/use-cases/article/unlike-article"; import type { SaveArticleBookmarkUseCase } from "@core/use-cases/article/save-article-bookmark"; import type { RemoveArticleBookmarkUseCase } from "@core/use-cases/article/remove-article-bookmark"; -import { NoMediaProvidedError } from "@core/errors"; +import { MediaLimitExceededError, 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"; @@ -319,14 +319,27 @@ export class ArticleController { ); } - const file = await request.file(); + // Iterated rather than taking request.file(), which silently keeps the + // first part and discards the rest: a client that uploaded three + // images would get a 200 and no way to know which one was stored. + const parts = request.files(); + const first = await parts.next(); - if (!file) { + if (first.done) { throw new NoMediaProvidedError("No cover image was provided."); } + const file = first.value; const fileBuffer = await file.toBuffer(); + // An article holds exactly one cover, so a second file is a request + // the API cannot honour. Checked before anything is stored. + if (!(await parts.next()).done) { + throw new MediaLimitExceededError( + "An article takes exactly one cover image.", + ); + } + const coverImageKey = await this.uploadArticleCoverUseCase.execute({ userId: request.user.id, fileBuffer, diff --git a/tests/e2e/article/upload-cover.test.ts b/tests/e2e/article/upload-cover.test.ts index b981e24..73b5f12 100644 --- a/tests/e2e/article/upload-cover.test.ts +++ b/tests/e2e/article/upload-cover.test.ts @@ -39,6 +39,34 @@ const MULTIPART_HEADERS = { "content-type": `multipart/form-data; boundary=${BOUNDARY}`, }; +/** + * Builds a multipart body carrying several files under the same field name. + */ +function multipartMany(count: number): Buffer { + const parts: Buffer[] = []; + + for (let i = 0; i < count; i++) { + parts.push( + Buffer.from( + `--${BOUNDARY} +` + + `Content-Disposition: form-data; name="file"; filename="cover${i}.png" +` + + `Content-Type: image/png + +`, + ), + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, i]), + Buffer.from(" +"), + ); + } + + parts.push(Buffer.from(`--${BOUNDARY}-- +`)); + return Buffer.concat(parts); +} + beforeAll(async () => { await request({ method: "POST", url: "/auth/register", payload: user }); const login = await request({ @@ -116,6 +144,34 @@ describe("POST /articles/cover", () => { expect(response.statusCode).toBe(415); }); + it("should reject a request carrying more than one file", async () => { + // An article holds exactly one cover. Taking the first part and + // discarding the rest would answer 200 and leave the client with no + // way to know which image was stored. + const response = await authRequest(accessToken, { + method: "POST", + url: "/articles/cover", + headers: MULTIPART_HEADERS, + payload: multipartMany(3), + }); + + expect(response.statusCode).toBe(400); + expect(parseBody(response).title).toBe( + "MediaLimitExceededError", + ); + }); + + it("should reject even two files", async () => { + const response = await authRequest(accessToken, { + method: "POST", + url: "/articles/cover", + headers: MULTIPART_HEADERS, + payload: multipartMany(2), + }); + + expect(response.statusCode).toBe(400); + }); + it("should reject an empty file", async () => { const response = await authRequest(accessToken, { method: "POST",