#!/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="" \ # ./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."