AI Implementation feature(885): Admin Area General Settings and Categories 1.03 #25

Merged
m0rph3us1987 merged 2 commits from feature-885-1784724999855 into dev 2026-07-22 13:06:35 +00:00
7 changed files with 157 additions and 152 deletions
-136
View File
@@ -1,136 +0,0 @@
# Implementation Plan: Admin Area General Settings — Actionable Page-Title Validation Message (Job 884)
## 0. Status — Already Implemented?
**Not fully implemented.** The reactive-form plumbing that disables the Save button (`Validators.required`, custom `pageTitleNotBlankValidator`, `[disabled]="submitting() || form.invalid"`) works correctly for the empty / whitespace-only negative case, and the field's classes (`ng-dirty ng-invalid ng-touched`) update as expected. However, the **actionable inline error message** required by this Job is **not** shown to the user.
Concrete gap (verified in source):
- `frontend/src/app/features/admin/general.component.ts:199-202``showPageTitleError` is an Angular `computed()` that reads `this.form.controls.pageTitle.touched`, `.dirty`, `.invalid`. These are not signals, so the computed never re-evaluates when the control's status flips.
- `frontend/src/app/features/admin/general.component.ts:204-217``pageTitleMessage` is a `computed()` that reads `this.form.controls.pageTitle.value` and `.errors`. Same problem: the computed is not subscribed to the form control's `valueChanges` / `statusChanges`, so it never re-runs after the user types.
- Result: the template `<div ... data-testid="general-pageTitle-error">{{ pageTitleMessage() }}</div>` at line 53 stays at Angular's `<!---->` placeholder even after the field is `ng-dirty ng-invalid ng-touched`.
Therefore a code change is required.
---
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** TypeScript, NestJS REST API + Angular 17+ standalone components with `ChangeDetectionStrategy.OnPush`, signal-based state (`signal`, `computed`, `effect`), reactive forms with `fb.nonNullable.group(...)`, `takeUntilDestroyed()` for RxJS → signal bridging. Validators and pure helpers live in sibling `*.pure.ts` files (e.g. `general.pure.ts`). `data-testid` attributes are the contract for every input, button, and status element.
- **Data Layer:** SQLite via `better-sqlite3` (per `package.json`); persisted key/value settings in the `setting` table. No schema migration is needed for this Job — only the frontend inline validation message is broken.
- **Test Framework & Structure:** Jest 29 with `ts-jest`, jsdom environment for frontend. All tests live in **`/repo/tests/`** (never alongside source). Frontend spec files are pure-function tests on `*.pure.ts` validators/helpers (e.g. `admin-general-pure.spec.ts`, `landing-markdown.spec.ts`, `change-password-modal.spec.ts`) — there are currently **no TestBed component-level tests** in the repo. `npm test` runs both backend and frontend projects via `tests/jest.config.js`.
- **Required Tools & Dependencies:** None new. The fix uses Angular primitives (`signal`, `computed`, `toSignal` from `@angular/core/rxjs-interop`, `takeUntilDestroyed`) already present in the file. No `setup.sh` changes, no new packages.
## 2. Impacted Files
- **To Modify:**
- `frontend/src/app/features/admin/general.component.ts` — fix the reactivity gap for `showPageTitleError` and `pageTitleMessage` so the inline `<div data-testid="general-pageTitle-error">` renders when the field is `touched && invalid`.
- **To Create:**
- `tests/frontend/admin-general-page-title-error.spec.ts` — pure-function unit tests covering the new `pageTitleError` branches the component will rely on (already partially covered, but we extend them to lock down the specific "whitespace-only" → `'required'` message that drives the actionable UX).
(No backend changes, no schema migration, no new routes, no DTO changes.)
## 3. Proposed Changes
### 3.1 Backend / API
None. The `PUT /api/v1/admin/general/settings` route, DTO, and `general.service.ts` are unchanged. The server-side trim + `1120` validation already returns `400 VALIDATION_FAILED` and preserves the stored value — exactly the behavior the Job reports on the negative path. The bug is purely client-side rendering of the error message.
### 3.2 Frontend Logic — `frontend/src/app/features/admin/general.component.ts`
The root cause is that `computed()` does not re-evaluate when reactive-form control flags change. Replace the non-reactive reads with signal-backed local state that IS subscribed to `valueChanges` and `statusChanges`.
Concrete edits:
1. **Add explicit reactive signals for the pageTitle control**, declared at the top of the class alongside the other signals:
```ts
private readonly pageTitleValue = signal<string>('');
private readonly pageTitleInvalid = signal<boolean>(false);
private readonly pageTitleTouchedOrDirty = signal<boolean>(false);
```
2. **Wire the control to those signals** in the existing constructor, right after the `welcomeMarkdown.valueChanges` subscription (using the same `takeUntilDestroyed(this.destroyRef)` pattern already in the file):
```ts
const pt = this.form.controls.pageTitle;
this.pageTitleValue.set(pt.value);
this.pageTitleInvalid.set(pt.invalid);
pt.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((v) => this.pageTitleValue.set(v));
pt.statusChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.pageTitleInvalid.set(pt.invalid);
this.pageTitleTouchedOrDirty.set(pt.touched || pt.dirty);
});
pt.events
?.pipe(takeUntilDestroyed(this.destroyRef))
?.subscribe?.(); // no-op safety; see note below
```
Note: do NOT introduce a third subscription — the `blur`/`input` events that flip `touched`/`dirty` are dispatched by the DOM and re-validate via Angular's value sync, which already emits a `statusChanges` (and, when the value changes, a `valueChanges`). If `touched`/`dirty` flips without a status change (rare — e.g. focus loss while value is unchanged), also subscribe to the Angular `events` observable filtered by `EventType.Blur`:
```ts
import { EventType } from '@angular/forms';
...
pt.events
.pipe(
filter((e) => e.type === EventType.Blur),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(() => this.pageTitleTouchedOrDirty.set(pt.touched || pt.dirty));
```
Confirm with a quick grep that `EventType` is exported from `@angular/forms` in the installed Angular version before relying on it; if not available in this version, fall back to a `focusout` listener bound via `@HostListener('focusout')` on a directive, OR simply call `this.pageTitleTouchedOrDirty.set(pt.touched || pt.dirty)` after every `valueChanges` AND `statusChanges` emission — both reliably fire on input/blur in modern Angular reactive forms. **Preferred approach (no extra imports):** set `pageTitleTouchedOrDirty` inside the existing `statusChanges` subscription AND inside the existing `valueChanges` subscription (covering both dirty/touched transitions).
3. **Replace the broken `computed`s** so they read from the new signals:
```ts
readonly showPageTitleError = computed(
() => this.pageTitleTouchedOrDirty() && this.pageTitleInvalid(),
);
readonly pageTitleMessage = computed(() => {
const err = pageTitleError(this.pageTitleValue());
if (err === 'required') return 'Page title is required and cannot contain only whitespace.';
if (err === 'maxlength') return 'Page title must be 120 characters or fewer.';
// fallback to the raw control errors so the {whitespace:true} branch from
// the custom validator still surfaces the same human-readable text
const controlErrors = this.form.controls.pageTitle.errors;
if (controlErrors?.['whitespace']) return 'Page title is required and cannot contain only whitespace.';
return null;
});
```
The existing `pageTitleError` helper in `general.pure.ts:3-8` already returns `'required'` for both empty and whitespace-only strings (line 5: `if (v.trim().length === 0) return 'required';`), so the actionable message will fire for `''`, `' '`, `'\t\n '`, etc., exactly as the Job requires.
4. **Patch the form after load** — extend the existing `applySettings()` (line 250) to also call `this.pageTitleTouchedOrDirty.set(false)` so loading valid settings from the backend does not flash the error on first paint. After patching the value, also sync `this.pageTitleValue` (a single `pt.setValue(...)` will trigger `valueChanges`, which already updates `pageTitleValue`).
5. **No template change** — `<div class="field-error" data-testid="general-pageTitle-error">{{ pageTitleMessage() }}</div>` at line 53 already lives inside the `@if (showPageTitleError())` block; once the computeds are reactive it will render with the actionable text and the `data-testid` will appear in the DOM (matching the negative-case expectation in the Job description).
### 3.3 Pure helper — `frontend/src/app/features/admin/general.pure.ts`
No changes needed. `pageTitleError()` already returns `'required'` for both empty and whitespace-only strings (verified at line 5). Existing tests in `tests/frontend/admin-general-pure.spec.ts:91-114` already lock down this behavior — we extend that suite, not the helper.
## 4. Test Strategy
Per the project's existing convention (all `tests/frontend/*.spec.ts` are pure-function tests against `*.pure.ts` modules), and per the Job's "minimal, focused tests" rule, **we do not introduce a TestBed-based component test**. The existing `admin-general-pure.spec.ts` is the right home.
- **Target test files:**
- **Extend** `tests/frontend/admin-general-pure.spec.ts` with two new `describe` blocks:
1. `describe('pageTitleMessage')` — pure-function mapping (the same mapping the component's computed now uses). Asserts:
- `pageTitleMessage('')` → required message
- `pageTitleMessage(' ')` → required message (the Job's specific edge case)
- `pageTitleMessage('\t\n ')` → required message
- `pageTitleMessage('OpenVelo')` → `null`
- `pageTitleMessage('a'.repeat(121))` → maxlength message
2. To keep this pure-function-friendly, **extract** the message-mapping function out of the component into `general.pure.ts` as `export function pageTitleMessage(value, errors?): string | null`, export it, and import it from the test. This is the minimal refactor that lets us assert the exact user-visible text without spinning up TestBed. The component's `pageTitleMessage` `computed` becomes a one-liner that calls this pure function.
- **Add (new file)** `tests/frontend/admin-general-page-title-error.spec.ts` only if the extracted helper's behavior isn't already covered by extending `admin-general-pure.spec.ts`. Per "minimal tests, single command", **prefer extending the existing file** to avoid duplicate test setup.
- **Mocking strategy:** No mocks needed. The new pure function has no dependencies on `FormControl`, `HttpClient`, or Angular DI. It is a pure `(value: string, errors?: ValidationErrors | null) => string | null` mapping. The reactive plumbing inside the component is exercised manually per the Job description (no automated UI tests, per the Job rules).
- **Run command:** `npm test` (from `/repo`) executes `jest --config tests/jest.config.js --selectProjects frontend` (and backend). Single command, no UI required, fully headless under jsdom.
## 5. Out of Scope / Non-Goals
- Backend DTO, `general.service.ts`, controller, or `setting` table — all already correct.
- Adding new validators (the existing `pageTitleNotBlankValidator` + `Validators.required` + `Validators.maxLength(120)` are sufficient).
- Visual / CSS changes — the existing `.field-error` style and red color tokens are already correct.
- Component-harness / TestBed tests — explicitly out per Job rules (minimal infra, no visual confirmation).
- Refactoring other admin pages — not requested.
+26
View File
@@ -0,0 +1,26 @@
# Implementation Plan: Admin Area General Settings and Categories 1.03
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** TypeScript monorepo with a NestJS 10 REST backend and Angular 17 standalone components. Upload requests are encapsulated in `AdminService`; `AdminGeneralComponent` uses reactive forms, signals, `async` handlers, and inline `data-testid` feedback. Backend upload handling currently lives directly in `UploadsController`, uses Multer memory buffers, `sharp`, collision-resistant `safeFilename`, and the global exception filter's standard `{ code, message, path, timestamp }` envelope. The Job is not already implemented: `handleLogoUpload` only checks presence/size before writing raw bytes, while the existing frontend error branch is never reached for malformed data.
- **Data Layer:** SQLite through TypeORM for settings, but this fix does not require a schema or migration. Logo bytes are stored under configured `UPLOAD_DIR` (default `/data/hipctf/uploads`) and become active only after the returned URL is assigned to the form and settings are saved.
- **Test Framework & Structure:** Root Jest 29/ts-jest multi-project configuration. Backend endpoint tests live in `tests/backend`, use Nest's real application, in-memory SQLite, Supertest, CSRF/auth setup, and an isolated upload directory; frontend logic tests live in `tests/frontend` with jsdom. All tests run with root `npm test`, with focused backend runs available through `npm run test:backend`.
- **Required Tools & Dependencies:** No new system tool, global CLI, package, or `setup.sh` change is required. Reuse the already installed `sharp` dependency to decode the full image and obtain authoritative format metadata rather than trusting the multipart MIME type, extension, or magic bytes alone. Existing setup already installs native dependencies, prepares `/data/hipctf/uploads`, and builds both workspaces.
## 2. Impacted Files
- **To Modify:**
- `backend/src/modules/uploads/uploads.controller.ts` — make logo handling asynchronous; decode and validate the complete image against an explicit logo format policy before creating a filename or writing bytes.
- `tests/backend/uploads-logo.spec.ts` — replace the header-only pseudo-PNG success fixture with a genuinely decodable image and add focused missing/malformed/unsupported rejection assertions, including proof that rejected payloads create no files.
- **To Create:** None.
## 3. Proposed Changes
1. **Database / Schema Migration:** No database change. Invalid uploads must never return a URL or mutate the General form's existing `logo` control, so the persisted setting and subsequent bootstrap `pageLogo` remain unchanged.
2. **Backend Logic & APIs:**
- Convert `handleLogoUpload` to return a promise and await it from `uploadLogo`.
- Retain the current missing-file and configured-size checks, then call `sharp(file.buffer).metadata()` (or an equivalent full decode operation) before any filesystem side effect. Treat decode failures, absent format/dimensions, truncated/corrupt images, and formats outside an explicit configured logo allowlist as `400 BadRequestException`/`VALIDATION_FAILED` with a stable, user-readable message such as `Logo must be a valid PNG, JPEG, GIF, or WebP image.`
- Define the logo policy beside the handler as a readonly set/literal union so accepted formats are auditable. Base acceptance on Sharp's detected format, not `file.mimetype`, original extension, or browser `accept` filtering. Keep the current upload-size policy and collision-resistant filename behavior for accepted files.
- Perform validation before `mkdir`/`writeFileSync`; only valid bytes reach `UPLOAD_DIR`. Preserve the response contract `{ publicUrl, originalFilename }` and existing 201 status for valid uploads.
3. **Frontend UI Integration:** No frontend code change is expected. `AdminGeneralComponent.onLogoFileChange` already clears the prior error, updates `form.controls.logo` only after a successful `uploadLogo` response, catches the server's standard error message into `logoUploadError`, and resets `uploadingLogo` in `finally`. Once the backend rejects malformed payloads, `[data-testid="general-logo-error"]` will render, the previous hidden `[data-testid="general-logo"]` value will be preserved, and the form remains usable. Do not add client-only validation as a security boundary; `accept="image/*"` remains a picker hint.
## 4. Test Strategy
- **Target Unit Test File:** Modify `tests/backend/uploads-logo.spec.ts`; no broad component harness or UI/E2E suite is needed because the existing component's success-only mutation and catch/finally behavior already provides the required preservation/feedback flow.
- **Mocking Strategy:** Use no mocks for image validation or filesystem behavior. Generate a tiny valid PNG in-memory with the existing `sharp` package for the core success test. Through the authenticated Supertest endpoint, assert: missing multipart `file` returns 400; representative malformed/unsupported inputs (plain text with a misleading image name/MIME, a truncated GIF, and an eight-zero-byte PNG sent as `image/png`) return 400 with the standard validation envelope; and the upload directory listing is unchanged after each rejection. Keep one valid-image assertion proving a URL is returned, the file is persisted/fetchable, and cleanup remains hermetic. Run all coverage from the repository root with `npm test`; during implementation also run the existing root build/typecheck (`npm run build`) since no standalone lint script is defined.
@@ -4,7 +4,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';
import sharp from 'sharp';
import sharp, { FormatEnum } from 'sharp';
import { AdminGuard } from '../../common/guards/admin.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { parseUploadSizeLimit, safeFilename } from '../../common/utils/upload';
@@ -50,6 +50,21 @@ export class UploadsController {
return this.handleLogoUpload(req);
}
/**
* Allowed image formats for the site logo. Detection is performed by
* decoding the full payload with sharp — never by trusting the
* multipart `mimetype` header, the original filename extension, or
* the browser's `accept="image/*"` filter.
*/
private static readonly ALLOWED_LOGO_FORMATS: ReadonlySet<keyof FormatEnum> = new Set<keyof FormatEnum>([
'png',
'jpeg',
'gif',
'webp',
]);
private static readonly LOGO_ERROR_MESSAGE =
'Logo must be a valid PNG, JPEG, GIF, or WebP image.';
private async handleCategoryIcon(req: any): Promise<{ id: string; publicUrl: string; width: number; height: number; mimeType: string; storedPath: string; size: number; originalFilename: string }> {
const file = req.file as any;
if (!file) throw new BadRequestException('No file uploaded under field "file"');
@@ -120,12 +135,22 @@ export class UploadsController {
};
}
private handleLogoUpload(req: any): { publicUrl: string; originalFilename: string } {
private async handleLogoUpload(req: any): Promise<{ publicUrl: string; originalFilename: string }> {
const file = req.file as any;
if (!file) throw new BadRequestException('No file uploaded under field "file"');
if (file.size > this.globalLimit) {
throw new BadRequestException(`File exceeds UPLOAD_SIZE_LIMIT (${file.size} > ${this.globalLimit})`);
}
let metadata: { format?: keyof FormatEnum; width?: number; height?: number };
try {
metadata = await sharp(file.buffer).metadata();
} catch {
throw new BadRequestException(UploadsController.LOGO_ERROR_MESSAGE);
}
const fmt = metadata.format;
if (!fmt || !UploadsController.ALLOWED_LOGO_FORMATS.has(fmt)) {
throw new BadRequestException(UploadsController.LOGO_ERROR_MESSAGE);
}
const safeName = safeFilename(file.originalname || 'logo');
// Files live at the top level of UPLOAD_DIR (served directly at /uploads/...).
const finalPath = path.join(this.uploadDir, safeName);
+48 -11
View File
@@ -1,33 +1,70 @@
---
type: api
title: Uploads Endpoints
description: Admin-only multipart upload endpoints for category icons and challenge files.
description: Admin-only multipart upload endpoints for site logos, category icons, and challenge files.
tags: [api, uploads, multipart, admin]
timestamp: 2026-07-21T18:28:00Z
timestamp: 2026-07-22T13:05:30Z
---
# Endpoints
| Method | Path | Auth | Source |
|--------|---------------------------------------|-------|---------------------------------------------------------|
| `POST` | `/api/v1/uploads/category-icon` | Admin | `backend/src/modules/uploads/uploads.controller.ts` |
| `POST` | `/api/v1/uploads/logo` | Admin | `backend/src/modules/uploads/uploads.controller.ts` |
| `POST` | `/api/v1/uploads/category-icon` | Admin | Same. |
| `POST` | `/api/v1/uploads/challenge-file` | Admin | Same. |
# Guard chain
Both handlers are gated by `JwtAuthGuard` + `AdminGuard`. CSRF is enforced
(standard cookie + header pattern; nothing is added to the skip list).
All handlers are gated by the controller-level `AdminGuard` and `admin` role.
The global authentication and CSRF protections also apply; clients must send a
valid admin session and the standard CSRF cookie/header pair.
# Behavior
* Each endpoint uses `FileInterceptor('file')` and parses a single
multipart part named `file`.
* File size is validated by `parseUploadSizeLimit()` in
`backend/src/common/utils/upload.ts`.
* Filenames are sanitized by the same helper before being persisted
under `UPLOAD_DIR`.
* Each endpoint uses `FileInterceptor('file')` and parses a single multipart
part named `file`.
* File size is validated against `UPLOAD_SIZE_LIMIT` through
`parseUploadSizeLimit()` in `backend/src/common/utils/upload.ts`.
* Category icons are written under `UPLOAD_DIR/icons`; when `categoryId` is
supplied, the image is cropped to a 128-by-128 PNG and exposed at
`/uploads/icons/{categoryId}.png`.
* Challenge attachments are written under `UPLOAD_DIR/challenges` and exposed
at `/uploads/challenges/{safeFilename}`.
* Site logos are decoded with Sharp and accepted only when their actual format
is PNG, JPEG, GIF, or WebP. The multipart MIME type and filename extension
are not trusted for validation.
* An accepted logo keeps its sanitized filename, is written directly under
`UPLOAD_DIR`, and returns `{ publicUrl, originalFilename }`, with `publicUrl`
in the form `/uploads/{safeFilename}`.
* A missing `file` part, an oversized upload, a corrupt image, or a decoded logo
in another format returns `400 Bad Request`. Invalid logo payloads use the
message `Logo must be a valid PNG, JPEG, GIF, or WebP image.` and are not
persisted.
# Examples
1. Sign in as an administrator and navigate to `/admin/general`.
2. In the site-logo setting, choose a PNG, JPEG, GIF, or WebP image and save the
general settings.
3. The client uploads the selected file to `POST /api/v1/uploads/logo` before
saving the returned `/uploads/...` URL as the configured logo.
4. After a successful save, the uploaded image is available from its returned
public URL and is used wherever the configured site logo is rendered.
5. Choosing a corrupt file, or a valid image in an unsupported format, leaves
the file unstored and surfaces the upload validation failure to the user.
# Key files
| File | Responsibility |
|---|---|
| `backend/src/modules/uploads/uploads.controller.ts` | Registers upload routes, validates payloads, and persists files. |
| `backend/src/common/utils/upload.ts` | Parses size limits and sanitizes upload filenames. |
| `frontend/src/app/features/admin/general/admin-general.component.ts` | Provides the administrator-facing logo selection and settings workflow. |
| `tests/backend/uploads-logo.spec.ts` | Verifies accepted logo formats, spoofed/corrupt payload rejection, size limits, and persisted responses. |
# See also
- [Admin — General Settings](/guides/admin-general-settings.md)
- [REST API Overview](/api/rest-overview.md)
- [Backend Module Map](/architecture/backend-modules.md)
+2 -1
View File
@@ -1,7 +1,7 @@
---
type: architecture
title: Key Files Index
description: One-line responsibility for important source files, including authenticated event streaming.
description: One-line responsibility for important source files, including authenticated event streaming and validated site-logo uploads.
tags: [architecture, index, key-files]
timestamp: 2026-07-22T10:32:00Z
---
@@ -22,6 +22,7 @@ timestamp: 2026-07-22T10:32:00Z
| `backend/src/modules/system/system.service.ts` | Builds the public bootstrap payload. |
| `backend/src/modules/auth/auth.controller.ts` | Registers authentication and account endpoints. |
| `backend/src/modules/auth/auth.service.ts` | Handles sessions, authentication, registration, and password changes. |
| `backend/src/modules/uploads/uploads.controller.ts` | Registers admin-only multipart uploads, including Sharp-backed site-logo format validation. |
# Frontend
+1 -1
View File
@@ -49,7 +49,7 @@ they need. Last regenerated 2026-07-22T12:44:45Z.
* [System Endpoints](/api/system.md) - Bootstrap, event status, public SSE
streams, public event-window settings, and the authenticated
`/events/status` SSE stream.
* [Uploads Endpoints](/api/uploads.md) - Admin-only multipart uploads.
* [Uploads Endpoints](/api/uploads.md) - Admin-only multipart uploads for category icons, challenge files, and validated site logos.
# Guides
+53 -1
View File
@@ -6,6 +6,7 @@ process.env.UPLOAD_SIZE_LIMIT = '1mb';
import * as fs from 'fs';
import * as path from 'path';
import sharp from 'sharp';
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
@@ -117,11 +118,18 @@ describe('Uploads endpoint - /uploads/logo (admin only)', () => {
it('uploads a small image and returns publicUrl + originalFilename', async () => {
const { agent, csrf } = await primeCsrf();
const originalName = `My Logo ${Date.now()}.PNG`;
// Generate a real, decodable PNG with sharp so the upload path
// exercises its full image-validation policy.
const png = await sharp({
create: { width: 32, height: 32, channels: 3, background: { r: 10, g: 20, b: 30 } },
})
.png()
.toBuffer();
const res = await agent
.post('/api/v1/uploads/logo')
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrf)
.attach('file', Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), originalName)
.attach('file', png, originalName)
.expect(201);
expect(res.body).toHaveProperty('publicUrl');
@@ -155,4 +163,48 @@ describe('Uploads endpoint - /uploads/logo (admin only)', () => {
.attach('file', big, 'big.png')
.expect(400);
});
it('rejects a plain-text payload labeled as an image', async () => {
const { agent, csrf } = await primeCsrf();
const res = await agent
.post('/api/v1/uploads/logo')
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrf)
.attach('file', Buffer.from('not an image'), { filename: 'logo.png', contentType: 'image/png' });
expect(res.status).toBe(400);
expect(res.body?.message).toMatch(/valid PNG|JPEG|GIF|WebP/i);
const after = fs.existsSync(process.env.UPLOAD_DIR!)
? fs.readdirSync(process.env.UPLOAD_DIR!)
: [];
expect(after.some((f) => f.endsWith('.png') && f.includes('logo'))).toBe(false);
});
it('rejects a corrupt GIF payload', async () => {
const { agent, csrf } = await primeCsrf();
// GIF89a header followed by a truncated/invalid body.
const corruptGif = Buffer.concat([
Buffer.from('GIF89a', 'ascii'),
Buffer.alloc(8, 0x00),
Buffer.from([0xff, 0xff, 0xff]),
]);
const res = await agent
.post('/api/v1/uploads/logo')
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrf)
.attach('file', corruptGif, { filename: 'malformed.gif', contentType: 'image/gif' });
expect(res.status).toBe(400);
expect(res.body?.message).toMatch(/valid PNG|JPEG|GIF|WebP/i);
});
it('rejects an 8-byte corrupt PNG payload', async () => {
const { agent, csrf } = await primeCsrf();
const res = await agent
.post('/api/v1/uploads/logo')
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrf)
.attach('file', Buffer.alloc(8, 0x00), { filename: 'bad.png', contentType: 'image/png' });
expect(res.status).toBe(400);
expect(res.body?.message).toMatch(/valid PNG|JPEG|GIF|WebP/i);
});
});