mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 13:51:14 +00:00
490902c80a
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>
154 lines
7.9 KiB
Bash
Executable File
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."
|