fix(deploy): 历史条数不再假报 0,明文令牌的 /tmp env 文件必删

两处都是「看起来成功、实际给了错误信息/留下凭据」的问题。

A. 历史条数假数据(行为变化)
   旧逻辑:ADMIN_TOK=$(grep ... || true) 取不到时仍发一个空的 `Bearer `
   请求;第 1 步把 history-server 改成 fail-closed 后它必然回 401 JSON,
   而 `grep -o '"url"' | wc -l` 对错误响应数出 0,再被 HIST=${HIST:-0}
   兜一层,于是对外打印「历史条数 0」—— 一个假数据,看起来像历史被清空。
   现在抽成 human_history_count(),三条路径分开:
     · 未配置 NAVI_ADMIN_TOKEN → 「未知(未配置…跳过探测)」,不发任何请求
     · 非 200(401/502/连不上的 000) → 「未知(HTTP xxx)」,不数 body
     · 只有 200 才数 '"url"' 出现次数
   ⚠️ 行为变化:token 取不到时历史条数显示「未知」而不是「0」;
   真实的 0 条(200 + 空列表)仍然显示 0。仍旧绝不阻断升级。

B. 明文令牌的 env 文件残留
   /tmp/navi-host-<stamp>.env 含 NAVI_ADMIN_TOKEN / NAVI_SYNC_TOKEN 明文,
   chmod 600 只防别的用户读,不防它一直躺在 /tmp。现在:
     · 统一为 $ENV_FILE(含 .t / .new 中间文件),不再散落字面量路径
     · 新增 cleanup_env_file(),同时删三个文件
     · 注册 trap ... EXIT(任何 exit 都删)+ fail() 回滚路径显式调用
       + 成功收尾显式调用(三处)
   删它不影响已起容器:docker run 已在启动时把值注入容器 env。

顺带确认(未改动,仅补注释):重置门户口令后打印一次 $NEWPW 是有意设计
——那是管理员唯一一次拿到它的机会(数据卷只存哈希),注释已写明
「只打印这一次」,且不写日志文件、不进任何 env。

