feat(backend): enforce admin-only ops endpoints and cover destructive cleanup smoke

restrict ops endpoints to admin-only access

block operator and viewer keys from admin maintenance routes
cover destructive pricing cleanup in smoke execution, not only preview

extend orchestration without regressing existing smoke stages
This commit is contained in:
greebo
2026-03-20 16:02:38 +03:00
parent 210981c953
commit 5aa35b1d04
10 changed files with 1090 additions and 13 deletions

View File

@@ -0,0 +1,236 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "${TMP_DIR}"' EXIT
# shellcheck source=backend/scripts/smoke_common.sh
source "${SCRIPT_DIR}/smoke_common.sh"
wait_for_health
create_fresh_scheme_from_upload "smoke-version-lifecycle"
request "scheme_detail_initial" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}" "200"
assert_json_eq "${TMP_DIR}/scheme_detail_initial.body" "status" "draft"
assert_json_int_eq "${TMP_DIR}/scheme_detail_initial.body" "current_version_number" "1"
request "scheme_current_initial" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "200"
VERSION1_ID="$(json_get "${TMP_DIR}/scheme_current_initial.body" "scheme_version_id")"
assert_json_int_eq "${TMP_DIR}/scheme_current_initial.body" "version_number" "1"
assert_json_eq "${TMP_DIR}/scheme_current_initial.body" "status" "draft"
echo "VERSION1_ID=${VERSION1_ID}"
request "ensure_draft_v1" "POST" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/ensure?expected_current_scheme_version_id=${VERSION1_ID}" \
"200"
assert_json_eq "${TMP_DIR}/ensure_draft_v1.body" "scheme_version_id" "${VERSION1_ID}"
assert_json_eq "${TMP_DIR}/ensure_draft_v1.body" "created" "false"
request "draft_structure_v1" "GET" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/structure?expected_scheme_version_id=${VERSION1_ID}" \
"200"
read -r VERSION1_SEAT_RECORD_ID VERSION1_SEAT_ID ORIGINAL_ROW_LABEL ORIGINAL_SEAT_NUMBER <<EOF
$(python3 - "${TMP_DIR}/draft_structure_v1.body" <<'PY'
import json
import sys
from pathlib import Path
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
seat = next((item for item in payload.get("seats", []) if item.get("seat_id")), None)
if seat is None:
raise SystemExit("No seat with seat_id found in version 1 draft structure")
print(
seat["seat_record_id"],
seat["seat_id"],
seat.get("row_label") or "__EMPTY__",
seat.get("seat_number") or "__EMPTY__",
)
PY
)
EOF
echo "VERSION1_SEAT_RECORD_ID=${VERSION1_SEAT_RECORD_ID}"
echo "VERSION1_SEAT_ID=${VERSION1_SEAT_ID}"
echo "ORIGINAL_ROW_LABEL=${ORIGINAL_ROW_LABEL}"
echo "ORIGINAL_SEAT_NUMBER=${ORIGINAL_SEAT_NUMBER}"
STAMP="$(date +%s)-$$"
PRICING_CATEGORY_NAME="lifecycle-publish-${STAMP}"
PRICING_CATEGORY_CODE="LIFECYCLE_${STAMP}"
UPDATED_ROW_LABEL="LC-${STAMP}"
request "create_pricing_category_v1" "POST" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/categories?expected_scheme_version_id=${VERSION1_ID}" \
"200" \
"{\"name\":\"${PRICING_CATEGORY_NAME}\",\"code\":\"${PRICING_CATEGORY_CODE}\"}"
PRICING_CATEGORY_ID="$(json_get "${TMP_DIR}/create_pricing_category_v1.body" "pricing_category_id")"
echo "PRICING_CATEGORY_ID=${PRICING_CATEGORY_ID}"
request "create_price_rule_v1" "POST" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/rules?expected_scheme_version_id=${VERSION1_ID}" \
"200" \
"{\"pricing_category_id\":\"${PRICING_CATEGORY_ID}\",\"target_type\":\"seat\",\"target_ref\":\"${VERSION1_SEAT_ID}\",\"amount\":\"777.00\",\"currency\":\"RUB\"}"
PRICE_RULE_ID="$(json_get "${TMP_DIR}/create_price_rule_v1.body" "price_rule_id")"
echo "PRICE_RULE_ID=${PRICE_RULE_ID}"
request "draft_pricing_snapshot_v1" "POST" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/pricing/snapshot?expected_scheme_version_id=${VERSION1_ID}" \
"200"
request "publish_v1" "POST" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/publish?expected_scheme_version_id=${VERSION1_ID}" \
"200"
assert_json_eq "${TMP_DIR}/publish_v1.body" "status" "published"
assert_json_int_eq "${TMP_DIR}/publish_v1.body" "current_version_number" "1"
request "scheme_detail_after_publish_v1" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}" "200"
assert_json_eq "${TMP_DIR}/scheme_detail_after_publish_v1.body" "status" "published"
assert_json_int_eq "${TMP_DIR}/scheme_detail_after_publish_v1.body" "current_version_number" "1"
request "scheme_current_after_publish_v1" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "200"
assert_json_eq "${TMP_DIR}/scheme_current_after_publish_v1.body" "scheme_version_id" "${VERSION1_ID}"
assert_json_int_eq "${TMP_DIR}/scheme_current_after_publish_v1.body" "version_number" "1"
assert_json_eq "${TMP_DIR}/scheme_current_after_publish_v1.body" "status" "published"
request "ensure_draft_v2" "POST" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/ensure?expected_current_scheme_version_id=${VERSION1_ID}" \
"200"
VERSION2_ID="$(json_get "${TMP_DIR}/ensure_draft_v2.body" "scheme_version_id")"
echo "VERSION2_ID=${VERSION2_ID}"
assert_json_eq "${TMP_DIR}/ensure_draft_v2.body" "created" "true"
assert_json_eq "${TMP_DIR}/ensure_draft_v2.body" "source_scheme_version_id" "${VERSION1_ID}"
assert_json_int_eq "${TMP_DIR}/ensure_draft_v2.body" "version_number" "2"
request "draft_structure_v2_before_mutation" "GET" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/structure?expected_scheme_version_id=${VERSION2_ID}" \
"200"
VERSION2_SEAT_RECORD_ID="$(python3 - "${TMP_DIR}/draft_structure_v2_before_mutation.body" "${VERSION1_SEAT_ID}" <<'PY'
import json
import sys
from pathlib import Path
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
seat_id = sys.argv[2]
seat = next((item for item in payload.get("seats", []) if item.get("seat_id") == seat_id), None)
if seat is None:
raise SystemExit("Target seat_id not found in version 2 draft structure")
print(seat["seat_record_id"])
PY
)"
echo "VERSION2_SEAT_RECORD_ID=${VERSION2_SEAT_RECORD_ID}"
request "patch_seat_v2" "PATCH" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/seats/records/${VERSION2_SEAT_RECORD_ID}?expected_scheme_version_id=${VERSION2_ID}" \
"200" \
"{\"row_label\":\"${UPDATED_ROW_LABEL}\"}"
assert_json_eq "${TMP_DIR}/patch_seat_v2.body" "row_label" "${UPDATED_ROW_LABEL}"
request "draft_structure_v2_after_mutation" "GET" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/structure?expected_scheme_version_id=${VERSION2_ID}" \
"200"
assert_file_contains "${TMP_DIR}/draft_structure_v2_after_mutation.body" "\"row_label\":\"${UPDATED_ROW_LABEL}\""
request "draft_compare_preview_v2" "GET" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/compare-preview?expected_scheme_version_id=${VERSION2_ID}" \
"200"
assert_file_contains "${TMP_DIR}/draft_compare_preview_v2.body" "\"status\":\"changed\""
request "draft_pricing_snapshot_v2" "POST" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/pricing/snapshot?expected_scheme_version_id=${VERSION2_ID}" \
"200"
request "publish_v2" "POST" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/publish?expected_scheme_version_id=${VERSION2_ID}" \
"200"
assert_json_eq "${TMP_DIR}/publish_v2.body" "status" "published"
assert_json_int_eq "${TMP_DIR}/publish_v2.body" "current_version_number" "2"
request "scheme_detail_after_publish_v2" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}" "200"
assert_json_eq "${TMP_DIR}/scheme_detail_after_publish_v2.body" "status" "published"
assert_json_int_eq "${TMP_DIR}/scheme_detail_after_publish_v2.body" "current_version_number" "2"
request "scheme_current_after_publish_v2" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "200"
assert_json_eq "${TMP_DIR}/scheme_current_after_publish_v2.body" "scheme_version_id" "${VERSION2_ID}"
assert_json_int_eq "${TMP_DIR}/scheme_current_after_publish_v2.body" "version_number" "2"
assert_json_eq "${TMP_DIR}/scheme_current_after_publish_v2.body" "status" "published"
request "scheme_versions_after_publish_v2" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/versions?limit=20&offset=0" "200"
assert_json_len_eq "${TMP_DIR}/scheme_versions_after_publish_v2.body" "items" "2"
request "rollback_to_v1" "POST" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/rollback" \
"200" \
"{\"target_version_number\":1}"
assert_json_eq "${TMP_DIR}/rollback_to_v1.body" "status" "draft"
assert_json_int_eq "${TMP_DIR}/rollback_to_v1.body" "current_version_number" "1"
request "scheme_detail_after_rollback" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}" "200"
assert_json_eq "${TMP_DIR}/scheme_detail_after_rollback.body" "status" "draft"
assert_json_int_eq "${TMP_DIR}/scheme_detail_after_rollback.body" "current_version_number" "1"
request "scheme_current_after_rollback" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "200"
assert_json_eq "${TMP_DIR}/scheme_current_after_rollback.body" "scheme_version_id" "${VERSION1_ID}"
assert_json_int_eq "${TMP_DIR}/scheme_current_after_rollback.body" "version_number" "1"
assert_json_eq "${TMP_DIR}/scheme_current_after_rollback.body" "status" "draft"
request "editor_context_after_rollback" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/editor/context" "200"
assert_json_eq "${TMP_DIR}/editor_context_after_rollback.body" "current_scheme_version_id" "${VERSION1_ID}"
assert_json_eq "${TMP_DIR}/editor_context_after_rollback.body" "current_is_draft" "true"
request "draft_structure_after_rollback" "GET" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/structure?expected_scheme_version_id=${VERSION1_ID}" \
"200"
python3 - "${TMP_DIR}/draft_structure_after_rollback.body" "${VERSION1_SEAT_ID}" "${ORIGINAL_ROW_LABEL}" "${ORIGINAL_SEAT_NUMBER}" <<'PY'
import json
import sys
from pathlib import Path
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
seat_id = sys.argv[2]
expected_row_label = sys.argv[3]
expected_seat_number = sys.argv[4]
seat = next((item for item in payload.get("seats", []) if item.get("seat_id") == seat_id), None)
if seat is None:
raise SystemExit(f"Seat {seat_id} not found after rollback")
actual_row_label = seat.get("row_label") or "__EMPTY__"
actual_seat_number = seat.get("seat_number") or "__EMPTY__"
if actual_row_label != expected_row_label:
raise SystemExit(
f"Rollback row_label mismatch: expected {expected_row_label}, got {actual_row_label}"
)
if actual_seat_number != expected_seat_number:
raise SystemExit(
f"Rollback seat_number mismatch: expected {expected_seat_number}, got {actual_seat_number}"
)
PY
echo "[OK] rollback restored version 1 seat semantics"
request "unpublish_after_rollback" "POST" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/unpublish" \
"200"
assert_json_eq "${TMP_DIR}/unpublish_after_rollback.body" "status" "draft"
assert_json_int_eq "${TMP_DIR}/unpublish_after_rollback.body" "current_version_number" "1"
request "scheme_detail_after_unpublish" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}" "200"
assert_json_eq "${TMP_DIR}/scheme_detail_after_unpublish.body" "status" "draft"
assert_json_int_eq "${TMP_DIR}/scheme_detail_after_unpublish.body" "current_version_number" "1"
request "audit_trail" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/audit" "200"
assert_file_contains "${TMP_DIR}/audit_trail.body" "\"event_type\":\"scheme.published\""
assert_file_contains "${TMP_DIR}/audit_trail.body" "\"event_type\":\"scheme.version.created\""
assert_file_contains "${TMP_DIR}/audit_trail.body" "\"event_type\":\"scheme.rolled_back\""
assert_file_contains "${TMP_DIR}/audit_trail.body" "\"event_type\":\"scheme.unpublished\""
echo
echo "===== done ====="
echo "[OK] smoke version lifecycle completed successfully"
echo "FRESH_SCHEME_ID=${SCHEME_ID}"
echo "VERSION1_ID=${VERSION1_ID}"
echo "VERSION2_ID=${VERSION2_ID}"