diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentalApiExceptionHandler.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentalApiExceptionHandler.java index 3b4be0e..71ab5f0 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentalApiExceptionHandler.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentalApiExceptionHandler.java @@ -59,8 +59,8 @@ ResponseEntity incompleteApartmentLegalProfile( } @ExceptionHandler(IllegalArgumentException.class) -ResponseEntity invalidArgument() { - return error(HttpStatus.BAD_REQUEST, "La requête est invalide."); +ResponseEntity invalidArgument() { + return error(HttpStatus.BAD_REQUEST, "invalid_request", "La requête est invalide."); } @ExceptionHandler(PropertyConditionReportRuleViolation.class) @@ -196,10 +196,6 @@ private static String field(String path) { return separator < 0 ? path : path.substring(separator + 1); } -private static ResponseEntity error(HttpStatus status, String message) { - return ResponseEntity.status(status).body(new ApiError(message)); -} - private static ResponseEntity error( HttpStatus status, String code, String message) { return ResponseEntity.status(status).body(new CodedApiError(code, message)); @@ -234,8 +230,6 @@ private static ResponseEntity validation(String field, Strin "validation_failed", java.util.List.of(new FieldValidationError(field, code)))); } -record ApiError(String message) {} - record CodedApiError(String code, String message) {} record ValidationApiError(String code, java.util.List errors) {} diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/RentalWebAdaptersTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/RentalWebAdaptersTest.java index aeef3ad..89892f0 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/RentalWebAdaptersTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/RentalWebAdaptersTest.java @@ -1001,6 +1001,7 @@ void internalExceptionMessagesAreNeverReturned() throws Exception { mockMvc .perform(get("/api/apartments/apartment-1").with(asLandlord())) .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("invalid_request")) .andExpect(jsonPath("$.message").value("La requête est invalide.")); reset(apartments); @@ -1093,6 +1094,7 @@ void rentalExceptionsMapNotFoundValidationAndConflictStatuses() throws Exception "receivedAt":"2026-02-02","timeZone":"Not/AZone","amount":1000} """)) .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("invalid_request")) .andExpect(jsonPath("$.message").value("La requête est invalide.")); mockMvc .perform( diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/PropertyConditionReportWebTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/PropertyConditionReportWebTest.java index d96ceee..0ce7ff0 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/PropertyConditionReportWebTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/PropertyConditionReportWebTest.java @@ -338,6 +338,21 @@ void uploadRejectsInvalidOrOversizedPhotosBeforeCallingTheService() throws Excep .andExpect(status().isBadRequest()) .andExpect(error("file", "invalid")); + mockMvc + .perform( + multipart("/api/leases/lease-1/condition-reports/ENTRY/photos") + .file( + new MockMultipartFile( + "file", + "wrong-signature.jpg", + MediaType.IMAGE_JPEG_VALUE, + new byte[] {(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A})) + .param("zoneId", "living-room:floor") + .with(landlord()) + .with(csrf())) + .andExpect(status().isBadRequest()) + .andExpect(error("file", "invalid")); + mockMvc .perform( multipart("/api/leases/lease-1/condition-reports/ENTRY/photos") @@ -352,6 +367,32 @@ void uploadRejectsInvalidOrOversizedPhotosBeforeCallingTheService() throws Excep verifyNoInteractions(reports); } +@Test +void uploadAcceptsTheSameHeifHeaderAsTheFrontend() throws Exception { + when(reports.uploadPhoto( + eq(LANDLORD_ID), + eq(LEASE_ID), + eq(PropertyConditionReportType.ENTRY), + eq("living-room:floor"), + any(byte[].class), + eq("subject-1"))) + .thenReturn(report()); + + mockMvc + .perform( + multipart("/api/leases/lease-1/condition-reports/ENTRY/photos") + .file( + new MockMultipartFile( + "file", + "photo.heif", + "image/heif", + new byte[] {0, 0, 0, 0, 'f', 't', 'y', 'p', 'a', 'v', 'i', 'f'})) + .param("zoneId", "living-room:floor") + .with(landlord()) + .with(csrf())) + .andExpect(status().isOk()); +} + @Test void supplementRejectsBadPdfAndIndexedBadPhotoBeforeCallingTheService() throws Exception { mockMvc diff --git a/docs/validation-parity-checklist.md b/docs/validation-parity-checklist.md index e09c352..e3b5feb 100644 --- a/docs/validation-parity-checklist.md +++ b/docs/validation-parity-checklist.md @@ -13,6 +13,7 @@ validation framework, state-management mechanism, or dependency. - Request validation failures use HTTP 400 and `{ "code": "validation_failed", "errors": [{ "field": "...", "code": "..." }] }`. +- Other safely rejected requests use the stable HTTP 400 code `invalid_request`. - Conflicts use HTTP 409, oversized uploads use HTTP 413, unavailable IRL data uses HTTP 503, and unexpected failures use HTTP 500. HTTP 422 is not part of this contract. - Angular maps known field/code pairs to French inline guidance. Unknown fields/codes, network @@ -317,7 +318,7 @@ creation, rent-payment recording, and the native condition-photo control. Local verification recorded on 2026-07-26: - Prettier passed for every frontend/documentation file changed since the epic baseline. -- Frontend lint, application/e2e type checking, production build, and all 327 unit tests passed. +- Frontend lint, application/e2e type checking, production build, and all 330 unit tests passed. - All 36 Playwright journeys passed across mobile, tablet, and desktop viewports. -- `./mvnw spotless:check verify` passed: 415 unit/web tests, 16 Docker-backed integration tests, +- `./mvnw spotless:check verify` passed: 416 unit/web tests, 16 Docker-backed integration tests, packaging, and coverage checks. diff --git a/frontend/src/app/components/condition-report-page/condition-report-file-validation.spec.ts b/frontend/src/app/components/condition-report-page/condition-report-file-validation.spec.ts index 6a1afc9..099fd90 100644 --- a/frontend/src/app/components/condition-report-page/condition-report-file-validation.spec.ts +++ b/frontend/src/app/components/condition-report-page/condition-report-file-validation.spec.ts @@ -1,4 +1,27 @@ -import { operationError } from './condition-report-file-validation'; +import { operationError, photoFileError } from './condition-report-file-validation'; + +describe('condition-report photo validation', () => { + it('requires the backend MIME and matching signature rules', async () => { + const jpeg = Uint8Array.from([0xff, 0xd8, 0xff]); + const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + + expect(await photoFileError(new File([jpeg], 'photo.jpg'))).toContain('JPEG'); + expect(await photoFileError(new File([png], 'photo.jpg', { type: 'image/jpeg' }))).toBe( + 'Le contenu de la photo est invalide.', + ); + expect(await photoFileError(new File([jpeg], 'photo.jpg', { type: 'image/jpeg' }))).toBe( + '', + ); + }); + + it('accepts the same HEIF header brands as the backend', async () => { + const ftyp = Uint8Array.from([0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66]); + + expect(await photoFileError(new File([ftyp], 'photo.heif', { type: 'image/heif' }))).toBe( + '', + ); + }); +}); describe('condition-report file operation errors', () => { it('maps structured field failures without exposing a server message', () => { @@ -82,4 +105,16 @@ describe('condition-report file operation errors', () => { ), ).toBe('Les informations jointes sont invalides.'); }); + + it('keeps oversized multipart failures actionable', () => { + expect( + operationError( + { + status: 413, + error: { code: 'attachment_too_large', message: 'internal detail' }, + }, + 'Échec.', + ), + ).toBe('La pièce jointe dépasse la taille maximale autorisée.'); + }); }); diff --git a/frontend/src/app/components/condition-report-page/condition-report-file-validation.ts b/frontend/src/app/components/condition-report-page/condition-report-file-validation.ts index 167fb3c..a8f2b9a 100644 --- a/frontend/src/app/components/condition-report-page/condition-report-file-validation.ts +++ b/frontend/src/app/components/condition-report-page/condition-report-file-validation.ts @@ -1,7 +1,6 @@ -import { validationErrors } from '../../services/http-error-message'; +import { httpErrorMessage, validationErrors } from '../../services/http-error-message'; const photoTypes = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif']); -const photoExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif']; export async function pdfFileError(file: File | null, maxBytes?: number): Promise { if (!file || !file.size) return 'Sélectionnez un PDF signé non vide.'; @@ -19,19 +18,16 @@ export async function photoFileError(file: File, maxBytes?: number): Promise maxBytes) return `La photo dépasse la taille autorisée (${maxBytes} octets).`; - const name = file.name.toLowerCase(); - if ( - !photoTypes.has(file.type.toLowerCase()) && - !photoExtensions.some((extension) => name.endsWith(extension)) - ) - return 'Sélectionnez une photo JPEG, PNG, WebP, HEIC ou HEIF.'; + const type = file.type.toLowerCase(); + if (!photoTypes.has(type)) return 'Sélectionnez une photo JPEG, PNG, WebP, HEIC ou HEIF.'; const bytes = new Uint8Array(await file.slice(0, 16).arrayBuffer()); - if (!isPhotoSignature(bytes)) return 'Le contenu de la photo est invalide.'; + if (!isPhotoSignature(type, bytes)) return 'Le contenu de la photo est invalide.'; return ''; } export function operationError(error: unknown, fallback: string): string { if (status(error) === 409) return 'Les données ont changé. Actualisez la page puis réessayez.'; + if (status(error) === 413) return httpErrorMessage(error, fallback); const fieldError = validationErrors(error)[0]; if (!fieldError) return fallback; if (fieldError.code === 'size_mismatch') @@ -56,21 +52,21 @@ export function operationError(error: unknown, fallback: string): string { } } -function isPhotoSignature(bytes: Uint8Array): boolean { - return ( - (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) || - (bytes[0] === 0x89 && +function isPhotoSignature(type: string, bytes: Uint8Array): boolean { + if (type === 'image/jpeg') return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff; + if (type === 'image/png') + return ( + bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && - bytes[7] === 0x0a) || - (text(bytes, 0, 4) === 'RIFF' && text(bytes, 8, 12) === 'WEBP') || - (text(bytes, 4, 8) === 'ftyp' && - ['heic', 'heix', 'hevc', 'hevx', 'mif1'].includes(text(bytes, 8, 12))) - ); + bytes[7] === 0x0a + ); + if (type === 'image/webp') return text(bytes, 0, 4) === 'RIFF' && text(bytes, 8, 12) === 'WEBP'; + return text(bytes, 4, 8) === 'ftyp'; } function text(bytes: Uint8Array, from: number, to: number): string {