Files
satellite-provider/scripts/probe_upload_validation.sh
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

154 lines
7.9 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# Manual end-to-end probe for POST /api/satellite/upload strict metadata
# validation (AZ-810). Each failure call should return HTTP 400 with an
# `application/problem+json` body. The happy-path call should return HTTP 200
# with the per-item result envelope.
#
# Three enforcement layers compose at the endpoint:
# 1. UnmappedMemberHandling.Disallow + [JsonRequired] on the metadata DTO
# — the UavUploadValidationFilter deserializes the `metadata` form field
# via the strict global JsonSerializerOptions and surfaces JsonException
# under `errors["metadata"]`. Covers missing-required, unknown fields,
# type mismatches, malformed UUIDs (AZ-810 rules 3, 12, 13, 14).
# 2. FluentValidation (UavTileBatchMetadataPayloadValidator +
# UavTileMetadataValidator) — per-item range checks (lat, lon, tileZoom,
# tileSizeMeters, capturedAt freshness) and the items.Count <=
# MaxBatchSize cap. Errors are prefixed with `metadata.` so paths look
# like `errors["metadata.items[0].latitude"]`. Covers AZ-810 rules 4-5,
# 7-11.
# 3. Cross-field envelope rule (items.Count == files.Count) — surfaces
# under both `errors["metadata.items"]` AND `errors["files"]`. Covers
# AZ-810 rule 6.
#
# Auth: the endpoint requires JWT bearer + the `permissions` claim must
# contain `GPS` (AZ-487 / AZ-488). Mint a token via:
# dotnet run --project SatelliteProvider.IntegrationTests -- --mint-only
# then jq the `permissions` claim into the GPS group, or use the GPS-specific
# minter helper if one is exposed.
#
# Usage:
# API_URL=https://localhost:8080 JWT="<bearer-token-with-GPS-permission>" \
# ./scripts/probe_upload_validation.sh
API_URL="${API_URL:-https://localhost:8080}"
JWT="${JWT:-}"
ENDPOINT="${API_URL%/}/api/satellite/upload"
TMPDIR="${TMPDIR:-/tmp}"
JPEG_PATH="${TMPDIR}/probe_upload_validation.jpg"
if [[ -z "${JWT}" ]]; then
echo "ERROR: set JWT env var to a bearer token whose 'permissions' claim contains 'GPS'."
echo " Mint a default token (no GPS claim) via:"
echo " dotnet run --project SatelliteProvider.IntegrationTests -- --mint-only"
echo " Then attach the GPS permission claim manually or use a GPS-specific minter."
exit 2
fi
# Emit a tiny valid JPEG (FF D8 FF D9 = empty SOI/EOI; the endpoint's
# UavTileQualityGate Rule 1 only inspects the magic bytes, not full decode,
# but Rules 2 / 3 / 5 will reject it. Since AZ-810 validation runs BEFORE the
# quality gate, the validator's verdict is what we're probing here. A tiny
# placeholder keeps multipart bodies small.)
printf '\xff\xd8\xff\xd9' > "${JPEG_PATH}"
curl_args=(-sS -k -H "Authorization: Bearer ${JWT}")
probe() {
local label="$1"
local metadata="$2"
local files_arg="$3" # quoted -F arg-list, e.g. -F 'files=@/tmp/x.jpg;type=image/jpeg'
local expected_status="$4"
echo "----- ${label} (expecting HTTP ${expected_status}) -----"
local response
# shellcheck disable=SC2086
response=$(curl "${curl_args[@]}" -X POST \
-F "metadata=${metadata}" \
${files_arg} \
"${ENDPOINT}" -w "\nHTTP_STATUS=%{http_code}\n")
echo "${response}"
local actual_status
actual_status=$(echo "${response}" | tail -n 1 | sed 's/HTTP_STATUS=//')
if [[ "${actual_status}" != "${expected_status}" ]]; then
echo "FAIL: expected HTTP ${expected_status}, got ${actual_status}"
return 1
fi
echo "OK: HTTP ${expected_status}"
echo
}
# AC-2: happy path (well-formed envelope + 1 file)
happy_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}]}'
probe "happy-path" "${happy_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 200
# Rule 2: missing metadata form field
echo "----- missing-metadata-field (expecting HTTP 400) -----"
response=$(curl "${curl_args[@]}" -X POST \
-F "files=@${JPEG_PATH};type=image/jpeg" \
"${ENDPOINT}" -w "\nHTTP_STATUS=%{http_code}\n")
echo "${response}"
actual_status=$(echo "${response}" | tail -n 1 | sed 's/HTTP_STATUS=//')
if [[ "${actual_status}" != "400" ]]; then
echo "FAIL: expected HTTP 400, got ${actual_status}"
exit 1
fi
echo "OK: HTTP 400"
echo
# Rule 3: malformed metadata JSON
probe "malformed-json" '{"items": [{ "latitude": 50.10, "longitude": 36.10' \
"-F files=@${JPEG_PATH};type=image/jpeg" 400
# Rule 4: empty items
probe "empty-items" '{"items": []}' "" 400
# Rule 6: items.Count != files.Count (2 items, 1 file)
mismatch_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"},{"latitude":50.11,"longitude":36.11,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}]}'
probe "items-files-mismatch" "${mismatch_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400
# Rule 7: lat out of range
lat_metadata='{"items":[{"latitude":91.0,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}]}'
probe "lat-out-of-range" "${lat_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400
# Rule 8: lon out of range
lon_metadata='{"items":[{"latitude":50.10,"longitude":181.0,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}]}'
probe "lon-out-of-range" "${lon_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400
# Rule 9: tileZoom out of range
zoom_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":30,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}]}'
probe "tileZoom-out-of-range" "${zoom_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400
# Rule 10: tileSizeMeters non-positive
size_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":0.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}]}'
probe "tileSizeMeters-non-positive" "${size_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400
# Rule 11a: capturedAt in the future (use a date 1 year out for portability)
future_iso="$(date -u -v+1y +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -d '+1 year' +"%Y-%m-%dT%H:%M:%SZ")"
future_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"${future_iso}"'"}]}'
probe "capturedAt-future" "${future_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400
# Rule 11b: capturedAt too old (60 days)
old_iso="$(date -u -v-60d +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -d '60 days ago' +"%Y-%m-%dT%H:%M:%SZ")"
old_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"${old_iso}"'"}]}'
probe "capturedAt-too-old" "${old_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400
# Rule 12: malformed flightId UUID
flight_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'","flightId":"not-a-uuid"}]}'
probe "flightId-malformed" "${flight_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400
# Rule 13: unknown root field
unknown_root_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}],"debug":"fingerprint"}'
probe "unknown-root-field" "${unknown_root_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400
# Rule 13b: unknown nested field
unknown_nested_metadata='{"items":[{"latitude":50.10,"longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'","altitude":500.0}]}'
probe "unknown-nested-field" "${unknown_nested_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400
# Rule 14: type mismatch (latitude as string)
type_mismatch_metadata='{"items":[{"latitude":"fifty","longitude":36.10,"tileZoom":18,"tileSizeMeters":200.0,"capturedAt":"'"$(date -u +"%Y-%m-%dT%H:%M:%SZ")"'"}]}'
probe "lat-type-mismatch" "${type_mismatch_metadata}" "-F files=@${JPEG_PATH};type=image/jpeg" 400
echo "All probes passed."