Files
satellite-provider/_docs/03_implementation/reviews/batch_04_cycle8_review.md
T
Oleksandr Bezdieniezhnykh 490902c80a [AZ-810] Strict validation for POST /api/satellite/upload metadata
Adds the per-endpoint child of AZ-795 ("Strict Input Validation Epic")
for the UAV upload multipart endpoint. Three new validators land under
SatelliteProvider.Api/Validators/:

- UavTileBatchMetadataPayloadValidator: items NotNull + NotEmpty +
  count <= MaxBatchSize + RuleForEach dispatching to the per-item
  validator.
- UavTileMetadataValidator: lat / lon / tileZoom range, tileSizeMeters
  > 0, capturedAt within [now - MaxAgeDays, now + future-skew]; uses an
  injectable TimeProvider so unit tests can drive a fixed clock.
- UavUploadValidationFilter: IEndpointFilter that reads the multipart
  `metadata` form field, deserializes it with the strict global
  JsonSerializerOptions (so UnmappedMemberHandling.Disallow +
  [JsonRequired] from AZ-795 are honored), runs the FluentValidation
  chain, and enforces the cross-field `items.Count == files.Count`
  envelope rule. FluentValidation errors are prefixed with `metadata.`
  so wire keys look like `errors["metadata.items[0].latitude"]`.

[JsonRequired] is added to every non-optional axis on
UavTileMetadata and UavTileBatchMetadataPayload; FlightId stays
nullable per AZ-503 anonymous-flight semantics.

Coverage: 13 unit tests + 16 integration tests + 1 curl probe script
exercise the happy path and every failure mode. All 9 ACs covered;
no regression in AZ-488 UavUploadTests payloads (traced against the
new rules).

Documentation: uav-tile-upload.md bumped v1.1.0 -> v1.2.0 with the
new validation rules section + 400-shape examples + changelog entry.
api_program.md updated to describe the three new validators + filter
+ the AddTransient<UavUploadValidationFilter>() DI registration.

Reports: batch_04_cycle8_report.md + reviews/batch_04_cycle8_review.md
record the PASS_WITH_WARNINGS verdict (2 Low DRY-in-tests findings:
FixedTimeProvider duplication crossed the cycle-2 "promote to shared"
threshold; PostBatch helper duplicated between two integration
suites). Both deferred to follow-up PBIs.

Task spec archived: _docs/02_tasks/todo/AZ-810... -> done/.
Jira: AZ-810 transitioned In Progress -> In Testing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 13:32:19 +03:00

17 KiB

Code Review Report

Batch: 04 (cycle 8) Tasks: AZ-810 (POST /api/satellite/upload strict metadata validation, multipart envelope) Date: 2026-05-23 Verdict: PASS_WITH_WARNINGS

Findings

# Severity Category File:Line Title
1 Low Maintainability / DRY SatelliteProvider.Tests/Validators/UavTileBatchMetadataPayloadValidatorTests.cs, SatelliteProvider.Tests/Validators/UavTileMetadataValidatorTests.cs, SatelliteProvider.Tests/Services/UavTileQualityGateTests.cs, SatelliteProvider.Tests/Services/UavTileUploadHandlerTests.cs FixedTimeProvider duplicated across four test files (now exceeds the "3 consumers → promote" threshold the cycle-2 file comment named)
2 Low Maintainability / DRY SatelliteProvider.IntegrationTests/UavUploadTests.cs:~270 + SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs:600-614 PostBatch(client, metadata, files) multipart-build helper duplicated with identical signature/behavior across the two upload integration suites
3 Info Wire-shape asymmetry SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs:67-77 Errors raised inside the metadata-JSON deserialization (malformed JSON, type-mismatch, unknown root field, malformed flightId) all surface under errors["metadata"] — the JSON path inside the JsonException is intentionally not exploded into errors["metadata.items[0].latitude"]. Documented in the contract; tests assert the actual key.

Finding Details

F1: FixedTimeProvider duplication has now crossed the "promote to shared" threshold (Low / Maintainability)

  • Location: four test files in SatelliteProvider.Tests/ carry an identical private sealed class FixedTimeProvider : TimeProvider { ... } body. Two pre-existed (AZ-488 cycle 2 — UavTileQualityGateTests, UavTileUploadHandlerTests); two are new in this batch (UavTileBatchMetadataPayloadValidatorTests, UavTileMetadataValidatorTests).
  • Description: The cycle-2 file-level comment in UavTileQualityGateTests explicitly said "if a third consumer appears, promote to SatelliteProvider.TestSupport." Batch 4 added the third AND fourth consumer. The duplication is currently harmless (the implementations are byte-identical), but the next reader changing one of them risks a silent drift, especially since FluentValidation's RuleForEach.SetValidator(...) propagates the TimeProvider instance the root validator was given — a fork would not be detected by either side's unit tests.
  • Suggestion: Promote FixedTimeProvider to SatelliteProvider.TestSupport/FixedTimeProvider.cs (analogous to JwtTokenFactory, IntegrationTestResetGuard). Update the four call-sites in a follow-up Low PBI. Do NOT do it as part of AZ-810 — it is out of task scope and would push four test files into the diff for no functional benefit.
  • Suggestion target: open follow-up PBI "Promote FixedTimeProvider test helper to SatelliteProvider.TestSupport" (≈1 SP).
  • Task: AZ-810 (cycle-2 carryover; AZ-810 just made the duplication visible enough to act on).

