Fix validation parity regressions
This commit is contained in:
1 parent
15c30986aa
commit
84c767b07e
6 files changed
+98
-29
No files matched your search
+2
-8
@@ -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) {}
|
||||
|
||||
+2
@@ -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(
|
||||
|
||||
+41
@@ -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
|
||||
|
||||
@@ -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.
|
||||
+36
-1
@@ -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.');
|
||||
});
|
||||
});
|
||||
+14
-18
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user