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

640 lines
28 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 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" <<EOF
schema dogfood-provision-cleanup/2
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
oauth 101 601 $ACCESS_TOKEN_HASH $REFRESH_TOKEN_HASH
EOF
chmod 600 "$TMP_DIR/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.sql" \
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 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" <<EOF
schema dogfood-provision-cleanup/2
run_id cleanup-run-$db_state
key passport_ip:login:day:127.0.0.1:20260723
oauth 101 601 $ACCESS_TOKEN_HASH $REFRESH_TOKEN_HASH
EOF
chmod 600 "$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" <<EOF
schema dogfood-provision-cleanup/2
run_id cleanup-run-target-count-drift
oauth 101 601 $ACCESS_TOKEN_HASH $REFRESH_TOKEN_HASH
EOF
chmod 600 "$target_manifest"
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='target-count-drift' DOGFOOD_FAKE_CLEANUP_SQL="$TMP_DIR/mysql-cleanup-target-count-drift.sql" \
DOGFOOD_FAKE_REDIS_DELETES="$TMP_DIR/target-count-drift-redis.log" \
"$SCRIPT" --cleanup "$target_manifest" cleanup-run-target-count-drift \
>"$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" <<EOF
schema dogfood-provision-cleanup/2
run_id cleanup-run-retry
oauth 101 601 $ACCESS_TOKEN_HASH $REFRESH_TOKEN_HASH
EOF
chmod 600 "$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'