Fix validation parity regressions

This commit is contained in:
nessar committed 2026-08-17 14:18:17 +02:00
1 parent 15c30986aa
commit 84c767b07e
6 files changed
+98 -29

No files matched your search

@@ -59,8 +59,8 @@ ResponseEntity<CodedApiError> incompleteApartmentLegalProfile(
}
@ExceptionHandler(IllegalArgumentException.class)
ResponseEntity<ApiError> invalidArgument() {
return error(HttpStatus.BAD_REQUEST, "La requête est invalide.");
ResponseEntity<CodedApiError> 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<ApiError> error(HttpStatus status, String message) {
return ResponseEntity.status(status).body(new ApiError(message));
}
private static ResponseEntity<CodedApiError> error(
HttpStatus status, String code, String message) {
return ResponseEntity.status(status).body(new CodedApiError(code, message));
@@ -234,8 +230,6 @@ private static ResponseEntity<ValidationApiError> 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<FieldValidationError> errors) {}
@@ -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(
@@ -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
+3 -2
View File
@@ -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.
@@ -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.');
});
});
@@ -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<string> {
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<str
if (!file.size) return 'La photo sélectionnée est vide.';
if (maxBytes && file.size > 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 {