From f74f8f471f78763a7ae37e5278c3fadbbf87187e Mon Sep 17 00:00:00 2001 From: voltsrage Date: Fri, 26 Jun 2026 04:40:51 +0800 Subject: [PATCH] fix: ran script test for Schema, Authentication, Roles, Batch CRUD, MinIO Upload, and Status Machine --- scripts/fixtures/test-scan.pdf | 14 + scripts/run-vigilcare-records-verification.sh | 548 ++++++++++++++++++ 2 files changed, 562 insertions(+) create mode 100644 scripts/fixtures/test-scan.pdf create mode 100755 scripts/run-vigilcare-records-verification.sh diff --git a/scripts/fixtures/test-scan.pdf b/scripts/fixtures/test-scan.pdf new file mode 100644 index 0000000..02891ca --- /dev/null +++ b/scripts/fixtures/test-scan.pdf @@ -0,0 +1,14 @@ +%PDF-1.0 +1 0 obj<>endobj +2 0 obj<>endobj +3 0 obj<>endobj +xref +0 4 +0000000000 65535 f +0000000009 00000 n +0000000052 00000 n +0000000101 00000 n +trailer<> +startxref +149 +%%EOF diff --git a/scripts/run-vigilcare-records-verification.sh b/scripts/run-vigilcare-records-verification.sh new file mode 100755 index 0000000..c18c047 --- /dev/null +++ b/scripts/run-vigilcare-records-verification.sh @@ -0,0 +1,548 @@ +#!/usr/bin/env bash +# Runs Phase 1 verification checks from docs/plans/phase-1-plan.md. +# +# Prerequisites: +# docker compose up -d (PostgreSQL + Redis; used via docker compose exec) +# dotnet run --project VigilCareRecordsAPI +# +# PostgreSQL and Redis checks use host psql/redis-cli when installed, otherwise +# docker compose exec against the running compose services. +# +# Environment overrides: +# VIGILCARE_API_URL default: http://localhost:5217 +# VIGILCARE_COMPOSE_FILE default: /docker-compose.yml +# VIGILCARE_REDIS_PORT default: 6383 (host redis-cli only) +# VIGILCARE_PG_HOST default: localhost (host psql only) +# VIGILCARE_PG_PORT default: 5437 (host psql only) +# VIGILCARE_PG_DB default: vigilcare_records +# VIGILCARE_PG_USER default: postgres +# VIGILCARE_PG_PASSWORD default: password +# VIGILCARE_SKIP_DB_CHECKS set to 1 to skip PostgreSQL assertions + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +FIXTURE_PDF="$SCRIPT_DIR/fixtures/test-scan.pdf" + +API_URL="${VIGILCARE_API_URL:-http://localhost:5217}" +COMPOSE_FILE="${VIGILCARE_COMPOSE_FILE:-$REPO_ROOT/docker-compose.yml}" +COMPOSE=(docker compose -f "$COMPOSE_FILE") +REDIS_PORT="${VIGILCARE_REDIS_PORT:-6383}" +PG_HOST="${VIGILCARE_PG_HOST:-localhost}" +PG_PORT="${VIGILCARE_PG_PORT:-5437}" +PG_DB="${VIGILCARE_PG_DB:-vigilcare_records}" +PG_USER="${VIGILCARE_PG_USER:-postgres}" +PG_PASSWORD="${VIGILCARE_PG_PASSWORD:-password}" +SKIP_DB_CHECKS="${VIGILCARE_SKIP_DB_CHECKS:-0}" + +PASS_COUNT=0 +FAIL_COUNT=0 +FAILED_TESTS=() + +log() { + printf '%s\n' "$*" +} + +section() { + log "" + log "== $1 ==" +} + +pass() { + PASS_COUNT=$((PASS_COUNT + 1)) + log " PASS: $1" +} + +fail() { + FAIL_COUNT=$((FAIL_COUNT + 1)) + FAILED_TESTS+=("$1") + log " FAIL: $1" +} + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + log "ERROR: required command not found: $cmd" + exit 1 + fi +} + +compose_service_running() { + local service="$1" + "${COMPOSE[@]}" ps --status running --services 2>/dev/null | grep -qx "$service" +} + +psql_available() { + [[ "$SKIP_DB_CHECKS" == "1" ]] && return 1 + command -v psql >/dev/null 2>&1 && return 0 + compose_service_running postgres +} + +redis_available() { + command -v redis-cli >/dev/null 2>&1 && return 0 + compose_service_running redis +} + +psql_query() { + if command -v psql >/dev/null 2>&1; then + PGPASSWORD="$PG_PASSWORD" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" -Atqc "$1" + elif compose_service_running postgres; then + "${COMPOSE[@]}" exec -T postgres \ + psql -U "$PG_USER" -d "$PG_DB" -Atqc "$1" + else + return 1 + fi +} + +redis_get() { + local key="$1" + if command -v redis-cli >/dev/null 2>&1; then + redis-cli -p "$REDIS_PORT" GET "$key" + elif compose_service_running redis; then + "${COMPOSE[@]}" exec -T redis redis-cli GET "$key" + else + return 1 + fi +} + +http_code() { + curl -sS -o /dev/null -w '%{http_code}' "$@" +} + +json_post() { + local url="$1" + local body="$2" + curl -sS -X POST "$url" \ + -H 'Content-Type: application/json' \ + -d "$body" +} + +login() { + local username="$1" + local password="${2:-password}" + json_post "$API_URL/api/v1/auth/login" \ + "{\"username\":\"$username\",\"password\":\"$password\"}" +} + +extract_data_field() { + local json="$1" + local field="$2" + jq -er ".data.$field // empty" <<<"$json" +} + +extract_error_code() { + local json="$1" + jq -er '.error.code // empty' <<<"$json" +} + +upload_batch() { + local token="$1" + local patient_id="${2:-}" + local file_path="${3:-$FIXTURE_PDF}" + + local -a form_args=( + -F "file=@${file_path};type=application/pdf" + -F "batchType=VITALS_SHEET" + ) + if [[ -n "$patient_id" ]]; then + form_args+=(-F "patientId=$patient_id") + fi + + curl -sS -X POST "$API_URL/api/v1/digitization-batches" \ + -H "Authorization: Bearer $token" \ + "${form_args[@]}" +} + +assign_batch() { + local token="$1" + local batch_id="$2" + local entry_clerk_id="$3" + + curl -sS -X PATCH "$API_URL/api/v1/digitization-batches/$batch_id/assign" \ + -H "Authorization: Bearer $token" \ + -H 'Content-Type: application/json' \ + -d "{\"entryClerkUserId\":\"$entry_clerk_id\"}" +} + +decode_jwt_claim() { + local token="$1" + local claim="$2" + local payload + payload="$(printf '%s' "$token" | cut -d. -f2 | tr '_-' '/+')" + local pad=$(( (4 - ${#payload} % 4) % 4 )) + payload="${payload}$(printf '=%.0s' $(seq 1 "$pad"))" + jq -er --arg claim "$claim" '.[$claim] // empty' <<<"$(printf '%s' "$payload" | base64 -d 2>/dev/null || printf '%s' "$payload" | base64 -d)" +} + +assert_api_reachable() { + local code + code="$(http_code "$API_URL/swagger/index.html" || true)" + if [[ "$code" != "200" ]]; then + log "ERROR: API not reachable at $API_URL (HTTP $code)." + log "Start infrastructure with: docker compose up -d" + log "Start API with: dotnet run --project VigilCareRecordsAPI" + exit 1 + fi +} + +test_auth_roles_and_me() { + section "4. JWT role claims and /auth/me" + + local intake_json verifier_json me_json me_code + intake_json="$(login intake1)" + if [[ "$(jq -er '.success' <<<"$intake_json")" != "true" ]]; then + fail "intake1 login succeeds" + return + fi + pass "intake1 login succeeds" + + local intake_token intake_refresh intake_role + intake_token="$(extract_data_field "$intake_json" token)" + intake_refresh="$(extract_data_field "$intake_json" refreshToken)" + intake_role="$(extract_data_field "$intake_json" role)" + + if [[ "$intake_role" == "INTAKE_CLERK" ]]; then + pass "intake1 role claim is INTAKE_CLERK" + else + fail "intake1 role claim is INTAKE_CLERK (got: $intake_role)" + fi + + if [[ -n "$intake_refresh" ]]; then + pass "intake1 login includes refreshToken" + else + fail "intake1 login includes refreshToken" + fi + + verifier_json="$(login verifier1)" + local verifier_role + verifier_role="$(extract_data_field "$verifier_json" role)" + if [[ "$verifier_role" == "VERIFIER" ]]; then + pass "verifier1 role claim is VERIFIER" + else + fail "verifier1 role claim is VERIFIER (got: $verifier_role)" + fi + + me_code="$(http_code "$API_URL/api/v1/auth/me")" + if [[ "$me_code" == "401" ]]; then + pass "GET /auth/me without token returns 401" + else + fail "GET /auth/me without token returns 401 (got: $me_code)" + fi + + me_json="$(curl -sS "$API_URL/api/v1/auth/me" -H "Authorization: Bearer $intake_token")" + local me_role me_username + me_role="$(extract_data_field "$me_json" role)" + me_username="$(extract_data_field "$me_json" username)" + if [[ "$me_role" == "INTAKE_CLERK" && "$me_username" == "intake1" ]]; then + pass "GET /auth/me returns current user with role" + else + fail "GET /auth/me returns current user with role" + fi + + local jwt_role + jwt_role="$(decode_jwt_claim "$intake_token" "http://schemas.microsoft.com/ws/2008/06/identity/claims/role" 2>/dev/null || true)" + if [[ -z "$jwt_role" ]]; then + jwt_role="$(decode_jwt_claim "$intake_token" role 2>/dev/null || true)" + fi + if [[ "$jwt_role" == "INTAKE_CLERK" ]]; then + pass "JWT access token contains INTAKE_CLERK role claim" + else + fail "JWT access token contains INTAKE_CLERK role claim (got: ${jwt_role:-})" + fi +} + +test_refresh_logout_and_audit() { + section "4b. Refresh rotation, logout revocation, auth audit events" + + local login_json refresh_token refresh_json new_refresh logout_code reuse_code + login_json="$(login intake1)" + refresh_token="$(extract_data_field "$login_json" refreshToken)" + + refresh_json="$(json_post "$API_URL/api/v1/auth/refresh" "{\"refreshToken\":\"$refresh_token\"}")" + if [[ "$(jq -er '.success' <<<"$refresh_json")" == "true" ]]; then + pass "POST /auth/refresh returns new access token" + else + fail "POST /auth/refresh returns new access token" + return + fi + + new_refresh="$(extract_data_field "$refresh_json" refreshToken)" + if [[ -n "$new_refresh" && "$new_refresh" != "$refresh_token" ]]; then + pass "POST /auth/refresh rotates refresh token" + else + fail "POST /auth/refresh rotates refresh token" + fi + + logout_code="$(http_code -X POST "$API_URL/api/v1/auth/logout" \ + -H 'Content-Type: application/json' \ + -d "{\"refreshToken\":\"$new_refresh\"}")" + if [[ "$logout_code" == "204" ]]; then + pass "POST /auth/logout returns 204" + else + fail "POST /auth/logout returns 204 (got: $logout_code)" + fi + + reuse_code="$(http_code -X POST "$API_URL/api/v1/auth/refresh" \ + -H 'Content-Type: application/json' \ + -d "{\"refreshToken\":\"$new_refresh\"}")" + if [[ "$reuse_code" == "422" ]]; then + pass "reused refresh token after logout returns 422" + else + fail "reused refresh token after logout returns 422 (got: $reuse_code)" + fi + + if ! psql_available; then + if [[ "$SKIP_DB_CHECKS" == "1" ]]; then + log " SKIP: PostgreSQL auth audit event checks (VIGILCARE_SKIP_DB_CHECKS=1)" + else + log " SKIP: PostgreSQL auth audit event checks (postgres not reachable)" + fi + return + fi + + local audit_rows + audit_rows="$(psql_query "SELECT event_type FROM auth_audit_events ORDER BY occurred_at DESC LIMIT 10;")" + if grep -q 'TOKEN_REFRESHED' <<<"$audit_rows" && grep -q 'USER_LOGOUT' <<<"$audit_rows"; then + pass "auth audit events include TOKEN_REFRESHED and USER_LOGOUT" + else + fail "auth audit events include TOKEN_REFRESHED and USER_LOGOUT" + fi +} + +test_upload_and_presigned_url() { + section "3. Upload batch and presigned document URL" + + local intake_json intake_token upload_json batch_id status document_url head_code sha256 + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + + upload_json="$(upload_batch "$intake_token")" + local upload_code + upload_code="$(http_code -X POST "$API_URL/api/v1/digitization-batches" \ + -H "Authorization: Bearer $intake_token" \ + -F "file=@${FIXTURE_PDF};type=application/pdf" \ + -F "batchType=VITALS_SHEET")" + + if [[ "$upload_code" == "201" ]]; then + pass "upload creates batch (201)" + else + fail "upload creates batch (201) (got: $upload_code)" + return + fi + + batch_id="$(extract_data_field "$upload_json" id)" + status="$(extract_data_field "$upload_json" status)" + if [[ "$status" == "UPLOADED" ]]; then + pass "uploaded batch status is UPLOADED" + else + fail "uploaded batch status is UPLOADED (got: $status)" + fi + + if psql_available; then + sha256="$(psql_query "SELECT document_sha256 FROM digitization_batches WHERE id = '$batch_id';")" + if [[ -n "$sha256" && ${#sha256} -eq 64 ]]; then + pass "batch persisted with SHA-256 document hash" + else + fail "batch persisted with SHA-256 document hash" + fi + + local object_count + object_count="$(psql_query "SELECT COUNT(*) FROM scanned_documents WHERE batch_id = '$batch_id';")" + if [[ "$object_count" == "1" ]]; then + pass "scanned document metadata persisted for batch" + else + fail "scanned document metadata persisted for batch" + fi + fi + + local get_json + get_json="$(curl -sS "$API_URL/api/v1/digitization-batches/$batch_id" \ + -H "Authorization: Bearer $intake_token")" + document_url="$(extract_data_field "$get_json" documentUrl)" + if [[ -n "$document_url" && "$document_url" == http* ]]; then + pass "GET batch returns presigned documentUrl" + else + fail "GET batch returns presigned documentUrl" + return + fi + + head_code="$(curl -sS -o /dev/null -w '%{http_code}' -I "$document_url" || true)" + if [[ "$head_code" == "200" || "$head_code" == "403" ]]; then + # MinIO may answer HEAD differently depending on signature params; GET is authoritative. + head_code="$(curl -sS -o /dev/null -w '%{http_code}' "$document_url" || true)" + fi + if [[ "$head_code" == "200" ]]; then + pass "presigned URL returns uploaded document (HTTP 200)" + else + fail "presigned URL returns uploaded document (HTTP 200) (got: $head_code)" + fi + +} + +test_duplicate_detection() { + section "2. SHA-256 duplicate detection" + + local intake_json intake_token patient_a patient_b first_code second_code second_json cross_code + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + patient_a="$(uuidgen | tr '[:upper:]' '[:lower:]')" + patient_b="$(uuidgen | tr '[:upper:]' '[:lower:]')" + + first_code="$(http_code -X POST "$API_URL/api/v1/digitization-batches" \ + -H "Authorization: Bearer $intake_token" \ + -F "file=@${FIXTURE_PDF};type=application/pdf" \ + -F "batchType=VITALS_SHEET" \ + -F "patientId=$patient_a")" + if [[ "$first_code" == "201" ]]; then + pass "first upload for patient succeeds (201)" + else + fail "first upload for patient succeeds (201) (got: $first_code)" + return + fi + + second_json="$(upload_batch "$intake_token" "$patient_a")" + second_code="$(http_code -X POST "$API_URL/api/v1/digitization-batches" \ + -H "Authorization: Bearer $intake_token" \ + -F "file=@${FIXTURE_PDF};type=application/pdf" \ + -F "batchType=VITALS_SHEET" \ + -F "patientId=$patient_a")" + local duplicate_code + duplicate_code="$(extract_error_code "$second_json")" + if [[ "$second_code" == "409" && "$duplicate_code" == "DUPLICATE_DOCUMENT" ]]; then + pass "duplicate upload for same patient returns 409 DUPLICATE_DOCUMENT" + else + fail "duplicate upload for same patient returns 409 DUPLICATE_DOCUMENT (http=$second_code code=$duplicate_code)" + fi + + cross_code="$(http_code -X POST "$API_URL/api/v1/digitization-batches" \ + -H "Authorization: Bearer $intake_token" \ + -F "file=@${FIXTURE_PDF};type=application/pdf" \ + -F "batchType=VITALS_SHEET" \ + -F "patientId=$patient_b")" + if [[ "$cross_code" == "201" ]]; then + pass "same file for different patient succeeds (201)" + else + fail "same file for different patient succeeds (201) (got: $cross_code)" + fi +} + +test_illegal_status_transition() { + section "1. Status machine — illegal transition returns 409" + + local intake_json intake_token upload_json batch_id assign_json assign_code error_code + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + upload_json="$(upload_batch "$intake_token")" + batch_id="$(extract_data_field "$upload_json" id)" + + if ! psql_available; then + log " SKIP: ILLEGAL_STATUS_TRANSITION check requires PostgreSQL (host psql or docker compose postgres)" + return + fi + + psql_query "UPDATE digitization_batches SET status = 'IN_ENTRY' WHERE id = '$batch_id';" >/dev/null + + local entry_json entry_id + entry_json="$(login entry1)" + entry_id="$(extract_data_field "$entry_json" userId)" + + assign_json="$(assign_batch "$intake_token" "$batch_id" "$entry_id")" + assign_code="$(http_code -X PATCH "$API_URL/api/v1/digitization-batches/$batch_id/assign" \ + -H "Authorization: Bearer $intake_token" \ + -H 'Content-Type: application/json' \ + -d "{\"entryClerkUserId\":\"$entry_id\"}")" + error_code="$(extract_error_code "$assign_json")" + + if [[ "$assign_code" == "409" && "$error_code" == "ILLEGAL_STATUS_TRANSITION" ]]; then + pass "assign on non-uploaded batch returns 409 ILLEGAL_STATUS_TRANSITION" + else + fail "assign on non-uploaded batch returns 409 ILLEGAL_STATUS_TRANSITION (http=$assign_code code=$error_code)" + fi +} + +test_redis_assignment_lock() { + section "5. Redis assignment lock" + + if ! redis_available; then + log " SKIP: Redis assignment lock check requires Redis (host redis-cli or docker compose redis)" + return + fi + + local intake_json intake_token upload_json batch_id + local entry1_json entry2_json entry1_id entry2_id + local first_assign second_assign second_code second_error redis_value + + intake_json="$(login intake1)" + intake_token="$(extract_data_field "$intake_json" token)" + upload_json="$(upload_batch "$intake_token")" + batch_id="$(extract_data_field "$upload_json" id)" + + entry1_json="$(login entry1)" + entry2_json="$(login entry2)" + entry1_id="$(extract_data_field "$entry1_json" userId)" + entry2_id="$(extract_data_field "$entry2_json" userId)" + + first_assign="$(assign_batch "$intake_token" "$batch_id" "$entry1_id")" + if [[ "$(jq -er '.success' <<<"$first_assign")" == "true" ]]; then + pass "assign batch to entry1 succeeds" + else + fail "assign batch to entry1 succeeds" + return + fi + + second_assign="$(assign_batch "$intake_token" "$batch_id" "$entry2_id")" + second_code="$(http_code -X PATCH "$API_URL/api/v1/digitization-batches/$batch_id/assign" \ + -H "Authorization: Bearer $intake_token" \ + -H 'Content-Type: application/json' \ + -d "{\"entryClerkUserId\":\"$entry2_id\"}")" + second_error="$(extract_error_code "$second_assign")" + if [[ "$second_code" == "409" && "$second_error" == "BATCH_ALREADY_ASSIGNED" ]]; then + pass "assign same batch to entry2 returns 409 BATCH_ALREADY_ASSIGNED" + else + fail "assign same batch to entry2 returns 409 BATCH_ALREADY_ASSIGNED (http=$second_code code=$second_error)" + fi + + redis_value="$(redis_get "batch:assign:${batch_id}" 2>/dev/null || true)" + if [[ "$redis_value" == "$entry1_id" ]]; then + pass "Redis lock stores entry1 user ID" + else + fail "Redis lock stores entry1 user ID (got: ${redis_value:-})" + fi +} + +main() { + require_cmd curl + require_cmd jq + + if [[ ! -f "$FIXTURE_PDF" ]]; then + log "ERROR: missing fixture PDF at $FIXTURE_PDF" + exit 1 + fi + + log "VigilCare Records — Phase 1 verification" + log "API: $API_URL" + + assert_api_reachable + + test_auth_roles_and_me + test_refresh_logout_and_audit + test_upload_and_presigned_url + test_duplicate_detection + test_illegal_status_transition + test_redis_assignment_lock + + log "" + log "Results: $PASS_COUNT passed, $FAIL_COUNT failed" + if (( FAIL_COUNT > 0 )); then + log "Failed checks:" + for item in "${FAILED_TESTS[@]}"; do + log " - $item" + done + exit 1 + fi + + log "All verification checks passed." +} + +main "$@"