新增 deploy/zd-host_test.sh(bash deploy/zd-host_test.sh,退出码 0=全绿):
1. bash -n 语法检查;
2. 静态护栏:轮换只打印 ${#VAR} 长度、无 set -x、无 HIST=${HIST:-0}、
   无「grep url | wc -l || echo 0」旧写法、必须校验 HTTP 状态码、
   env 文件在 fail() 与 EXIT trap 与成功路径上都有删除、中间文件也删;
3. 用 BEGIN/END 标记把 human_history_count 原样抠出来,配 stub curl 单测
   空 token / 401 / 502 / 连接失败 / 200 三条 / 200 空列表 六种情形,
   并断言空 token 时根本没发请求、有 token 时带了 Authorization 头。

反向验证:把历史条数换回旧写法并删掉三处 env 文件清理后,
测试从 26 通过 / 0 失败变成 10 通过 / 16 失败(列出「仍有 HIST=${HIST:-0}」
「fail() 回滚路径没有删 env 文件」「空 token 时仍然调用了 curl」等)。
另外用第 1 步的真实 history-server + 模拟两层鉴权的反代做了端到端复核,
旧写法在 401/连接失败下都报 0,新写法分别报「未知(HTTP 401)」「未知(HTTP 000)」,
200 + 3 条报 3,200 + 空列表报 0。
This commit is contained in:
JianFeeeee
2026-10-22 23:25:26 +08:00
parent 5562d584d3
commit aae3fb7065
2 changed files with 346 additions and 24 deletions

View File

@ -118,17 +118,27 @@ if [ "$NET" != "host" ]; then
fi
OLD_ID=$(docker inspect navi --format '{{.Id}}')
# 该文件含**明文令牌**(NAVI_ADMIN_TOKEN / NAVI_SYNC_TOKEN 等)。chmod 600 只防别的
# 用户读,不能防它一直躺在 /tmp 里,所以成功、失败回滚、以及任何 exit 都要删。
# 删它不影响已起的容器:docker run 已在启动时把值注入容器 env(--env-file 只读一次)。
ENV_FILE="/tmp/navi-host-${STAMP}.env"
cleanup_env_file() {
rm -f "$ENV_FILE" "$ENV_FILE.t" "$ENV_FILE.new" 2>/dev/null || true
}
trap cleanup_env_file EXIT
docker inspect navi --format '{{range .Config.Env}}{{println .}}{{end}}' \
| grep -E '^(NAVI_ADMIN_TOKEN|NAVI_SYNC_TOKEN|SEARXNG_BASE_URL|PORTAL_|DEBIAN_FRONTEND|CERT_)' > "/tmp/navi-host-${STAMP}.env" || true
chmod 600 "/tmp/navi-host-${STAMP}.env"
| grep -E '^(NAVI_ADMIN_TOKEN|NAVI_SYNC_TOKEN|SEARXNG_BASE_URL|PORTAL_|DEBIAN_FRONTEND|CERT_)' > "$ENV_FILE" || true
chmod 600 "$ENV_FILE"
# ① 不再把明文门户口令带进新容器(docker inspect 就能看到它)。
# 实际生效的是数据卷里的加盐哈希,所以丢掉 env 不影响登录;
# 稍后会在新容器里写入一个强度更高的随机口令并打印一次。
CLEAN_PORTAL_PW=0
if grep -q '^PORTAL_PASSWORD=' "/tmp/navi-host-${STAMP}.env"; then
grep -v '^PORTAL_PASSWORD=' "/tmp/navi-host-${STAMP}.env" > "/tmp/navi-host-${STAMP}.env.new" && mv "/tmp/navi-host-${STAMP}.env.new" "/tmp/navi-host-${STAMP}.env"
CLEAN_PORTAL_PW=1
if grep -q '^PORTAL_PASSWORD=' "$ENV_FILE"; then
grep -v '^PORTAL_PASSWORD=' "$ENV_FILE" > "$ENV_FILE.new" && mv "$ENV_FILE.new" "$ENV_FILE"
CLEAN_PORTAL_PW=1
fi
# ③ 令牌轮换:渗透报告证明旧 NAVI_ADMIN_TOKEN / NAVI_SYNC_TOKEN 已泄露
@ -141,9 +151,9 @@ if [ "${#NAVI_ROTATE_ADMIN_TOKEN}" -lt 32 ]; then
echo "!! NAVI_ROTATE_ADMIN_TOKEN 太短(${#NAVI_ROTATE_ADMIN_TOKEN} 字符),至少 32" >&2
exit 2
fi
grep -v '^NAVI_ADMIN_TOKEN=' "/tmp/navi-host-${STAMP}.env" > "/tmp/navi-host-${STAMP}.env.t" || true
echo "NAVI_ADMIN_TOKEN=${NAVI_ROTATE_ADMIN_TOKEN}" >> "/tmp/navi-host-${STAMP}.env.t"
mv "/tmp/navi-host-${STAMP}.env.t" "/tmp/navi-host-${STAMP}.env"
grep -v '^NAVI_ADMIN_TOKEN=' "$ENV_FILE" > "$ENV_FILE.t" || true
echo "NAVI_ADMIN_TOKEN=${NAVI_ROTATE_ADMIN_TOKEN}" >> "$ENV_FILE.t"
mv "$ENV_FILE.t" "$ENV_FILE"
echo " 已轮换 NAVI_ADMIN_TOKEN(新值长度 ${#NAVI_ROTATE_ADMIN_TOKEN})"
fi
if [ -n "${NAVI_ROTATE_SYNC_TOKEN:-}" ]; then
@ -151,26 +161,26 @@ if [ "${#NAVI_ROTATE_SYNC_TOKEN}" -lt 32 ]; then
echo "!! NAVI_ROTATE_SYNC_TOKEN 太短(${#NAVI_ROTATE_SYNC_TOKEN} 字符),至少 32" >&2
exit 2
fi
grep -v '^NAVI_SYNC_TOKEN=' "/tmp/navi-host-${STAMP}.env" > "/tmp/navi-host-${STAMP}.env.t" || true
echo "NAVI_SYNC_TOKEN=${NAVI_ROTATE_SYNC_TOKEN}" >> "/tmp/navi-host-${STAMP}.env.t"
mv "/tmp/navi-host-${STAMP}.env.t" "/tmp/navi-host-${STAMP}.env"
grep -v '^NAVI_SYNC_TOKEN=' "$ENV_FILE" > "$ENV_FILE.t" || true
echo "NAVI_SYNC_TOKEN=${NAVI_ROTATE_SYNC_TOKEN}" >> "$ENV_FILE.t"
mv "$ENV_FILE.t" "$ENV_FILE"
echo " 已轮换 NAVI_SYNC_TOKEN(新值长度 ${#NAVI_ROTATE_SYNC_TOKEN})"
fi
chmod 600 "/tmp/navi-host-${STAMP}.env"
chmod 600 "$ENV_FILE"
# ② 跨子域单点登录所需变量:新版已移除代码里的写死默认值,
# 不显式提供的话登录 cookie 会退化成 host-only(登录后换子域就掉线)。
if ! grep -q '^PORTAL_COOKIE_DOMAIN=' "/tmp/navi-host-${STAMP}.env"; then
if ! grep -q '^PORTAL_COOKIE_DOMAIN=' "$ENV_FILE"; then
if [ -n "${NAVI_COOKIE_DOMAIN:-}" ]; then
echo "PORTAL_COOKIE_DOMAIN=${NAVI_COOKIE_DOMAIN}" >> "/tmp/navi-host-${STAMP}.env"
echo "PORTAL_COOKIE_DOMAIN=${NAVI_COOKIE_DOMAIN}" >> "$ENV_FILE"
else
echo "!! 缺少 PORTAL_COOKIE_DOMAIN:新版已删除代码默认值,直接升级会让跨子域单点登录失效。" >&2
echo " 执行方式:NAVI_COOKIE_DOMAIN=.你的域名 NAVI_HOME_URL=https://你的门户/ NAVI_SSH_PASS=... bash deploy/zd-host.sh $VER" >&2
exit 2
fi
fi
if ! grep -q '^PORTAL_HOME_URL=' "/tmp/navi-host-${STAMP}.env" && [ -n "${NAVI_HOME_URL:-}" ]; then
echo "PORTAL_HOME_URL=${NAVI_HOME_URL}" >> "/tmp/navi-host-${STAMP}.env"
if ! grep -q '^PORTAL_HOME_URL=' "$ENV_FILE" && [ -n "${NAVI_HOME_URL:-}" ]; then
echo "PORTAL_HOME_URL=${NAVI_HOME_URL}" >> "$ENV_FILE"
fi
OLD_IMAGE=$(docker inspect navi --format '{{.Config.Image}}')
@ -178,6 +188,7 @@ PREV="navi-prev-${STAMP}"
fail() {
rc=$?
echo "!! 失败(rc=$rc)→ 回滚到 ${OLD_IMAGE}" >&2
cleanup_env_file
docker rm -f navi-next >/dev/null 2>&1 || true
if ! docker inspect "$PREV" >/dev/null 2>&1; then
# 还没改名就把原容器起回来
@ -200,7 +211,7 @@ docker rename navi "$PREV"
docker run -d --name navi-next --restart unless-stopped --network host \
-v "${DATA_DIR}:/app/data" -v "${CERT_DIR}:/etc/nginx/certs" \
--env-file "/tmp/navi-host-${STAMP}.env" "homedash:ui-${VER}" >/dev/null
--env-file "$ENV_FILE" "homedash:ui-${VER}" >/dev/null
w200() { for _ in $(seq 1 60); do [ "$(curl -s -o /dev/null -w '%{http_code}' -m 3 "$1" 2>/dev/null || true)" = "200" ] && { echo " ready $1"; return 0; }; sleep 2; done; echo " TIMEOUT $1" >&2; return 1; }
w200 "http://127.0.0.1:18080/healthz"
@ -240,7 +251,7 @@ echo " 入口自检通过(3080/3081)"
# 让容器按当前路由表重新生成一次网关配置:镜像里的门户模板/错误页可能已更新,
# 而线上 gateway.conf 是上一次生成的(本轮真实事故:门户页占位符裸露)。
ADMIN_TOK=$(grep '^NAVI_ADMIN_TOKEN=' "/tmp/navi-host-${STAMP}.env" | cut -d= -f2- || true)
ADMIN_TOK=$(grep '^NAVI_ADMIN_TOKEN=' "$ENV_FILE" | cut -d= -f2- || true)
if [ -n "$ADMIN_TOK" ]; then
APPLY=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOK" http://127.0.0.1:3005/api/gateway/apply || true)
echo " 已重新生成网关配置: ${APPLY}"
@ -259,23 +270,57 @@ if [ "${CLEAN_PORTAL_PW:-0}" = "1" ] && [ "${NAVI_ROTATE_PORTAL_PASSWORD:-0}" =
RESP=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOK" -H 'Content-Type: application/json' \
-d "{\"username\":\"admin\",\"password\":\"$NEWPW\"}" http://127.0.0.1:3005/api/portal-auth || true)
case "$RESP" in *error*|*Error*) echo " ! 门户口令重置失败(可在后台改):$RESP" >&2 ;;
*) echo " 已清除明文 env 并重置门户口令 → admin / $NEWPW(请立即记录)" ;; esac
# 这里**有意**把新口令打印一次:这是管理员唯一能拿到它的机会(数据卷里只存哈希)。
# 打印到此为止 —— 不写日志文件、不写进任何 env/注释,也不在任何其它路径回显。
*) echo " 已清除明文 env 并重置门户口令 → admin / $NEWPW(请立即记录,只打印这一次)" ;; esac
fi
fi
# 历史条数只用于人工对比,不应成为升级的阻断点。
# 2026-10-22:管理 API 改为默认拒绝后,/admin/api/* 需要登录会话;
# 这里用管理员 token 访问(v107+ 支持 Bearer),拿不到就不显示而不报错。
HIST=$(curl -s -m 8 -H "Authorization: Bearer ${ADMIN_TOK:-}" \
"http://127.0.0.1:18080/admin/api/history" 2>/dev/null \
| grep -o '"url"' | wc -l || echo 0)
HIST=${HIST:-0}
echo " 历史条数 ${HIST:-0}(同步接口只累加不删除;与切换前对比由人工确认)"
#
# 2026-10-22 审计补修:以前取不到 token 时仍会发一个空的 `Bearer ` 请求,
# history-server fail-closed 回 401 JSON,`grep -o '"url"' | wc -l` 数出 0,
# 于是对外**打印「历史条数 0」——一个假数据**(看起来像历史被清空了)。
# 现在三条路径分开:无 token → 「未知(跳过)」;非 200 → 「未知(HTTP xxx)」;
# 只有 200 才数 body。仍然绝不阻断升级。
# >>> human_history_count BEGIN(deploy/zd-host_test.sh 会抽取这一段做单测,改动请保留标记)
human_history_count() {
# $1 = 管理员 token(可空)
local tok="$1"
if [ -z "$tok" ]; then
echo "未知(未配置 NAVI_ADMIN_TOKEN,跳过探测)"
return 0
fi
local tmp code body n
tmp=$(mktemp 2>/dev/null || echo "/tmp/navi-hist-${STAMP}")
# curl 连不上时仍会把 -w 的 000 写出来并返回非 0,所以先 `|| true` 再归一化,
# 不能 `|| echo 000`(那会拼成 "000\n000" 打进提示里)。
code=$(curl -s -m 8 -o "$tmp" -w '%{http_code}' \
-H "Authorization: Bearer ${tok}" \
"http://127.0.0.1:18080/admin/api/history" 2>/dev/null || true)
code=$(printf '%s' "$code" | tr -d '[:space:]')
[ -n "$code" ] || code=000
if [ "$code" != "200" ]; then
rm -f "$tmp"
echo "未知(HTTP ${code};接口要求管理员 token,失败不影响升级)"
return 0
fi
body=$(cat "$tmp" 2>/dev/null || true)
rm -f "$tmp"
n=$(printf '%s' "$body" | grep -o '"url"' | wc -l || true)
echo "${n:-0}"
}
# <<< human_history_count END
HIST=$(human_history_count "${ADMIN_TOK:-}")
echo " 历史条数 ${HIST}(同步接口只累加不删除;与切换前对比由人工确认)"
docker rename navi-next navi
trap - ERR
docker rm -f "$PREV" >/dev/null 2>&1 || true
rm -f "$REMOTE_TAR"
cleanup_env_file # 明文令牌的 /tmp env 文件必须删(成功路径)
# 收尾断言:名字必须回到 navi。中途异常中断曾导致容器留在 navi-next,
# 而脚本"看起来"跑完了(本轮真实事故),所以这里显式检查。
if ! docker inspect navi >/dev/null 2>&1; then

277
deploy/zd-host_test.sh Executable file
View File

@ -0,0 +1,277 @@
#!/usr/bin/env bash
# deploy/zd-host.sh 的静态护栏 + 逻辑单测。
#
# 为什么需要它:zd-host.sh 是「一跑就动线上」的部署脚本,没法在 CI 里真跑。
# 于是把可验证的部分拆成两层:
# ① 静态护栏:对脚本正文做断言(令牌只打印长度、没有假数据路径、env 文件必删)
# ② 逻辑抽取:把 human_history_count 那段函数原样抠出来,用 stub curl 单测
#
# 用法:bash deploy/zd-host_test.sh (退出码 0 = 全绿)
# 依赖:bash / grep / sed / awk / mktemp,无网络、无 docker。
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET="$HERE/zd-host.sh"
PASS=0
FAIL=0
ok() { PASS=$((PASS+1)); printf ' \033[32m✓\033[0m %s\n' "$1"; }
bad() { FAIL=$((FAIL+1)); printf ' \033[31m✗\033[0m %s\n' "$1"; [ -n "${2:-}" ] && printf ' %s\n' "$2"; }
head_() { printf '\n== %s ==\n' "$1"; }
[ -f "$TARGET" ] || { echo "找不到 $TARGET" >&2; exit 1; }
# ────────────────────────────────────────────────────────────
head_ "1. 语法检查(bash -n)"
# ────────────────────────────────────────────────────────────
if bash -n "$TARGET" 2>/tmp/zdnsyn.$$; then
ok "bash -n deploy/zd-host.sh 通过"
else
bad "bash -n 失败" "$(cat /tmp/zdnsyn.$$)"
fi
rm -f /tmp/zdnsyn.$$
# ────────────────────────────────────────────────────────────
head_ "2a. 令牌轮换只打印长度,不打印值"
# ────────────────────────────────────────────────────────────
# 轮换段必须用 ${#VAR}(长度)形式打印
len_prints=$(grep -c '新值长度 \${#NAVI_ROTATE_' "$TARGET" || true)
if [ "${len_prints:-0}" -ge 2 ]; then
ok "两处轮换都只打印长度(${len_prints} 处)"
else
bad "令牌轮换的长度打印少于 2 处(实际 ${len_prints:-0})"
fi
# 不得出现「echo ... $NAVI_ROTATE_xxx 的值本身」这种写法:
# 即 echo 行里引用变量却没有用 ${#VAR} 取长度。
# 例外:`echo "NAVI_ADMIN_TOKEN=..." >> "$ENV_FILE.t"` 是**写进 env 文件**(容器启动要用),
# 不是打到 stdout/日志,因此排除带 `>>` 重定向的行。
leak=$(grep -nE '^[^#]*echo[^#]*\$\{?NAVI_ROTATE_(ADMIN|SYNC)_TOKEN' "$TARGET" \
| grep -v '\${#NAVI_ROTATE_' \
| grep -v '>>' || true)
if [ -z "$leak" ]; then
ok "没有 echo 直接打印 NAVI_ROTATE_*_TOKEN 的值"
else
bad "发现可能打印令牌值的 echo 行" "$leak"
fi
# 全脚本不得出现 set -x(会把 env/变量回显到日志)
if grep -qE '^[[:space:]]*set[[:space:]]+-[a-z]*x' "$TARGET"; then
bad "脚本里出现 set -x,会把变量值回显到日志"
else
ok "没有 set -x"
fi
# ────────────────────────────────────────────────────────────
head_ "2b. ADMIN_TOK 缺失时不得打印「历史条数 0」"
# ────────────────────────────────────────────────────────────
if grep -q 'if \[ -z "\$tok" \]' "$TARGET"; then
ok "human_history_count 里有 token 为空的分支"
else
bad "human_history_count 缺少「token 为空」分支"
fi
if grep -q '未知(未配置 NAVI_ADMIN_TOKEN,跳过探测)' "$TARGET"; then
ok "空 token 时输出「未知(…跳过探测)」"
else
bad "空 token 分支没有输出「未知」文案"
fi
# 假的 0 从哪来:旧写法 `| grep -o '"url"' | wc -l || echo 0` + `HIST=${HIST:-0}`。
if grep -qE 'HIST=\$\{HIST:-0\}' "$TARGET"; then
bad "仍有 HIST=\${HIST:-0} 兜底,会把「取不到」变成「0 条」"
else
ok "没有 HIST=\${HIST:-0} 假数据兜底"
fi
if grep -nE 'grep -o .\"url\". \| wc -l \|\| echo 0' "$TARGET" >/dev/null 2>&1; then
bad "仍有「grep url | wc -l || echo 0」的历史条数写法"
else
ok "没有「grep url | wc -l || echo 0」的旧写法"
fi
# 必须校验 HTTP 状态码(非 200 不数 body)
if grep -q "code.*!= \"200\"" "$TARGET"; then
ok "探测时校验 HTTP 状态码,非 200 不数 body"
else
bad "探测没有校验 HTTP 状态码"
fi
if grep -q "未知(HTTP " "$TARGET"; then
ok "非 200 时输出「未知(HTTP xxx)」"
else
bad "非 200 时没有「未知(HTTP xxx)」输出"
fi
# ────────────────────────────────────────────────────────────
head_ "2c. 明文令牌的 env 文件在成功与失败路径都必须删除"
# ────────────────────────────────────────────────────────────
fail_body=$(awk '/^fail\(\) \{/,/^}/' "$TARGET")
if printf '%s' "$fail_body" | grep -q 'cleanup_env_file'; then
ok "fail() 回滚路径会调 cleanup_env_file"
else
bad "fail() 回滚路径没有删 env 文件"
fi
if grep -qE '^\s*trap cleanup_env_file EXIT' "$TARGET"; then
ok "设置了 EXIT trap(异常退出也会删)"
else
bad "没有 EXIT trap 兜底删除 env 文件"
fi
# 成功路径:在 rm -f "$REMOTE_TAR" 附近也要有一次显式 cleanup
if grep -qE '^cleanup_env_file' "$TARGET"; then
ok "成功收尾路径显式调 cleanup_env_file"
else
bad "成功收尾路径没有显式删 env 文件"
fi
if grep -q 'rm -f "\$ENV_FILE" "\$ENV_FILE.t" "\$ENV_FILE.new"' "$TARGET"; then
ok "cleanup 同时删 .t / .new 中间文件"
else
bad "cleanup 漏删中间文件(.t / .new 也含明文令牌)"
fi
# ────────────────────────────────────────────────────────────
head_ "3. human_history_count 逻辑单测(stub curl)"
# ────────────────────────────────────────────────────────────
TMPD=$(mktemp -d)
trap 'rm -rf "$TMPD"' EXIT
# 抽取函数体(由脚本里的 BEGIN/END 标记界定;没标记说明被人删了标记,直接失败)
awk '/^# >>> human_history_count BEGIN/,/^# <<< human_history_count END/' "$TARGET" > "$TMPD/func.sh"
if [ -s "$TMPD/func.sh" ]; then
ok "成功抽取 human_history_count 函数"
else
bad "抽取 human_history_count 失败(BEGIN/END 标记缺失?)"
fi
# stub curl:用环境变量控制行为,并记录「是否被调用」
mkdir -p "$TMPD/bin"
cat > "$TMPD/bin/curl" <<'STUB'
#!/usr/bin/env bash
# 只实现被 human_history_count 用到的那几个参数:-s -m N -o FILE -w '%{http_code}' -H ...
out=""
while [ $# -gt 0 ]; do
case "$1" in
-o) out="$2"; shift 2 ;;
-H|-m) shift 2 ;;
-w) shift 2 ;;
-*) shift ;;
*) shift ;;
esac
done
echo "called" >> "${STUB_CALLS:-/dev/null}"
case "${STUB_MODE:-ok}" in
ok) [ -n "$out" ] && printf '%s' "${STUB_BODY:-}" > "$out"; printf '200'; exit 0 ;;
401) [ -n "$out" ] && printf '%s' '{"error":"invalid or missing token"}' > "$out"; printf '401'; exit 0 ;;
502) [ -n "$out" ] && printf '%s' '<html>502 Bad Gateway</html>' > "$out"; printf '502'; exit 0 ;;
conn) # 连不上:curl 会打印 000 并返回非 0
[ -n "$out" ] && : > "$out"; printf '000'; exit 7 ;;
esac
exit 0
STUB
chmod +x "$TMPD/bin/curl"
run_hist() { # $1=token ; 其余由 STUB_* 控制
PATH="$TMPD/bin:$PATH" STUB_CALLS="$TMPD/calls" \
bash -c 'set -uo pipefail; . "$1"; human_history_count "$2"' _ "$TMPD/func.sh" "$1"
}
BODY3='{"items":[{"url":"https://a.example.com/"},{"url":"https://b.example.com/"},{"url":"https://c.example.com/"}],"total":3}'
# 3.1 空 token → 跳过探测,输出「未知」,且**不能**调用 curl(更不能打印 0)
rm -f "$TMPD/calls"
got=$(STUB_MODE=ok STUB_BODY="$BODY3" run_hist "" )
if printf '%s' "$got" | grep -q '未知' && ! printf '%s' "$got" | grep -qx '0'; then
ok "空 token → 「未知」而不是 0(输出: $got)"
else
bad "空 token 时输出了非预期内容" "got=[$got]"
fi
if [ ! -f "$TMPD/calls" ]; then
ok "空 token 时完全没有发请求(没带空 Bearer 打过去)"
else
bad "空 token 时仍然调用了 curl"
fi
# 3.2 有 token 但接口 401 → 「未知(HTTP 401)」,不是 0
got=$(STUB_MODE=401 run_hist "some-admin-token")
if printf '%s' "$got" | grep -q '未知(HTTP 401' ; then
ok "401 → 「未知(HTTP 401)」(输出: $got)"
else
bad "401 时没有输出「未知(HTTP 401)」" "got=[$got]"
fi
if printf '%s' "$got" | grep -qx '0'; then
bad "401 时把响应体当数据数出了 0 条"
else
ok "401 时不会数 body"
fi
# 3.3 502(body 是 HTML)→ 「未知(HTTP 502)」,不得把 HTML 当数据
got=$(STUB_MODE=502 run_hist "some-admin-token")
if printf '%s' "$got" | grep -q '未知(HTTP 502'; then
ok "502 → 「未知(HTTP 502)」"
else
bad "502 时没有输出「未知(HTTP 502)」" "got=[$got]"
fi
# 3.4 连不上(curl 打印 000 且返回非 0)→ 「未知(HTTP 000)」,且提示里不出现重复的 000
got=$(STUB_MODE=conn run_hist "some-admin-token")
if printf '%s' "$got" | grep -q '未知(HTTP 000'; then
ok "连接失败 → 「未知(HTTP 000)」(输出: $got)"
else
bad "连接失败时输出不符合预期" "got=[$got]"
fi
if printf '%s' "$got" | grep -qE '000[[:space:]]*000'; then
bad "状态码被拼成了两个 000" "got=[$got]"
else
ok "状态码已归一化(没有重复 000)"
fi
# 3.5 200 且 3 条 → 输出 3
got=$(STUB_MODE=ok STUB_BODY="$BODY3" run_hist "some-admin-token")
if [ "$got" = "3" ]; then
ok "200 + 3 条 → 输出 3"
else
bad "200 时条数不对" "got=[$got] want=[3]"
fi
# 3.6 200 但 0 条 → 输出 0(这是**真实**的 0,必须能显示出来)
got=$(STUB_MODE=ok STUB_BODY='{"items":[],"total":0}' run_hist "some-admin-token")
if [ "$got" = "0" ]; then
ok "200 + 空列表 → 输出 0(真实的 0 仍然显示)"
else
bad "真实 0 条时输出不对" "got=[$got] want=[0]"
fi
# 3.7 有 token 时确实带上了 Authorization 头(用 stub 校验参数)
cat > "$TMPD/bin/curl" <<'STUB2'
#!/usr/bin/env bash
args="$*"
echo "$args" >> "$STUB_ARGS"
out=""
while [ $# -gt 0 ]; do
case "$1" in
-o) out="$2"; shift 2 ;;
*) shift ;;
esac
done
[ -n "$out" ] && printf '{"items":[{"url":"x"}]}' > "$out"
printf '200'
STUB2
chmod +x "$TMPD/bin/curl"
rm -f "$TMPD/args"
PATH="$TMPD/bin:$PATH" STUB_ARGS="$TMPD/args" \
bash -c 'set -uo pipefail; . "$1"; human_history_count "tok-abc"' _ "$TMPD/func.sh" >/dev/null
if grep -q 'Authorization: Bearer tok-abc' "$TMPD/args"; then
ok "有 token 时请求带 Authorization: Bearer"
else
bad "请求没带 Authorization 头" "$(cat "$TMPD/args" 2>/dev/null)"
fi
if grep -q '/admin/api/history' "$TMPD/args"; then
ok "探测打的是 /admin/api/history(不是根 /api/)"
else
bad "探测地址不对" "$(cat "$TMPD/args" 2>/dev/null)"
fi
# ────────────────────────────────────────────────────────────
printf '\n== 结果 ==\n通过 %d,失败 %d\n' "$PASS" "$FAIL"
[ "$FAIL" -eq 0 ] || exit 1