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,319 @@
#!/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-admin-ops"
request "scheme_current" "GET" "${API_URL}/api/v1/schemes/${SCHEME_ID}/current" "200"
CURRENT_VERSION_ID="$(json_get "${TMP_DIR}/scheme_current.body" "scheme_version_id")"
echo "CURRENT_VERSION_ID=${CURRENT_VERSION_ID}"
request "ensure_draft" "POST" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/ensure?expected_current_scheme_version_id=${CURRENT_VERSION_ID}" \
"200"
DRAFT_VERSION_ID="$(json_get "${TMP_DIR}/ensure_draft.body" "scheme_version_id")"
echo "DRAFT_VERSION_ID=${DRAFT_VERSION_ID}"
request "draft_structure" "GET" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/structure?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
"200"
TARGET_SEAT_ID="$(python3 - "${TMP_DIR}/draft_structure.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 for admin ops smoke")
print(seat["seat_id"])
PY
)"
echo "TARGET_SEAT_ID=${TARGET_SEAT_ID}"
STAMP="$(date +%s)-$$"
CLEANUP_PREFIX="ADMINOPS_CLEAN_${STAMP}_"
DELETE_CATEGORY_NAME="adminops-clean-delete-${STAMP}"
DELETE_CATEGORY_CODE="${CLEANUP_PREFIX}DELETE"
KEEP_CATEGORY_NAME="adminops-clean-keep-${STAMP}"
KEEP_CATEGORY_CODE="${CLEANUP_PREFIX}KEEP"
request "create_delete_category" "POST" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/categories?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
"200" \
"{\"name\":\"${DELETE_CATEGORY_NAME}\",\"code\":\"${DELETE_CATEGORY_CODE}\"}"
DELETE_CATEGORY_ID="$(json_get "${TMP_DIR}/create_delete_category.body" "pricing_category_id")"
echo "DELETE_CATEGORY_ID=${DELETE_CATEGORY_ID}"
request "create_keep_category" "POST" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/categories?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
"200" \
"{\"name\":\"${KEEP_CATEGORY_NAME}\",\"code\":\"${KEEP_CATEGORY_CODE}\"}"
KEEP_CATEGORY_ID="$(json_get "${TMP_DIR}/create_keep_category.body" "pricing_category_id")"
echo "KEEP_CATEGORY_ID=${KEEP_CATEGORY_ID}"
request "create_keep_category_rule" "POST" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing/rules?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
"200" \
"{\"pricing_category_id\":\"${KEEP_CATEGORY_ID}\",\"target_type\":\"seat\",\"target_ref\":\"${TARGET_SEAT_ID}\",\"amount\":\"555.00\",\"currency\":\"RUB\"}"
KEEP_RULE_ID="$(json_get "${TMP_DIR}/create_keep_category_rule.body" "price_rule_id")"
echo "KEEP_RULE_ID=${KEEP_RULE_ID}"
request "draft_pricing_snapshot" "POST" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/pricing/snapshot?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
"200"
request "publish_preview_refresh" "GET" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/publish-preview?refresh=true&expected_scheme_version_id=${DRAFT_VERSION_ID}" \
"200"
request "publish_preview_cached" "GET" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/draft/publish-preview?expected_scheme_version_id=${DRAFT_VERSION_ID}" \
"200"
request "admin_current_artifacts" "GET" \
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/current/artifacts" \
"200"
assert_json_eq "${TMP_DIR}/admin_current_artifacts.body" "scheme_version_id" "${DRAFT_VERSION_ID}"
assert_json_int_ge "${TMP_DIR}/admin_current_artifacts.body" "total" "4"
assert_file_contains "${TMP_DIR}/admin_current_artifacts.body" "\"artifact_type\":\"publish_preview\""
request "admin_current_validation" "GET" \
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/current/validation" \
"200"
assert_json_eq "${TMP_DIR}/admin_current_validation.body" "scheme_version_id" "${DRAFT_VERSION_ID}"
assert_file_contains "${TMP_DIR}/admin_current_validation.body" "\"report\":"
request "publish_preview_audit" "GET" \
"${API_URL}/api/v1/admin/artifacts/publish-preview/audit" \
"200"
assert_json_eq "${TMP_DIR}/publish_preview_audit.body" "artifact_type" "publish_preview"
assert_json_int_ge "${TMP_DIR}/publish_preview_audit.body" "db_rows_count" "1"
assert_json_int_ge "${TMP_DIR}/publish_preview_audit.body" "disk_files_count" "1"
PRE_CLEANUP_DB_ROWS_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit.body" "db_rows_count")"
PRE_CLEANUP_DISK_FILES_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit.body" "disk_files_count")"
PRE_CLEANUP_ORPHAN_FILES_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit.body" "orphan_files_count")"
PRE_CLEANUP_MISSING_FILES_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit.body" "missing_files_for_db_rows_count")"
echo "PRE_CLEANUP_DB_ROWS_COUNT=${PRE_CLEANUP_DB_ROWS_COUNT}"
echo "PRE_CLEANUP_DISK_FILES_COUNT=${PRE_CLEANUP_DISK_FILES_COUNT}"
echo "PRE_CLEANUP_ORPHAN_FILES_COUNT=${PRE_CLEANUP_ORPHAN_FILES_COUNT}"
echo "PRE_CLEANUP_MISSING_FILES_COUNT=${PRE_CLEANUP_MISSING_FILES_COUNT}"
request "publish_preview_cleanup_dry_run" "POST" \
"${API_URL}/api/v1/admin/artifacts/publish-preview/cleanup?dry_run=true" \
"200"
assert_json_eq "${TMP_DIR}/publish_preview_cleanup_dry_run.body" "artifact_type" "publish_preview"
assert_json_eq "${TMP_DIR}/publish_preview_cleanup_dry_run.body" "dry_run" "true"
assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_dry_run.body" "deleted_files_count" "0"
assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_dry_run.body" "deleted_db_rows_count" "0"
assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_dry_run.body" "orphan_files_count" "${PRE_CLEANUP_ORPHAN_FILES_COUNT}"
assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_dry_run.body" "missing_files_for_db_rows_count" "${PRE_CLEANUP_MISSING_FILES_COUNT}"
request "publish_preview_cleanup_execute" "POST" \
"${API_URL}/api/v1/admin/artifacts/publish-preview/cleanup?dry_run=false" \
"200"
assert_json_eq "${TMP_DIR}/publish_preview_cleanup_execute.body" "artifact_type" "publish_preview"
assert_json_eq "${TMP_DIR}/publish_preview_cleanup_execute.body" "dry_run" "false"
assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_execute.body" "orphan_files_count" "${PRE_CLEANUP_ORPHAN_FILES_COUNT}"
assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_execute.body" "missing_files_for_db_rows_count" "${PRE_CLEANUP_MISSING_FILES_COUNT}"
assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_execute.body" "deleted_files_count" "${PRE_CLEANUP_ORPHAN_FILES_COUNT}"
assert_json_int_eq "${TMP_DIR}/publish_preview_cleanup_execute.body" "deleted_db_rows_count" "${PRE_CLEANUP_MISSING_FILES_COUNT}"
request "publish_preview_audit_after_cleanup" "GET" \
"${API_URL}/api/v1/admin/artifacts/publish-preview/audit" \
"200"
assert_json_eq "${TMP_DIR}/publish_preview_audit_after_cleanup.body" "artifact_type" "publish_preview"
assert_json_int_eq "${TMP_DIR}/publish_preview_audit_after_cleanup.body" "orphan_files_count" "0"
assert_json_int_eq "${TMP_DIR}/publish_preview_audit_after_cleanup.body" "missing_files_for_db_rows_count" "0"
POST_CLEANUP_DB_ROWS_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit_after_cleanup.body" "db_rows_count")"
POST_CLEANUP_DISK_FILES_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit_after_cleanup.body" "disk_files_count")"
POST_CLEANUP_ORPHAN_FILES_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit_after_cleanup.body" "orphan_files_count")"
POST_CLEANUP_MISSING_FILES_COUNT="$(json_get "${TMP_DIR}/publish_preview_audit_after_cleanup.body" "missing_files_for_db_rows_count")"
echo "POST_CLEANUP_DB_ROWS_COUNT=${POST_CLEANUP_DB_ROWS_COUNT}"
echo "POST_CLEANUP_DISK_FILES_COUNT=${POST_CLEANUP_DISK_FILES_COUNT}"
echo "POST_CLEANUP_ORPHAN_FILES_COUNT=${POST_CLEANUP_ORPHAN_FILES_COUNT}"
echo "POST_CLEANUP_MISSING_FILES_COUNT=${POST_CLEANUP_MISSING_FILES_COUNT}"
if [[ "${POST_CLEANUP_DB_ROWS_COUNT}" != "${POST_CLEANUP_DISK_FILES_COUNT}" ]]; then
fail "publish-preview audit mismatch after cleanup: db_rows_count=${POST_CLEANUP_DB_ROWS_COUNT}, disk_files_count=${POST_CLEANUP_DISK_FILES_COUNT}"
fi
echo "[OK] publish-preview audit is fully consistent after cleanup"
request "pricing_cleanup_preview" "GET" \
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup-preview?code_prefix=${CLEANUP_PREFIX}" \
"200"
assert_json_eq "${TMP_DIR}/pricing_cleanup_preview.body" "scheme_id" "${SCHEME_ID}"
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_preview.body" "total_candidates" "2"
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_preview.body" "safe_to_delete_count" "1"
python3 - "${TMP_DIR}/pricing_cleanup_preview.body" "${DELETE_CATEGORY_ID}" "${KEEP_CATEGORY_ID}" <<'PY'
import json
import sys
from pathlib import Path
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
delete_category_id = sys.argv[2]
keep_category_id = sys.argv[3]
items = {item["pricing_category_id"]: item for item in payload.get("items", [])}
if delete_category_id not in items:
raise SystemExit(f"Delete candidate {delete_category_id} missing from cleanup preview")
if keep_category_id not in items:
raise SystemExit(f"Protected category {keep_category_id} missing from cleanup preview")
if not items[delete_category_id]["deletable"]:
raise SystemExit("Delete candidate is expected to be deletable")
if items[keep_category_id]["deletable"]:
raise SystemExit("Protected category is expected to be skipped because it has rules")
PY
echo "[OK] pricing cleanup preview matched expected deletable/skipped categories"
request "pricing_cleanup_dry_run" "POST" \
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup" \
"200" \
"{\"code_prefixes\":[\"${CLEANUP_PREFIX}\"],\"name_prefixes\":[],\"pricing_category_ids\":[],\"delete_only_without_rules\":true,\"dry_run\":true}"
assert_json_eq "${TMP_DIR}/pricing_cleanup_dry_run.body" "scheme_id" "${SCHEME_ID}"
assert_json_eq "${TMP_DIR}/pricing_cleanup_dry_run.body" "dry_run" "true"
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run.body" "matched_total" "2"
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run.body" "would_delete_count" "1"
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run.body" "deleted_count" "0"
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run.body" "skipped_count" "1"
python3 - "${TMP_DIR}/pricing_cleanup_dry_run.body" "${DELETE_CATEGORY_ID}" "${KEEP_CATEGORY_ID}" <<'PY'
import json
import sys
from pathlib import Path
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
delete_category_id = sys.argv[2]
keep_category_id = sys.argv[3]
would_delete_ids = set(payload.get("would_delete_category_ids", []))
if would_delete_ids != {delete_category_id}:
raise SystemExit(
f"Dry run expected would_delete_category_ids={[delete_category_id]}, got={sorted(would_delete_ids)}"
)
skipped_ids = {item["pricing_category_id"] for item in payload.get("skipped", [])}
if skipped_ids != {keep_category_id}:
raise SystemExit(
f"Dry run expected skipped={[keep_category_id]}, got={sorted(skipped_ids)}"
)
PY
echo "[OK] pricing cleanup dry-run kept protected category and selected only empty fixture category"
request "pricing_cleanup_execute" "POST" \
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup" \
"200" \
"{\"code_prefixes\":[\"${CLEANUP_PREFIX}\"],\"name_prefixes\":[],\"pricing_category_ids\":[],\"delete_only_without_rules\":true,\"dry_run\":false}"
assert_json_eq "${TMP_DIR}/pricing_cleanup_execute.body" "scheme_id" "${SCHEME_ID}"
assert_json_eq "${TMP_DIR}/pricing_cleanup_execute.body" "dry_run" "false"
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_execute.body" "matched_total" "2"
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_execute.body" "would_delete_count" "1"
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_execute.body" "deleted_count" "1"
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_execute.body" "skipped_count" "1"
python3 - "${TMP_DIR}/pricing_cleanup_execute.body" "${DELETE_CATEGORY_ID}" "${KEEP_CATEGORY_ID}" <<'PY'
import json
import sys
from pathlib import Path
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
delete_category_id = sys.argv[2]
keep_category_id = sys.argv[3]
deleted_ids = set(payload.get("deleted_category_ids", []))
if deleted_ids != {delete_category_id}:
raise SystemExit(
f"Cleanup execute expected deleted_category_ids={[delete_category_id]}, got={sorted(deleted_ids)}"
)
skipped_ids = {item["pricing_category_id"] for item in payload.get("skipped", [])}
if skipped_ids != {keep_category_id}:
raise SystemExit(
f"Cleanup execute expected skipped={[keep_category_id]}, got={sorted(skipped_ids)}"
)
PY
echo "[OK] pricing cleanup execute deleted only safe fixture category"
request "pricing_bundle_after_cleanup" "GET" \
"${API_URL}/api/v1/schemes/${SCHEME_ID}/pricing" \
"200"
assert_json_len_eq "${TMP_DIR}/pricing_bundle_after_cleanup.body" "categories" "1"
assert_json_len_eq "${TMP_DIR}/pricing_bundle_after_cleanup.body" "rules" "1"
python3 - "${TMP_DIR}/pricing_bundle_after_cleanup.body" "${DELETE_CATEGORY_ID}" "${KEEP_CATEGORY_ID}" "${KEEP_RULE_ID}" <<'PY'
import json
import sys
from pathlib import Path
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
delete_category_id = sys.argv[2]
keep_category_id = sys.argv[3]
keep_rule_id = sys.argv[4]
category_ids = {item["pricing_category_id"] for item in payload.get("categories", [])}
rule_ids = {item["price_rule_id"] for item in payload.get("rules", [])}
if delete_category_id in category_ids:
raise SystemExit("Deleted cleanup category still present in pricing bundle")
if keep_category_id not in category_ids:
raise SystemExit("Protected cleanup category missing after execute cleanup")
if keep_rule_id not in rule_ids:
raise SystemExit("Protected pricing rule missing after execute cleanup")
PY
echo "[OK] pricing bundle reflects destructive cleanup result"
request "pricing_cleanup_preview_after_cleanup" "GET" \
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup-preview?code_prefix=${CLEANUP_PREFIX}" \
"200"
assert_json_eq "${TMP_DIR}/pricing_cleanup_preview_after_cleanup.body" "scheme_id" "${SCHEME_ID}"
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_preview_after_cleanup.body" "total_candidates" "1"
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_preview_after_cleanup.body" "safe_to_delete_count" "0"
request "pricing_cleanup_dry_run_after_cleanup" "POST" \
"${API_URL}/api/v1/admin/schemes/${SCHEME_ID}/pricing/categories/cleanup" \
"200" \
"{\"code_prefixes\":[\"${CLEANUP_PREFIX}\"],\"name_prefixes\":[],\"pricing_category_ids\":[],\"delete_only_without_rules\":true,\"dry_run\":true}"
assert_json_eq "${TMP_DIR}/pricing_cleanup_dry_run_after_cleanup.body" "scheme_id" "${SCHEME_ID}"
assert_json_eq "${TMP_DIR}/pricing_cleanup_dry_run_after_cleanup.body" "dry_run" "true"
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run_after_cleanup.body" "matched_total" "1"
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run_after_cleanup.body" "would_delete_count" "0"
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run_after_cleanup.body" "deleted_count" "0"
assert_json_int_eq "${TMP_DIR}/pricing_cleanup_dry_run_after_cleanup.body" "skipped_count" "1"
python3 - "${TMP_DIR}/pricing_cleanup_dry_run_after_cleanup.body" "${KEEP_CATEGORY_ID}" <<'PY'
import json
import sys
from pathlib import Path
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
keep_category_id = sys.argv[2]
would_delete_ids = payload.get("would_delete_category_ids", [])
if would_delete_ids:
raise SystemExit(f"Expected no deletable categories after cleanup, got={would_delete_ids}")
skipped_ids = {item["pricing_category_id"] for item in payload.get("skipped", [])}
if skipped_ids != {keep_category_id}:
raise SystemExit(
f"Post-cleanup dry run expected skipped={[keep_category_id]}, got={sorted(skipped_ids)}"
)
PY
echo "[OK] repeated cleanup state is stable after destructive cleanup"
echo
echo "===== done ====="
echo "[OK] smoke admin ops completed successfully"
echo "FRESH_SCHEME_ID=${SCHEME_ID}"
echo "DELETE_CATEGORY_ID=${DELETE_CATEGORY_ID}"
echo "KEEP_CATEGORY_ID=${KEEP_CATEGORY_ID}"
echo "KEEP_RULE_ID=${KEEP_RULE_ID}"