games-development-ai/deploy/tests/test-provision-dogfood-users.sh

470 lines
20 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# 使用本地假 HTTP 服务与假 MySQL 客户端验证狗粮账号预置脚本,不连接真实环境。
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SCRIPT="$ROOT_DIR/deploy/provision-dogfood-users.sh"
TMP_DIR="$(mktemp -d)"
SERVER_PID=''
cleanup() {
if [ -n "$SERVER_PID" ]; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
fail() {
printf 'TEST_FAIL %s\n' "$*" >&2
exit 1
}
# 服务状态同时供假 HTTP 服务与假 MySQL 查询读取;原子替换避免读到半写文件。
printf '{"accounts":{},"creators":{},"claims":[],"reconcileCalls":[]}\n' >"$TMP_DIR/state.json"
cat >"$TMP_DIR/fake-server.py" <<'PY'
import json
import os
import tempfile
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from socketserver import TCPServer
state_file = os.environ["DOGFOOD_FAKE_STATE"]
port_file = os.environ["DOGFOOD_FAKE_PORT_FILE"]
request_log = os.environ["DOGFOOD_FAKE_REQUEST_LOG"]
provisioning_run_id = os.environ["DOGFOOD_FAKE_PROVISIONING_RUN_ID"]
provisioning_capability = os.environ["DOGFOOD_FAKE_PROVISIONING_CAPABILITY"]
def load_state():
with open(state_file, encoding="utf-8") as handle:
return json.load(handle)
def save_state(state):
directory = os.path.dirname(state_file)
fd, temporary = tempfile.mkstemp(dir=directory)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(state, handle)
os.replace(temporary, state_file)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
class Handler(BaseHTTPRequestHandler):
def log_message(self, _format, *_args):
return
def response(self, payload, status=200):
body = json.dumps(payload, separators=(",", ":")).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def json_body(self):
length = int(self.headers.get("Content-Length", "0"))
return json.loads(self.rfile.read(length) or b"{}")
def record(self):
with open(request_log, "a", encoding="utf-8") as handle:
handle.write(f"{self.command} {self.path}\n")
def do_GET(self):
self.record()
if self.path == "/app-api/ad/slot/list-enabled":
self.response({"code": 1111002006, "data": None})
return
if self.path == "/app-api/trade/charge/points-balance" or self.path.startswith(
"/app-api/trade/withdraw/page?"
):
authorization = self.headers.get("Authorization")
if authorization == "Bearer fake-player-token":
self.response({"code": 1106008000, "data": None})
else:
# mock token 已关闭:test1 或无 token 都先被可信鉴权边界拒绝。
self.response({"code": 401, "data": None}, 401)
return
self.response({"code": 404}, 404)
def do_POST(self):
self.record()
body = self.json_body()
if self.path == "/admin-api/system/auth/login":
self.response({"code": 0, "data": {"accessToken": "fake-admin-token"}})
return
state = load_state()
if self.path.startswith("/admin-api/aigc/quota/claims/") and self.path.endswith("/reconcile"):
if self.headers.get("Authorization") not in (
"Bearer fake-admin-token",
"Bearer bootstrap-token-from-file",
):
self.response({"code": 401, "msg": "未授权"}, 401)
return
try:
user_id = int(self.path.split("/")[-2])
except (ValueError, IndexError):
self.response({"code": 400, "msg": "userId 非法"}, 400)
return
known_ids = {account["userId"] for account in state["accounts"].values()}
if user_id not in known_ids:
self.response({"code": 0, "data": False})
return
# 模拟后端唯一键与 CAS:重复补偿只记录调用,不增加第二条 claim。
state["reconcileCalls"].append(user_id)
if user_id not in state["claims"]:
state["claims"].append(user_id)
save_state(state)
self.response({"code": 0, "data": True})
return
if self.path == "/app-api/passport/password-register":
if (
self.headers.get("X-Dogfood-Provisioning-Run-Id") != provisioning_run_id
or self.headers.get("X-Dogfood-Provisioning-Capability") != provisioning_capability
):
self.response({"code": 1002090007, "msg": "预置凭据错误"})
return
username = body.get("username", "")
if username in state["accounts"]:
self.response({"code": 1002090004, "msg": "用户名已存在"})
return
user_id = 1000 + len(state["accounts"]) + 1
state["accounts"][username] = {
"password": body.get("password", ""),
"userId": user_id,
}
save_state(state)
self.response({"code": 0, "data": {"userId": user_id}})
return
if self.path == "/app-api/passport/password-login":
account = state["accounts"].get(body.get("username", ""))
if not account or account["password"] != body.get("password"):
self.response({"code": 1002090002, "msg": "账号或密码错误"})
return
self.response({
"code": 0,
"data": {"userId": account["userId"], "accessToken": "fake-player-token"},
})
return
self.response({"code": 404}, 404)
def do_PUT(self):
self.record()
if self.path != "/admin-api/passport/player/set-creator":
self.response({"code": 404}, 404)
return
body = self.json_body()
state = load_state()
user_id = body.get("userId")
username = next(
(name for name, account in state["accounts"].items() if account["userId"] == user_id),
None,
)
if username is None:
self.response({"code": 404}, 404)
return
state["creators"][username] = True
save_state(state)
self.response({"code": 0, "data": True})
class LocalThreadingHTTPServer(ThreadingHTTPServer):
def server_bind(self):
# 跳过 HTTPServer.server_bind 的反向 DNS 查询,离线构建机不能依赖主机名解析。
TCPServer.server_bind(self)
self.server_name, self.server_port = self.server_address[:2]
server = LocalThreadingHTTPServer(("127.0.0.1", 0), Handler)
with open(port_file, "w", encoding="utf-8") as handle:
handle.write(str(server.server_port))
server.serve_forever()
PY
cat >"$TMP_DIR/mysql" <<'SH'
#!/usr/bin/env bash
# 根据 SQL 返回聚合计数或 40 个目标账号中缺失 claim 的 userId。
set -euo pipefail
sql="$(cat)"
DOGFOOD_FAKE_SQL="$sql" python3 - "$DOGFOOD_FAKE_STATE" <<'PY'
import json
import os
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
state = json.load(handle)
accounts = len(state["accounts"])
creators = len(state["creators"])
claims = state["claims"]
free = 52 - len(claims)
mode = os.environ.get("DOGFOOD_FAKE_COUNTS_MODE", "all")
duplicates = 1 if mode == "duplicate" else 0
sql = os.environ["DOGFOOD_FAKE_SQL"]
if "SELECT p.id" in sql:
missing = sorted(
account["userId"] for account in state["accounts"].values()
if account["userId"] not in claims
)
if os.environ.get("DOGFOOD_FAKE_MISSING_MODE") == "rogue":
missing.append(999999)
print(*missing, sep="\n")
raise SystemExit(0)
print(accounts, creators, 0, len(claims), duplicates, free, sep="\t")
PY
SH
chmod +x "$TMP_DIR/mysql"
cat >"$TMP_DIR/ssh" <<'SH'
#!/usr/bin/env bash
# 模拟远端只读查询:验证最后一个参数确为完整 SQL 的 Base64,再返回计数或缺失列表。
set -euo pipefail
sql_base64="${!#}"
sql="$(printf '%s' "$sql_base64" | base64 -d)"
case "$sql" in
*'SELECT COUNT(DISTINCT p.id)'*|*'SELECT p.id'*) ;;
*) printf '远端未收到完整狗粮账号 SQL\n' >&2; exit 2 ;;
esac
cat >/dev/null
DOGFOOD_FAKE_SQL="$sql" python3 - "$DOGFOOD_FAKE_STATE" <<'PY'
import json
import os
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
state = json.load(handle)
accounts = len(state["accounts"])
creators = len(state["creators"])
claims = state["claims"]
mode = os.environ.get("DOGFOOD_FAKE_COUNTS_MODE", "all")
duplicates = 1 if mode == "duplicate" else 0
sql = os.environ["DOGFOOD_FAKE_SQL"]
if "SELECT p.id" in sql:
missing = sorted(
account["userId"] for account in state["accounts"].values()
if account["userId"] not in claims
)
if os.environ.get("DOGFOOD_FAKE_MISSING_MODE") == "rogue":
missing.append(999999)
print(*missing, sep="\n")
raise SystemExit(0)
print(accounts, creators, 0, len(claims), duplicates, 52 - len(claims), sep="\t")
PY
SH
chmod +x "$TMP_DIR/ssh"
DOGFOOD_FAKE_STATE="$TMP_DIR/state.json" \
DOGFOOD_FAKE_PORT_FILE="$TMP_DIR/port" \
DOGFOOD_FAKE_REQUEST_LOG="$TMP_DIR/requests.log" \
DOGFOOD_FAKE_PROVISIONING_RUN_ID='dogfood-test-run-001' \
DOGFOOD_FAKE_PROVISIONING_CAPABILITY='0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' \
python3 "$TMP_DIR/fake-server.py" >"$TMP_DIR/server.log" 2>&1 &
SERVER_PID=$!
for _ in $(seq 1 300); do
[ -s "$TMP_DIR/port" ] && break
sleep 0.1
done
[ -s "$TMP_DIR/port" ] || fail "假 HTTP 服务未启动"
PORT="$(<"$TMP_DIR/port")"
run_provision() {
local log_file="$1"
local mysql_ssh_host="${2:-}"
local expected="${3:-pass}"
if ! PROVISION_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' \
DOGFOOD_PASSWORD_SEED='test-seed-must-not-appear' \
DOGFOOD_PROVISION_RUN_ID='dogfood-test-run-001' \
DOGFOOD_PROVISION_CAPABILITY='0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' \
ADMIN_TOKEN_FILE="$TMP_DIR/bootstrap-admin.token" \
BASE="http://127.0.0.1:$PORT" \
MYSQL_BIN="$TMP_DIR/mysql" \
MYSQL_PASSWORD='fake-mysql-password' \
MYSQL_SSH_HOST="$mysql_ssh_host" \
DOGFOOD_FAKE_COUNTS_MODE="${DOGFOOD_FAKE_COUNTS_MODE:-all}" \
DOGFOOD_FAKE_MISSING_MODE="${DOGFOOD_FAKE_MISSING_MODE:-}" \
DOGFOOD_FAKE_STATE="$TMP_DIR/state.json" \
DOGFOOD_ACCOUNTS_FILE="$TMP_DIR/dogfood-accounts.tsv" \
VERIFY_TIMEOUT_SEC=2 \
PATH="$TMP_DIR:$PATH" \
"$SCRIPT" >"$log_file" 2>&1; then
[ "$expected" = fail ] && return 0
sed -n '1,160p' "$log_file" >&2
fail "预置脚本执行失败"
fi
[ "$expected" = pass ] || fail "缺失 claim 的终验场景被错误放行:$DOGFOOD_FAKE_COUNTS_MODE"
}
printf 'bootstrap-token-from-file\n' >"$TMP_DIR/bootstrap-admin.token"
chmod 600 "$TMP_DIR/bootstrap-admin.token"
# 第一遍模拟 40 条注册消息全部异常:账号已创建但 claim 全缺,脚本必须逐个主动补偿。
run_provision "$TMP_DIR/first.log"
! rg -q '^POST /admin-api/system/auth/login$' "$TMP_DIR/requests.log" || \
fail "ADMIN_TOKEN_FILE 场景仍触发 admin 密码登录"
for protected_path in \
'/app-api/ad/slot/list-enabled' \
'/app-api/trade/charge/points-balance' \
'/app-api/trade/withdraw/page'; do
rg -q "^GET ${protected_path}" "$TMP_DIR/requests.log" || fail "未预检安全守卫:$protected_path"
done
python3 - "$TMP_DIR/state.json" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as handle:
state = json.load(handle)
assert len(state["claims"]) == 40, state
assert len(state["reconcileCalls"]) == 40, state
state["reconcileCalls"] = []
with open(sys.argv[1], "w", encoding="utf-8") as handle:
json.dump(state, handle)
PY
# 第二遍覆盖“账号已存在且 claim 已齐”的幂等路径:不得调用任何补偿接口。
run_provision "$TMP_DIR/second.log"
python3 - "$TMP_DIR/state.json" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as handle:
state = json.load(handle)
assert state["reconcileCalls"] == [], state
# 制造一个“账号已存在但 claim 缺失”的历史失败,再验证只修这一人。
state["claims"].remove(1007)
with open(sys.argv[1], "w", encoding="utf-8") as handle:
json.dump(state, handle)
PY
run_provision "$TMP_DIR/recover-one.log"
python3 - "$TMP_DIR/state.json" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as handle:
state = json.load(handle)
assert state["reconcileCalls"] == [1007], state
assert len(state["claims"]) == 40, state
state["reconcileCalls"] = []
with open(sys.argv[1], "w", encoding="utf-8") as handle:
json.dump(state, handle)
PY
# 第三遍覆盖从开发机调用时的远端 MySQL 只读查询传参路径。
run_provision "$TMP_DIR/remote.log" 'fake-remote'
[ "$(wc -l <"$TMP_DIR/dogfood-accounts.tsv" | tr -d ' ')" = '41' ] || fail "凭据文件行数不是 40+表头"
grep -q $'^dogfoodc01\tcreator\t' "$TMP_DIR/dogfood-accounts.tsv" || fail "缺少 dogfoodc01"
grep -q $'^dogfoodc10\tcreator\t' "$TMP_DIR/dogfood-accounts.tsv" || fail "缺少 dogfoodc10"
grep -q $'^dogfoodp01\tplayer\t' "$TMP_DIR/dogfood-accounts.tsv" || fail "缺少 dogfoodp01"
grep -q $'^dogfoodp30\tplayer\t' "$TMP_DIR/dogfood-accounts.tsv" || fail "缺少 dogfoodp30"
mode="$(stat -c '%a' "$TMP_DIR/dogfood-accounts.tsv" 2>/dev/null || stat -f '%Lp' "$TMP_DIR/dogfood-accounts.tsv")"
[ "$mode" = '600' ] || fail "凭据文件权限不是 600:$mode"
for log_file in "$TMP_DIR/first.log" "$TMP_DIR/second.log" "$TMP_DIR/recover-one.log" "$TMP_DIR/remote.log"; do
grep -q 'PROVISION_PASS accounts=40 creators=10 role_mismatches=0 claimed_accounts=40 duplicate_claims=0 FREE=12' "$log_file" \
|| fail "缺少终验通过证据:$log_file"
! grep -q 'test-seed-must-not-appear' "$log_file" || fail "日志泄漏 seed"
! grep -q 'admin-password-must-not-appear' "$log_file" || fail "日志泄漏 admin 密码"
! grep -q 'bootstrap-token-from-file\|fake-admin-token\|fake-player-token\|fake-mysql-password' "$log_file" || \
fail "日志泄漏 token 或数据库密码"
done
# 手工场景仍可显式使用用户名密码;不能与 token 文件混用。
rm -f "$TMP_DIR/manual-requests.log"
mv "$TMP_DIR/requests.log" "$TMP_DIR/token-requests.log"
: >"$TMP_DIR/requests.log"
PROVISION_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' \
DOGFOOD_PASSWORD_SEED='test-seed-must-not-appear' ADMIN_USERNAME='admin' \
DOGFOOD_PROVISION_RUN_ID='dogfood-test-run-001' \
DOGFOOD_PROVISION_CAPABILITY='0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' \
ADMIN_PASSWORD='admin-password-must-not-appear' BASE="http://127.0.0.1:$PORT" \
MYSQL_BIN="$TMP_DIR/mysql" MYSQL_PASSWORD='fake-mysql-password' MYSQL_SSH_HOST='' \
DOGFOOD_FAKE_STATE="$TMP_DIR/state.json" DOGFOOD_ACCOUNTS_FILE="$TMP_DIR/manual-accounts.tsv" \
VERIFY_TIMEOUT_SEC=2 PATH="$TMP_DIR:$PATH" "$SCRIPT" >"$TMP_DIR/manual.log" 2>&1
rg -q '^POST /admin-api/system/auth/login$' "$TMP_DIR/requests.log" || fail "手工账号密码模式未登录 admin"
# token 文件必须是 0600 普通文件,且两种认证来源不能同时提供。
chmod 644 "$TMP_DIR/bootstrap-admin.token"
if PROVISION_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' DOGFOOD_PASSWORD_SEED=x \
ADMIN_TOKEN_FILE="$TMP_DIR/bootstrap-admin.token" BASE="http://127.0.0.1:$PORT" \
"$SCRIPT" >"$TMP_DIR/wide-token.log" 2>&1; then
fail "权限过宽的 ADMIN_TOKEN_FILE 未被拒绝"
fi
chmod 600 "$TMP_DIR/bootstrap-admin.token"
ln -s "$TMP_DIR/bootstrap-admin.token" "$TMP_DIR/bootstrap-admin-link.token"
if PROVISION_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' DOGFOOD_PASSWORD_SEED=x \
ADMIN_TOKEN_FILE="$TMP_DIR/bootstrap-admin-link.token" BASE="http://127.0.0.1:$PORT" \
"$SCRIPT" >"$TMP_DIR/symlink-token.log" 2>&1; then
fail "symlink ADMIN_TOKEN_FILE 未被拒绝"
fi
if PROVISION_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' DOGFOOD_PASSWORD_SEED=x \
ADMIN_TOKEN_FILE="$TMP_DIR/bootstrap-admin.token" ADMIN_USERNAME=admin ADMIN_PASSWORD=secret \
BASE="http://127.0.0.1:$PORT" "$SCRIPT" >"$TMP_DIR/mixed-auth.log" 2>&1; then
fail "token 文件与用户名密码混用未被拒绝"
fi
# 数据库缺失查询若返回本次 40 人之外的编号,脚本必须在请求后端前拒绝,防止越权消耗额度。
DOGFOOD_FAKE_MISSING_MODE=rogue run_provision "$TMP_DIR/rogue-missing-id.log" '' fail
rg -q '不属于本次 40 个目标账号' "$TMP_DIR/rogue-missing-id.log" || fail "未拒绝越界补偿 userId"
# 重复 claim 属数据不变量破坏,不能通过补偿接口掩盖。
DOGFOOD_FAKE_COUNTS_MODE=duplicate run_provision "$TMP_DIR/duplicate-claims.log" '' fail
rg -q 'duplicate_claims=1' "$TMP_DIR/duplicate-claims.log" || fail "未报告重复 claim"
# 危险边界必须在任何网络请求前拒绝。
if PROVISION_ACK='wrong' DOGFOOD_PASSWORD_SEED=x ADMIN_TOKEN_FILE="$TMP_DIR/bootstrap-admin.token" \
BASE="http://127.0.0.1:$PORT" "$SCRIPT" >"$TMP_DIR/bad-ack.log" 2>&1; then
fail "错误 ACK 未被拒绝"
fi
if PROVISION_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' DOGFOOD_PASSWORD_SEED=x \
ADMIN_TOKEN_FILE="$TMP_DIR/bootstrap-admin.token" BASE='http://localhost:48080' \
"$SCRIPT" >"$TMP_DIR/bad-base.log" 2>&1; then
fail "非法 BASE host 未被拒绝"
fi
# 失败补偿只按本轮 0600 清单删除四个精确限流桶和本轮 OAuth access/refresh 缓存 key。
cat >"$TMP_DIR/redis-cli" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
[ "${REDISCLI_AUTH:-}" = 'fake-redis-password' ]
[ "$*" = '-h 127.0.0.1 -p 16379 -n 0 -x DEL' ]
key="$(cat)"
printf '%s\n' "$key" >>"$DOGFOOD_FAKE_REDIS_DELETES"
printf '1\n'
SH
chmod +x "$TMP_DIR/redis-cli"
cat >"$TMP_DIR/runtime-cleanup.tsv" <<'EOF'
schema dogfood-provision-cleanup/1
run_id cleanup-run-001
key passport_ip:register:hour:127.0.0.1:2026072312
key passport_ip:register:day:127.0.0.1:20260723
key passport_ip:login:hour:127.0.0.1:2026072312
key passport_ip:login:day:127.0.0.1:20260723
key oauth2_access_token:accessToken0123456789abcdef
key oauth2_access_token:refreshToken0123456789abcdef
EOF
chmod 600 "$TMP_DIR/runtime-cleanup.tsv"
REDIS_PASSWORD='fake-redis-password' REDIS_CLI_BIN="$TMP_DIR/redis-cli" \
DOGFOOD_FAKE_REDIS_DELETES="$TMP_DIR/redis-deletes.log" \
"$SCRIPT" --cleanup "$TMP_DIR/runtime-cleanup.tsv" cleanup-run-001 \
>"$TMP_DIR/runtime-cleanup.log"
[ "$(sort -u "$TMP_DIR/redis-deletes.log" | wc -l | tr -d ' ')" = 6 ] || \
fail "运行态补偿未精确删除 4 个限流桶与 2 个 token key"
[ ! -e "$TMP_DIR/runtime-cleanup.tsv" ] || fail "补偿成功后未删除敏感清单"
rg -q '^DOGFOOD_RUNTIME_CLEANUP_PASS run_id=cleanup-run-001 keys=6$' \
"$TMP_DIR/runtime-cleanup.log" || fail "运行态补偿缺可审计结果"
! rg -q 'accessToken0123456789abcdef|refreshToken0123456789abcdef' \
"$TMP_DIR/runtime-cleanup.log" || fail "运行态补偿日志泄漏 token"
# 预检阶段尚无 Redis 副作用时,只有 schema/run-id 的清单应幂等收口为 keys=0。
cat >"$TMP_DIR/empty-runtime-cleanup.tsv" <<'EOF'
schema dogfood-provision-cleanup/1
run_id cleanup-run-empty
EOF
chmod 600 "$TMP_DIR/empty-runtime-cleanup.tsv"
REDIS_PASSWORD='fake-redis-password' REDIS_CLI_BIN="$TMP_DIR/redis-cli" \
DOGFOOD_FAKE_REDIS_DELETES="$TMP_DIR/redis-deletes.log" \
"$SCRIPT" --cleanup "$TMP_DIR/empty-runtime-cleanup.tsv" cleanup-run-empty \
>"$TMP_DIR/empty-runtime-cleanup.log"
[ ! -e "$TMP_DIR/empty-runtime-cleanup.tsv" ] || fail "空运行态清理清单未幂等删除"
rg -q '^DOGFOOD_RUNTIME_CLEANUP_PASS run_id=cleanup-run-empty keys=0$' \
"$TMP_DIR/empty-runtime-cleanup.log" || fail "空运行态清理清单未幂等收口"
printf 'TEST_PASS provision-dogfood-users 缺失 claim 补偿、运行态清理、40/40 单 claim、越权拒绝、幂等与脱敏校验通过\n'