feat(vnext2): close user key self-service on real host
This commit is contained in:
@@ -1,110 +1,236 @@
|
||||
#!/usr/bin/env bash
|
||||
# verify_user_key_self_service.sh — 用户 key 自助验收入口
|
||||
#
|
||||
# 本脚本为 Phase 0 skeleton。验收逻辑在 Phase 3(vNext.2)实现。
|
||||
# 当前仅验证环境就绪与目录规范。
|
||||
#
|
||||
# 使用方式:
|
||||
# bash scripts/acceptance/verify_user_key_self_service.sh --help
|
||||
# bash scripts/acceptance/verify_user_key_self_service.sh [--env-check]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
TS="$(date +%Y%m%d_%H%M%S)"
|
||||
TS="${TS:-$(date +%Y%m%d_%H%M%S)}"
|
||||
ARTIFACT_DIR="${ARTIFACT_DIR:-$ROOT_DIR/artifacts/user-key-self-service/${TS}}"
|
||||
|
||||
CRM_BASE="${CRM_BASE:-https://sub.tksea.top/portal-admin-api}"
|
||||
USER_CHAT_BASE="${USER_CHAT_BASE:-}"
|
||||
CHAT_MODEL="${CHAT_MODEL:-gpt-5.4}"
|
||||
USER_SUBJECT_ID="${USER_SUBJECT_ID:-}"
|
||||
USER_AUTH_TOKEN="${USER_AUTH_TOKEN:-}"
|
||||
|
||||
# --- helpers ---
|
||||
die() { echo "FATAL: $*" >&2; exit 1; }
|
||||
info() { echo "INFO: $*"; }
|
||||
ok() { echo "OK: $*"; }
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
|
||||
cmd_help() {
|
||||
cat <<HELP
|
||||
usage: $(basename "$0") [--help|--env-check]
|
||||
info() { printf 'INFO: %s\n' "$*"; }
|
||||
ok() { printf 'OK: %s\n' "$*"; }
|
||||
warn() { printf 'WARN: %s\n' "$*" >&2; }
|
||||
die() { printf 'FATAL: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
Phase 0 skeleton — user key self-service acceptance script.
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage: verify_user_key_self_service.sh [--help|--env-check|--run]
|
||||
|
||||
options:
|
||||
--help 显示此帮助
|
||||
--env-check 验证环境变量与基本可达性
|
||||
Modes:
|
||||
--help 显示帮助
|
||||
--env-check 仅检查 CRM / chat 入口与认证前置
|
||||
--run 执行真实 user-key 闭环:create -> list -> get -> reset -> chat
|
||||
|
||||
当前状态:
|
||||
此脚本为 vNext.1 Phase 0 骨架。验收逻辑将在 vNext.2 (Phase 3) 实现。
|
||||
vNext.1 目标用户 key 自助已明确推迟到 vNext.2。
|
||||
Required env for --run:
|
||||
CRM_BASE CRM API base, e.g. https://sub.tksea.top/portal-admin-api
|
||||
USER_CHAT_BASE 最终 user-key 调用入口 base, e.g. https://sub.tksea.top
|
||||
CHAT_MODEL chat 模型名,default: gpt-5.4
|
||||
|
||||
环境变量:
|
||||
CRM_BASE CRM API 基础 URL (default: https://sub.tksea.top/portal-admin-api)
|
||||
CRM_ADMIN_TOKEN Admin token(可选,env-check 用)
|
||||
Authentication for /api/keys endpoints (choose one):
|
||||
USER_SUBJECT_ID 通过 X-Portal-Subject 头注入 subject(联合部署/受信入口)
|
||||
USER_AUTH_TOKEN 通过 Authorization: Bearer <token> 走用户链路
|
||||
|
||||
验收范围 (vNext.2):
|
||||
- 用户 key 自助申请
|
||||
- key 首次回显与仅首次显示明文
|
||||
- key 状态展示(active/paused/exhausted)
|
||||
- 用户首次 POST /v1/chat/completions = 200 闭环
|
||||
|
||||
输出:
|
||||
Artifacts:
|
||||
artifacts/user-key-self-service/<timestamp>/
|
||||
HELP
|
||||
exit 0
|
||||
- 00-env.json
|
||||
- 10-create.headers.txt / 10-create.body.json
|
||||
- 11-list.headers.txt / 11-list.body.json
|
||||
- 12-get.headers.txt / 12-get.body.json
|
||||
- 13-reset.headers.txt / 13-reset.body.json
|
||||
- 20-chat.headers.txt / 20-chat.body.json
|
||||
- 99-summary.json
|
||||
EOF
|
||||
}
|
||||
|
||||
build_auth_args() {
|
||||
if [[ -n "$USER_AUTH_TOKEN" ]]; then
|
||||
printf '%s\n' "-H" "Authorization: Bearer $USER_AUTH_TOKEN"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "$USER_SUBJECT_ID" ]]; then
|
||||
printf '%s\n' "-H" "X-Portal-Subject: $USER_SUBJECT_ID"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
curl_json_with_capture() {
|
||||
local method="$1"
|
||||
local url="$2"
|
||||
local headers_file="$3"
|
||||
local body_file="$4"
|
||||
local payload="${5:-}"
|
||||
local -a args
|
||||
args=(curl -sS --noproxy '*' -D "$headers_file" -o "$body_file" -X "$method")
|
||||
while IFS= read -r line; do
|
||||
args+=("$line")
|
||||
done < <(build_auth_args)
|
||||
if [[ -n "$payload" ]]; then
|
||||
args+=(-H 'Content-Type: application/json' -d "$payload")
|
||||
fi
|
||||
args+=("$url")
|
||||
"${args[@]}"
|
||||
}
|
||||
|
||||
curl_chat_with_capture() {
|
||||
local plaintext_key="$1"
|
||||
local payload="$2"
|
||||
local headers_file="$3"
|
||||
local body_file="$4"
|
||||
curl -sS --noproxy '*' \
|
||||
-D "$headers_file" \
|
||||
-o "$body_file" \
|
||||
-H "Authorization: Bearer $plaintext_key" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-X POST \
|
||||
-d "$payload" \
|
||||
"${USER_CHAT_BASE%/}/v1/chat/completions"
|
||||
}
|
||||
|
||||
extract_http_code() {
|
||||
local headers_file="$1"
|
||||
awk 'toupper($1) ~ /^HTTP\// { code=$2 } END { print code }' "$headers_file"
|
||||
}
|
||||
|
||||
json_get() {
|
||||
local file="$1"
|
||||
local expr="$2"
|
||||
python3 - "$file" "$expr" <<'PY'
|
||||
import json, sys
|
||||
path, expr = sys.argv[1:3]
|
||||
value = json.load(open(path, 'r', encoding='utf-8'))
|
||||
for part in expr.split('.'):
|
||||
if isinstance(value, dict):
|
||||
value = value.get(part)
|
||||
else:
|
||||
raise SystemExit(2)
|
||||
print("" if value is None else value)
|
||||
PY
|
||||
}
|
||||
|
||||
cmd_env_check() {
|
||||
info "env-check mode"
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
|
||||
if [[ -z "${CRM_BASE}" ]]; then
|
||||
warn "CRM_BASE is empty"
|
||||
local crm_health="unreachable"
|
||||
if crm_health=$(curl -sS --noproxy '*' "${CRM_BASE%/}/healthz" 2>/dev/null); then
|
||||
:
|
||||
else
|
||||
ok "CRM_BASE=${CRM_BASE}"
|
||||
crm_health="unreachable"
|
||||
fi
|
||||
|
||||
if [[ -n "${CRM_ADMIN_TOKEN:-}" ]]; then
|
||||
ok "CRM_ADMIN_TOKEN is set"
|
||||
local whoami
|
||||
whoami="$(curl -sS --noproxy '*' -H "Authorization: Bearer $CRM_ADMIN_TOKEN" "${CRM_BASE}/api/admin/session" 2>/dev/null)" || true
|
||||
if echo "${whoami}" | python3 -c "import sys,json; d=json.load(sys.stdin); d.get('authenticated',False) or d.get('username','')" 2>/dev/null; then
|
||||
ok "Admin session: valid"
|
||||
local chat_health="unset"
|
||||
if [[ -n "$USER_CHAT_BASE" ]]; then
|
||||
if chat_health=$(curl -sS --noproxy '*' "${USER_CHAT_BASE%/}/healthz" 2>/dev/null); then
|
||||
:
|
||||
else
|
||||
warn "Admin session: invalid. Phase 3 will establish login flow."
|
||||
chat_health="unreachable"
|
||||
fi
|
||||
else
|
||||
info "CRM_ADMIN_TOKEN not set — skipped (Phase 3 will implement login)"
|
||||
fi
|
||||
|
||||
# Check portal-admin-api reachability
|
||||
local health
|
||||
health="$(curl -sS --noproxy '*' "${CRM_BASE}/healthz" 2>/dev/null)" || true
|
||||
if [[ "${health}" == "ok" ]]; then
|
||||
ok "CRM health: OK"
|
||||
else
|
||||
warn "CRM health: ${health:-unreachable}"
|
||||
fi
|
||||
|
||||
# Write env-check summary
|
||||
local summary_file="$ARTIFACT_DIR/env-check-summary.json"
|
||||
python3 -c "
|
||||
import json, sys, datetime, os
|
||||
d = {
|
||||
'timestamp': datetime.datetime.now().isoformat(),
|
||||
'mode': 'env_check',
|
||||
'crm_base': os.environ.get('CRM_BASE', ''),
|
||||
'crm_reachable': '${health:-}' == 'ok',
|
||||
'admin_token_set': bool(os.environ.get('CRM_ADMIN_TOKEN', '')),
|
||||
'phase': 'skeleton',
|
||||
'note': 'Full verification deferred to vNext.2 (Phase 3)'
|
||||
OUT_PATH="$ARTIFACT_DIR/00-env.json" \
|
||||
CRM_BASE_PY="$CRM_BASE" \
|
||||
CRM_HEALTH="$crm_health" \
|
||||
USER_CHAT_BASE_PY="$USER_CHAT_BASE" \
|
||||
CHAT_HEALTH="$chat_health" \
|
||||
HAS_SUBJECT_ID="$USER_SUBJECT_ID" \
|
||||
HAS_AUTH_TOKEN="$USER_AUTH_TOKEN" \
|
||||
python3 - <<'PY'
|
||||
import json, os
|
||||
out = {
|
||||
"crm_base": os.environ["CRM_BASE_PY"],
|
||||
"crm_health": os.environ["CRM_HEALTH"],
|
||||
"user_chat_base": os.environ["USER_CHAT_BASE_PY"],
|
||||
"user_chat_health": os.environ["CHAT_HEALTH"],
|
||||
"has_user_subject_id": bool(os.environ["HAS_SUBJECT_ID"]),
|
||||
"has_user_auth_token": bool(os.environ["HAS_AUTH_TOKEN"]),
|
||||
}
|
||||
with open(sys.argv[1], 'w') as f:
|
||||
json.dump(d, f, ensure_ascii=False, indent=2)
|
||||
" "$summary_file"
|
||||
ok "env-check summary: $summary_file"
|
||||
with open(os.environ["OUT_PATH"], "w", encoding="utf-8") as fh:
|
||||
json.dump(out, fh, ensure_ascii=False, indent=2)
|
||||
PY
|
||||
if [[ "$crm_health" == "ok" ]]; then ok "CRM healthz=ok"; else warn "CRM healthz=$crm_health"; fi
|
||||
if [[ -n "$USER_CHAT_BASE" ]]; then info "user chat health=$chat_health"; fi
|
||||
ok "env summary: $ARTIFACT_DIR/00-env.json"
|
||||
}
|
||||
|
||||
cmd_run() {
|
||||
cmd_env_check
|
||||
[[ -n "$USER_CHAT_BASE" ]] || die "USER_CHAT_BASE is required for --run"
|
||||
if ! build_auth_args >/dev/null; then
|
||||
die "set USER_SUBJECT_ID or USER_AUTH_TOKEN for /api/keys authentication"
|
||||
fi
|
||||
|
||||
local create_payload create_code key_id plaintext_key masked_preview create_body
|
||||
create_payload='{"logical_group_id":"gpt-shared","display_name":"acceptance-key","allowed_models":["'"$CHAT_MODEL"'"]}'
|
||||
curl_json_with_capture POST "${CRM_BASE%/}/api/keys" "$ARTIFACT_DIR/10-create.headers.txt" "$ARTIFACT_DIR/10-create.body.json" "$create_payload" >/dev/null
|
||||
create_code="$(extract_http_code "$ARTIFACT_DIR/10-create.headers.txt")"
|
||||
[[ "$create_code" == "201" ]] || die "create key failed: HTTP $create_code"
|
||||
key_id="$(json_get "$ARTIFACT_DIR/10-create.body.json" 'key.key_id')"
|
||||
plaintext_key="$(json_get "$ARTIFACT_DIR/10-create.body.json" 'plaintext_key')"
|
||||
masked_preview="$(json_get "$ARTIFACT_DIR/10-create.body.json" 'key.masked_preview')"
|
||||
[[ -n "$key_id" && -n "$plaintext_key" ]] || die "create key response missing key_id/plaintext_key"
|
||||
ok "create key -> HTTP 201, key_id=$key_id"
|
||||
|
||||
curl_json_with_capture GET "${CRM_BASE%/}/api/keys" "$ARTIFACT_DIR/11-list.headers.txt" "$ARTIFACT_DIR/11-list.body.json" >/dev/null
|
||||
[[ "$(extract_http_code "$ARTIFACT_DIR/11-list.headers.txt")" == "200" ]] || die "list keys failed"
|
||||
ok "list keys -> HTTP 200"
|
||||
|
||||
curl_json_with_capture GET "${CRM_BASE%/}/api/keys/${key_id}" "$ARTIFACT_DIR/12-get.headers.txt" "$ARTIFACT_DIR/12-get.body.json" >/dev/null
|
||||
[[ "$(extract_http_code "$ARTIFACT_DIR/12-get.headers.txt")" == "200" ]] || die "get key failed"
|
||||
ok "get key -> HTTP 200"
|
||||
|
||||
curl_json_with_capture POST "${CRM_BASE%/}/api/keys/${key_id}/reset" "$ARTIFACT_DIR/13-reset.headers.txt" "$ARTIFACT_DIR/13-reset.body.json" '{}' >/dev/null
|
||||
[[ "$(extract_http_code "$ARTIFACT_DIR/13-reset.headers.txt")" == "200" ]] || die "reset key failed"
|
||||
plaintext_key="$(json_get "$ARTIFACT_DIR/13-reset.body.json" 'plaintext_key')"
|
||||
masked_preview="$(json_get "$ARTIFACT_DIR/13-reset.body.json" 'masked_preview')"
|
||||
[[ -n "$plaintext_key" && -n "$masked_preview" ]] || die "reset response missing plaintext_key/masked_preview"
|
||||
ok "reset key -> HTTP 200"
|
||||
|
||||
local chat_payload chat_code
|
||||
chat_payload='{"model":"'"$CHAT_MODEL"'","messages":[{"role":"user","content":"ping"}],"max_tokens":16,"temperature":0}'
|
||||
curl_chat_with_capture "$plaintext_key" "$chat_payload" "$ARTIFACT_DIR/20-chat.headers.txt" "$ARTIFACT_DIR/20-chat.body.json" >/dev/null
|
||||
chat_code="$(extract_http_code "$ARTIFACT_DIR/20-chat.headers.txt")"
|
||||
[[ "$chat_code" == "200" ]] || die "user chat failed: HTTP $chat_code"
|
||||
ok "user chat -> HTTP 200"
|
||||
|
||||
python3 - "$ARTIFACT_DIR/99-summary.json" <<PY
|
||||
import json
|
||||
summary = {
|
||||
"crm_base": ${CRM_BASE@Q},
|
||||
"user_chat_base": ${USER_CHAT_BASE@Q},
|
||||
"chat_model": ${CHAT_MODEL@Q},
|
||||
"key_id": ${key_id@Q},
|
||||
"masked_preview": ${masked_preview@Q},
|
||||
"create_http": int(${create_code@Q}),
|
||||
"list_http": 200,
|
||||
"get_http": 200,
|
||||
"reset_http": 200,
|
||||
"chat_http": 200,
|
||||
"checks": {
|
||||
"create_returns_plaintext_once": True,
|
||||
"list_returns_200": True,
|
||||
"get_returns_200": True,
|
||||
"reset_returns_new_plaintext": True,
|
||||
"user_chat_200": True,
|
||||
}
|
||||
}
|
||||
json.dump(summary, open(${ARTIFACT_DIR@Q} + "/99-summary.json", "w"), ensure_ascii=False, indent=2)
|
||||
PY
|
||||
cat "$ARTIFACT_DIR/99-summary.json"
|
||||
}
|
||||
|
||||
# --- main ---
|
||||
case "${1:---help}" in
|
||||
--help|-h) cmd_help ;;
|
||||
--env-check) cmd_env_check ;;
|
||||
*) cmd_help ;;
|
||||
--help|-h)
|
||||
usage
|
||||
;;
|
||||
--env-check)
|
||||
cmd_env_check
|
||||
;;
|
||||
--run)
|
||||
cmd_run
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
67
scripts/test/test_user_key_self_service_script.sh
Executable file
67
scripts/test/test_user_key_self_service_script.sh
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SCRIPT="$ROOT_DIR/scripts/acceptance/verify_user_key_self_service.sh"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local haystack="$1"
|
||||
local needle="$2"
|
||||
if [[ "$haystack" != *"$needle"* ]]; then
|
||||
fail "expected to find [$needle] in [$haystack]"
|
||||
fi
|
||||
}
|
||||
|
||||
[[ -f "$SCRIPT" ]] || fail "missing $SCRIPT"
|
||||
|
||||
help_output="$(bash "$SCRIPT" --help)"
|
||||
assert_contains "$help_output" "verify_user_key_self_service.sh"
|
||||
assert_contains "$help_output" "--env-check"
|
||||
assert_contains "$help_output" "--run"
|
||||
assert_contains "$help_output" "USER_CHAT_BASE"
|
||||
|
||||
tmpdir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
fakebin="$tmpdir/bin"
|
||||
mkdir -p "$fakebin"
|
||||
cat > "$fakebin/curl" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
url="${@: -1}"
|
||||
case "$url" in
|
||||
https://crm.example.com/healthz)
|
||||
printf 'ok'
|
||||
;;
|
||||
https://chat.example.com/healthz)
|
||||
printf '<!doctype html><html lang="zh-CN"><head><title>Sub2API</title></head><body>ok</body></html>'
|
||||
;;
|
||||
*)
|
||||
printf '{}'
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$fakebin/curl"
|
||||
|
||||
env_output="$(PATH="$fakebin:$PATH" ARTIFACT_DIR="$tmpdir/artifacts" CRM_BASE="https://crm.example.com" USER_CHAT_BASE="https://chat.example.com" bash "$SCRIPT" --env-check)"
|
||||
assert_contains "$env_output" "CRM healthz=ok"
|
||||
assert_contains "$env_output" "env summary"
|
||||
[[ -f "$tmpdir/artifacts/00-env.json" ]] || fail "missing env summary json"
|
||||
summary_text="$(cat "$tmpdir/artifacts/00-env.json")"
|
||||
assert_contains "$summary_text" '"crm_health": "ok"'
|
||||
assert_contains "$summary_text" '"user_chat_health": "<!doctype html><html lang=\"zh-CN\"'
|
||||
|
||||
set +e
|
||||
missing_auth_output="$(PATH="$fakebin:$PATH" ARTIFACT_DIR="$tmpdir/run-no-auth" CRM_BASE="https://crm.example.com" USER_CHAT_BASE="https://chat.example.com" bash "$SCRIPT" --run 2>&1)"
|
||||
missing_auth_status=$?
|
||||
set -e
|
||||
if [[ $missing_auth_status -eq 0 ]]; then
|
||||
fail "expected --run without auth to fail"
|
||||
fi
|
||||
assert_contains "$missing_auth_output" "set USER_SUBJECT_ID or USER_AUTH_TOKEN"
|
||||
|
||||
echo "PASS: user key self-service script regression checks"
|
||||
Reference in New Issue
Block a user