From 84c767b07eeac523ef1490ed312343c9f59e2ec8 Mon Sep 17 00:00:00 2001
From: Nessar
Date: Sun, 26 Jul 2026 13:55:02 +0200
Subject: [PATCH] Fix validation parity regressions
---
.../web/common/RentalApiExceptionHandler.java | 10 +----
.../adapter/in/web/RentalWebAdaptersTest.java | 2 +
.../lease/PropertyConditionReportWebTest.java | 41 +++++++++++++++++++
docs/validation-parity-checklist.md | 5 ++-
.../condition-report-file-validation.spec.ts | 37 ++++++++++++++++-
.../condition-report-file-validation.ts | 32 +++++++--------
6 files changed, 98 insertions(+), 29 deletions(-)
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 {