408 lines
16 KiB
Bash
Executable File
408 lines
16 KiB
Bash
Executable File
#!/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"]
|
||
|
||
|
||
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":
|
||
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" \
|
||
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' \
|
||
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 -f '%Lp' "$TMP_DIR/dogfood-accounts.tsv" 2>/dev/null || stat -c '%a' "$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' \
|
||
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
|
||
|
||
printf 'TEST_PASS provision-dogfood-users 缺失 claim 补偿、40/40 单 claim、越权拒绝、幂等与脱敏校验通过\n'
|