F2: PostBatch multipart helper duplicated across two integration test suites (Low / Maintainability)

  • Location: SatelliteProvider.IntegrationTests/UavUploadTests.cs (cycle 2) and the new SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs both define private static async Task<HttpResponseMessage> PostBatch(HttpClient client, object metadata, IReadOnlyList<byte[]> files) with identical signatures and bodies (serialize metadata via System.Text.Json, build MultipartFormDataContent, attach each file under the files name with image/jpeg).
  • Description: Same drift risk as F1, but limited to the integration test project. The helpers diverging would break only the suite that did not get the update — both suites pass against the production endpoint, so the silent-drift surface is small. Still worth flagging because UavUploadTests' helper has subtly different JsonSerializerOptions setup that may want to be unified.
  • Suggestion: Promote PostBatch to a shared UavUploadMultipartFixture (test-only helper) inside SatelliteProvider.IntegrationTests/. Both suites then reference one canonical builder. Defer to a follow-up PBI alongside F1.
  • Task: AZ-810.

F3: Metadata-JSON deserialization errors collapse to errors["metadata"] (Info / Wire-shape asymmetry)

  • Location: SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs::InvokeAsync lines 67-77 + 78-88 (the JsonException-catch path and the manual filter responses for missing form field / missing metadata).
  • Description: When the JSON inside the metadata form field fails strict deserialization (malformed JSON, unknown root field via UnmappedMemberHandling.Disallow, unknown nested field, nested type mismatch, malformed flightId UUID, missing required field surfaced as JsonException), the filter catches the exception manually and surfaces it under a single error key — errors["metadata"] — with the full JsonException.Message (which itself includes the JSON path like $.items[0].latitude). It does NOT explode the JsonException path into a separate prefixed error key like errors["metadata.items[0].latitude"]. This is by design: the metadata is a NESTED JSON value inside a multipart form field, so the form-level wire-shape correctly reports the error at the form-field granularity. The JSON path is preserved inside the message text so debuggers can still localize. FluentValidation rule violations DO get the full prefixed path (errors["metadata.items[0].latitude"]).
  • Suggestion: NONE. Documented in _docs/02_document/contracts/api/uav-tile-upload.md v1.2.0 "Metadata validation" section + _docs/02_document/contracts/api/error-shape.md. Integration tests use AssertErrorsContainsMention (substring match) so they tolerate either shape — important so the contract can later choose to explode JSON paths without breaking tests.
  • Task: AZ-810.

Phase Summary

