#!/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 and authorization.startswith("Bearer fakeAccessTokenLogin"): 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, "accessToken": f"fakeAccessTokenRegister{user_id}", "refreshToken": f"fakeRefreshTokenRegister{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": f"fakeAccessTokenLogin{account['userId']}", "refreshToken": f"fakeRefreshTokenLogin{account['userId']}", }, }) 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 hashlib import os import re 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 "FROM system_oauth2_access_token a" in sql: tokens = re.findall(r"a\.access_token='([A-Za-z0-9_-]+)'.*r\.refresh_token='([A-Za-z0-9_-]+)'", sql, re.S) if len(tokens) != 1: raise SystemExit("OAuth 主键查询没有携带唯一 token 对") access_token, refresh_token = tokens[0] numeric = int(re.search(r"([0-9]+)$", access_token).group(1)) kind_offset = 0 if "Register" in access_token else 100000 print( numeric + kind_offset, numeric + kind_offset + 500000, hashlib.sha256(access_token.encode()).hexdigest(), hashlib.sha256(refresh_token.encode()).hexdigest(), sep="\t", ) raise SystemExit(0) 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 必须只经 stdin 传入,不能藏在可见进程参数中。 set -euo pipefail sql="$(cat)" [[ "$*" != *'fakeAccessToken'* && "$*" != *'fakeRefreshToken'* ]] || { printf '远端命令参数泄漏 OAuth bearer\n' >&2 exit 2 } case "$sql" in *'SELECT COUNT(DISTINCT p.id)'*|*'SELECT p.id'*|*'FROM system_oauth2_access_token a'*) ;; *) printf '远端未从 stdin 收到完整狗粮账号 SQL\n' >&2; exit 2 ;; esac DOGFOOD_FAKE_SQL="$sql" python3 - "$DOGFOOD_FAKE_STATE" <<'PY' import json import hashlib import os import re 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 "FROM system_oauth2_access_token a" in sql: tokens = re.findall(r"a\.access_token='([A-Za-z0-9_-]+)'.*r\.refresh_token='([A-Za-z0-9_-]+)'", sql, re.S) if len(tokens) != 1: raise SystemExit("远端 OAuth 主键查询没有携带唯一 token 对") access_token, refresh_token = tokens[0] numeric = int(re.search(r"([0-9]+)$", access_token).group(1)) kind_offset = 0 if "Register" in access_token else 100000 print( numeric + kind_offset, numeric + kind_offset + 500000, hashlib.sha256(access_token.encode()).hexdigest(), hashlib.sha256(refresh_token.encode()).hexdigest(), sep="\t", ) raise SystemExit(0) 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\|fakeAccessToken\|fakeRefreshToken\|fake-mysql-password' "$log_file" || \ fail "日志泄漏 token 或数据库密码" done # 持久清理清单只能保存数据库主键和不可逆摘要,不能保存 bearer 明文。 cleanup_manifest="$TMP_DIR/dogfood-accounts.tsv.runtime-cleanup.tsv" rg -q '^schema dogfood-provision-cleanup/2$' "$cleanup_manifest" || fail "清理清单未升级为主键撤销 schema" rg -q '^oauth [0-9]+ [0-9]+ [0-9a-f]{64} [0-9a-f]{64}$' "$cleanup_manifest" || \ fail "清理清单缺 OAuth 数据库主键与摘要" ! rg -q 'fakeAccessToken|fakeRefreshToken|oauth2_access_token:' "$cleanup_manifest" || \ fail "持久清理清单仍保存 bearer 或带 bearer 的 Redis key" # 手工场景仍可显式使用用户名密码;不能与 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 # 失败补偿先按主键+摘要事务删除 MySQL access/refresh 记录,再清四个限流桶与 access token 缓存。 cat >"$TMP_DIR/redis-cli" <<'SH' #!/usr/bin/env bash set -euo pipefail [ "${REDISCLI_AUTH:-}" = 'fake-redis-password' ] if [ "$*" = '-h 127.0.0.1 -p 16379 -n 0 --scan --pattern oauth2_access_token:*' ]; then printf 'oauth2_access_token:accessToken0123456789abcdef\n' printf 'oauth2_access_token:refreshToken0123456789abcdef\n' exit 0 fi [ "$*" = '-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/mysql-cleanup" <<'SH' #!/usr/bin/env bash set -euo pipefail sql="$(cat)" printf '%s\n' "$sql" >"$DOGFOOD_FAKE_CLEANUP_SQL" [[ "$sql" == *'DELETE a FROM system_oauth2_access_token'* ]] [[ "$sql" == *'DELETE r FROM system_oauth2_refresh_token'* ]] [[ "$sql" == *"CONCAT('PAIR\\t'"* ]] [[ "$sql" == *"CONCAT('VALID_PAIRS\\t'"* ]] [[ "$sql" == *"CONCAT('REMAINING\\t'"* ]] case "${DOGFOOD_FAKE_DB_STATE:-present}" in present) printf 'TARGET\t1\nPAIR\t101\t601\t1\t1\t1\t1\nVALID_PAIRS\t1\nREVOKED\t1\t1\nREMAINING\t0\t0\n' ;; both-missing) printf 'TARGET\t1\nPAIR\t101\t601\t0\t0\t0\t0\nVALID_PAIRS\t1\nREVOKED\t0\t0\nREMAINING\t0\t0\n' ;; access-only) printf 'TARGET\t1\nPAIR\t101\t601\t1\t1\t0\t0\nVALID_PAIRS\t0\nREVOKED\t0\t0\nREMAINING\t1\t0\n' ;; refresh-only) printf 'TARGET\t1\nPAIR\t101\t601\t0\t0\t1\t1\nVALID_PAIRS\t0\nREVOKED\t0\t0\nREMAINING\t0\t1\n' ;; access-hash-drift) printf 'TARGET\t1\nPAIR\t101\t601\t1\t0\t1\t1\nVALID_PAIRS\t0\nREVOKED\t0\t0\nREMAINING\t1\t1\n' ;; refresh-hash-drift) printf 'TARGET\t1\nPAIR\t101\t601\t1\t1\t1\t0\nVALID_PAIRS\t0\nREVOKED\t0\t0\nREMAINING\t1\t1\n' ;; valid-count-drift) printf 'TARGET\t1\nPAIR\t101\t601\t1\t1\t1\t1\nVALID_PAIRS\t0\nREVOKED\t0\t0\nREMAINING\t1\t1\n' ;; residual-after-delete) printf 'TARGET\t1\nPAIR\t101\t601\t1\t1\t1\t1\nVALID_PAIRS\t1\nREVOKED\t1\t1\nREMAINING\t1\t0\n' ;; target-count-drift) printf 'TARGET\t2\nPAIR\t101\t601\t1\t1\t1\t1\nVALID_PAIRS\t1\nREVOKED\t1\t1\nREMAINING\t0\t0\n' ;; *) exit 2 ;; esac SH chmod +x "$TMP_DIR/mysql-cleanup" ACCESS_TOKEN_HASH="$(printf 'accessToken0123456789abcdef' | shasum -a 256 | awk '{print $1}')" REFRESH_TOKEN_HASH="$(printf 'refreshToken0123456789abcdef' | shasum -a 256 | awk '{print $1}')" cat >"$TMP_DIR/runtime-cleanup.tsv" <"$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 oauth=1 keys=6$' \ "$TMP_DIR/runtime-cleanup.log" || fail "运行态补偿缺可审计结果" ! rg -q 'accessToken0123456789abcdef|refreshToken0123456789abcdef' \ "$TMP_DIR/runtime-cleanup.log" || fail "运行态补偿日志泄漏 token" # 单侧缺失和任一摘要漂移都必须停在 Redis 之前,并保留清单供人工复核。 for db_state in access-only refresh-only access-hash-drift refresh-hash-drift valid-count-drift residual-after-delete; do manifest="$TMP_DIR/${db_state}-runtime-cleanup.tsv" redis_log="$TMP_DIR/${db_state}-redis-deletes.log" cleanup_log="$TMP_DIR/${db_state}-runtime-cleanup.log" cat >"$manifest" <"$redis_log" if REDIS_PASSWORD='fake-redis-password' REDIS_CLI_BIN="$TMP_DIR/redis-cli" \ MYSQL_BIN="$TMP_DIR/mysql-cleanup" MYSQL_PASSWORD='fake-mysql-password' MYSQL_SSH_HOST='' \ DOGFOOD_FAKE_DB_STATE="$db_state" DOGFOOD_FAKE_CLEANUP_SQL="$TMP_DIR/mysql-cleanup-${db_state}.sql" \ DOGFOOD_FAKE_REDIS_DELETES="$redis_log" \ "$SCRIPT" --cleanup "$manifest" "cleanup-run-$db_state" >"$cleanup_log" 2>&1; then fail "OAuth 非法数据库状态仍被判为成功:$db_state" fi [ -e "$manifest" ] || fail "OAuth 状态校验失败后错误删除清理清单:$db_state" [ ! -s "$redis_log" ] || fail "OAuth 状态校验失败后仍清理了 Redis:$db_state" rg -q 'OAuth MySQL 撤销状态非法' "$cleanup_log" || fail "OAuth 非法状态缺少可追踪拒因:$db_state" done # SQL 自报的 TARGET 必须与清单目标数完全一致。 target_manifest="$TMP_DIR/target-count-drift-runtime-cleanup.tsv" cat >"$target_manifest" <"$TMP_DIR/target-count-drift.log" 2>&1; then fail "OAuth TARGET 漂移仍被判为成功" fi rg -q 'OAuth MySQL 撤销目标不匹配' "$TMP_DIR/target-count-drift.log" || \ fail "OAuth TARGET 漂移缺少可追踪拒因" # 数据库记录已成对撤销时,重试仍须用摘要定位并清掉遗留 Redis 缓存。 cat >"$TMP_DIR/retry-runtime-cleanup.tsv" <"$TMP_DIR/retry-redis-deletes.log" REDIS_PASSWORD='fake-redis-password' REDIS_CLI_BIN="$TMP_DIR/redis-cli" \ MYSQL_BIN="$TMP_DIR/mysql-cleanup" MYSQL_PASSWORD='fake-mysql-password' MYSQL_SSH_HOST='' \ DOGFOOD_FAKE_DB_STATE='both-missing' DOGFOOD_FAKE_CLEANUP_SQL="$TMP_DIR/mysql-cleanup-retry.sql" \ DOGFOOD_FAKE_REDIS_DELETES="$TMP_DIR/retry-redis-deletes.log" \ "$SCRIPT" --cleanup "$TMP_DIR/retry-runtime-cleanup.tsv" cleanup-run-retry \ >"$TMP_DIR/retry-runtime-cleanup.log" [ "$(sort -u "$TMP_DIR/retry-redis-deletes.log" | wc -l | tr -d ' ')" = 2 ] || \ fail "MySQL 已撤销后的重试未按摘要清理 Redis" rg -q '^DOGFOOD_RUNTIME_CLEANUP_PASS run_id=cleanup-run-retry oauth=1 keys=2$' \ "$TMP_DIR/retry-runtime-cleanup.log" || fail "幂等 OAuth 清理缺少可审计结果" # 预检阶段尚无 Redis 副作用时,只有 schema/run-id 的清单应幂等收口为 keys=0。 cat >"$TMP_DIR/empty-runtime-cleanup.tsv" <<'EOF' schema dogfood-provision-cleanup/2 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" \ MYSQL_BIN="$TMP_DIR/mysql-cleanup" MYSQL_PASSWORD='fake-mysql-password' MYSQL_SSH_HOST='' \ DOGFOOD_FAKE_CLEANUP_SQL="$TMP_DIR/mysql-cleanup-empty.sql" \ 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 oauth=0 keys=0$' \ "$TMP_DIR/empty-runtime-cleanup.log" || fail "空运行态清理清单未幂等收口" printf 'TEST_PASS provision-dogfood-users 缺失 claim 补偿、运行态清理、40/40 单 claim、越权拒绝、幂等与脱敏校验通过\n'