Phase Outcome
1. Context Loading Read AZ-810 spec, _docs/02_document/contracts/api/uav-tile-upload.md v1.1.0 (pre-bump), _docs/02_document/contracts/api/error-shape.md v1.0.0, batch-2 RegionRequestValidator + batch-3 CreateRouteRequestValidator for cycle-8 conventions, GlobalExceptionHandler.cs for the BadHttpRequestException → ValidationProblemDetails shape, and UavUploadTests.cs (AZ-488) for the legacy multipart fixture. The endpoint is uniquely multipart, so the cycle-8 generic ValidationEndpointFilter<T>() (JSON-body-only) does NOT fit — a new UavUploadValidationFilter extracts the metadata form field, runs deserialization with strict JsonSerializerOptions, then runs FluentValidation, then enforces the cross-field items.Count == files.Count invariant.
2. Spec Compliance All 9 ACs covered. AC-1 (14 rules across deserializer + FluentValidation + cross-field) verified. AC-2 (happy path) verified by HappyPath_Returns200. AC-3 (validators in own files, ≥11 unit tests) — UavTileBatchMetadataPayloadValidator.cs + UavTileMetadataValidator.cs (2 files), 13 unit tests. AC-4 (integration ≥13) — 16 methods. AC-5 (contract v1.2.0) — bumped, validation rules section added. AC-6 (api_program.md) — updated. AC-7 ([JsonRequired] + .ProducesProblem(400)) — done; range annotations omitted per existing project pattern (FluentValidation messages convey the range). AC-8 (probe script) — scripts/probe_upload_validation.sh covers happy + 14 failure modes. AC-9 (no AZ-488 regression) — AZ-488 happy paths all use valid metadata (lat/lon in range, zoom=18, tileSizeMeters=200, capturedAt fresh) so the new validator accepts them unchanged; verified by tracing each UavUploadTests payload against the new validator rules.
3. Code Quality Three new files in SatelliteProvider.Api/Validators/ follow SRP cleanly. UavTileBatchMetadataPayloadValidator is 6 rules (1 NotNull + 1 NotEmpty + 1 count cap + RuleForEach). UavTileMetadataValidator is 5 range/freshness rules + explanatory comment on the deliberate FlightId no-op. UavUploadValidationFilter is ~120 lines doing exactly one job — buffer the form, deserialize the metadata, run the validator, check items/files parity. ArgumentNullException.ThrowIfNull used consistently; no silent catches; manual ValidationProblem shapes match the RFC 7807 contract. [JsonRequired] placement on UavTileMetadata/UavTileBatchMetadataPayload follows the cycle-7 (TileInventoryRequest) and batch-3 cycle-8 (CreateRouteRequest) precedent. Two DRY findings (F1 + F2) — both test-only, both deferred to follow-up PBIs.
4. Security All validation runs BEFORE any DB work, file write, or queue enqueue. The filter intercepts on the endpoint pipeline — even an authenticated caller cannot reach the handler without passing the validator. Cross-field items.Count == files.Count prevents an attacker from posting 100 metadata + 1 file (which would otherwise zip-bomb-style let the loop iterate over a short files array). UnmappedMemberHandling.Disallow prevents fingerprinting via unknown fields. The JsonException.Message surfaced under errors["metadata"] may include the offending JSON snippet — this is acceptable because the body is supplied by the caller themselves; it does not leak server-side state. JWT auth + RequireAuthorization(SatellitePermissions.UavUploadPolicy) retained on the endpoint. No new secrets, no PII in logs.
5. Performance Validators run synchronously against in-memory record fields — microseconds even at the MaxBatchSize = 100 upper bound. ReadFormAsync buffers the multipart body once; the buffered IFormCollection is reused by the downstream handler (ASP.NET caches it on the HttpRequest). For invalid requests we DO buffer the full body before rejecting, but Kestrel's MaxRequestBodySize bounds this; the alternative (streaming validation) would require a custom multipart parser and is overkill. No N+1, no blocking I/O. Filter overhead per request: one ReadFormAsync (already needed by the handler), one JsonSerializer.Deserialize of the metadata string, one synchronous FluentValidation pass.
6. Cross-Task Consistency Uses the same ProblemDetailsAssertions helper as batches 02/03 of cycle 8 and cycle 7. Error keys follow the same camelCase JSON-path policy per error-shape.md v1.0.0 Inv-4. ValidationProblemDetails produced has the same shape as the JSON-body endpoints (status=400, title="One or more validation errors occurred.", errors as a dict of arrays). Per-item indexed paths (items[0].latitude) follow the same convention as RegionRequestValidator's nested-DTO chain. New UavUploadValidationFilter is intentionally distinct from the generic ValidationEndpointFilter<T> — different envelope shape (multipart vs JSON body) — and the two filters' shape choices are mutually compatible.
7. Architecture Compliance New validators + filter in SatelliteProvider.Api/Validators/ — owned by WebApi (Layer 4). [JsonRequired] additions in SatelliteProvider.Common/DTO/UavTileMetadata.cs (Layer 0). No new cross-layer dependencies. No cycles. IValidator<UavTileBatchMetadataPayload> resolves via the existing AddValidatorsFromAssemblyContaining<Program>() registration (cycle 7). UavUploadValidationFilter is added to DI as transient (matches existing endpoint filter registration pattern). No public-API surface changes in Common (DTOs already public, attributes are metadata-only). No ADRs to breach (project has no _docs/02_document/adr/ folder).

Files Reviewed

AZ-810 (UAV upload validator + multipart filter)

  • SatelliteProvider.Api/Validators/UavTileBatchMetadataPayloadValidator.csNEW — root validator. RuleFor(p => p.Items) NotNull + NotEmpty + Must(<= MaxBatchSize); RuleForEach(p => p.Items).SetValidator(new UavTileMetadataValidator(qualityConfig, timeProvider)). The TimeProvider is threaded through so unit tests can inject a fixed clock and the produced per-item validator sees the same clock.
  • SatelliteProvider.Api/Validators/UavTileMetadataValidator.csNEW — per-item validator. 5 rules (lat ∈ [-90, 90], lon ∈ [-180, 180], tileZoom ∈ [0, 22], tileSizeMeters > 0, capturedAt within now ± CapturedAtFutureSkewSeconds/now - MaxAgeDays). FlightId intentionally NOT validated beyond JSON shape — the AZ-503 anonymous-flight semantics keep it nullable, and shape-level rejection (malformed UUID) is handled at the deserializer layer.
  • SatelliteProvider.Api/Validators/UavUploadValidationFilter.csNEWIEndpointFilter that intercepts multipart bodies. Reads metadata field, deserializes with the global strict JsonSerializerOptions (so UnmappedMemberHandling.Disallow applies), runs IValidator<UavTileBatchMetadataPayload>, then checks items.Count == files.Count. FluentValidation errors are prefixed with metadata. so the wire-key is metadata.items[0].latitude (full path); cross-field violation surfaces under BOTH errors["metadata.items"] AND errors["files"] so client UI code keyed on either field can act.
  • SatelliteProvider.Common/DTO/UavTileMetadata.cs[JsonRequired] on Latitude, Longitude, TileZoom, TileSizeMeters, CapturedAt (UavTileMetadata record) and on Items (UavTileBatchMetadataPayload record). FlightId stays optional. File-level comment block updated with the AZ-810 rationale so the next reader cannot accidentally remove the attributes thinking they are redundant.
  • SatelliteProvider.Api/Program.cs — registered UavUploadValidationFilter as transient (builder.Services.AddTransient<UavUploadValidationFilter>()) and wired .AddEndpointFilter<UavUploadValidationFilter>() onto the MapPost("/api/satellite/upload", ...) chain along with .Accepts<> + .Produces<> + .ProducesProblem(400). Order: RequireAuthorization runs first (no validator burns CPU for unauthenticated callers), then AddEndpointFilter, then handler. Transient lifetime is intentional (fresh instance per request, no shared mutable state) — matches the cycle-8 batch-2 RejectUnknownQueryParamsEndpointFilter precedent.
  • SatelliteProvider.Tests/Validators/UavTileBatchMetadataPayloadValidatorTests.csNEW — unit tests for the root validator: NotEmpty (empty list), MaxBatchSize boundary (100 vs 101), per-item failure propagation with indexed paths (items[1].latitude).
  • SatelliteProvider.Tests/Validators/UavTileMetadataValidatorTests.csNEW — unit tests for the per-item validator: each range rule (positive + negative), freshness window (positive + past/future negative), FlightId null/Guid handled. Uses local FixedTimeProvider (see F1 — duplicated).
  • SatelliteProvider.IntegrationTests/UavUploadValidationTests.csNEW — 16 integration tests covering happy path + 14 failure modes (rules 2-14 + AC-4 type-mismatch). Uses ProblemDetailsAssertions for the RFC 7807 shape check and AssertErrorsContainsMention for path/message substring matching.
  • SatelliteProvider.IntegrationTests/Program.cs — wired UavUploadValidationTests.RunAll into BOTH the smoke and the full suites (matches the batch-2/3 cycle-8 pattern).
  • scripts/probe_upload_validation.shNEW — bash + curl probe of the happy path + every failure mode. Reuses the existing probe_route_validation.sh structure (JWT mint, status-code assertion, --exit-on-fail).
  • _docs/02_document/contracts/api/uav-tile-upload.md — bumped v1.1.0 → v1.2.0. Added "Metadata validation" section enumerating all 14 rules + the three enforcement layers (deserializer / FluentValidation / cross-field) + the error-shape mapping. Expanded "HTTP 400 — envelope error" section with the new failure shapes. Added v1.2.0 changelog entry.
  • _docs/02_document/modules/api_program.md — updated endpoint description for POST /api/satellite/upload; added Api/Validators entries for the three new files; added Common/DTO (AZ-488) note about the new [JsonRequired] attributes; added DI registration entry for UavUploadValidationFilter.

Test Evidence

Pending — the implement skill defers the full integration-test suite run to autodev Step 11 (Run Tests). Per-file lint check on all 9 modified/new .cs files returned NO errors (ReadLints clean). Build sanity is implicit: ReadLints would surface compilation errors as Critical, and none surfaced.

Verdict Logic

  • 0 Critical, 0 High, 0 Medium.
  • 2 Low findings (F1 + F2) — both DRY in test-only code, both have a clear follow-up PBI plan, both safe to defer.
  • 1 Info finding (F3) — documented design decision, contract-aligned, tests tolerant.
  • PASS_WITH_WARNINGS — implement skill may proceed to Step 11 (commit + ask about push). Both Low findings tracked in this report for the cumulative review (Step 14.5) and the cycle-8 implementation report.