fix: Gate3 health plain text, refresh empty body, prune underscore chunks

test_api: assert /health returns plain text ok instead of JSON parse.

auth: RefreshRequest.refresh_token optional so SPA POST {} is not 422.

prune: allow leading underscore in asset names to avoid deleting _plugin-vue_export-helper.

Add run_test_api_on_server.sh and deployment follow-up changelog.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-06-01 13:41:28 +08:00
parent 65c77155d8
commit d519113734
5 changed files with 94 additions and 5 deletions
+2 -1
View File
@@ -10,8 +10,9 @@ import re
import sys
from pathlib import Path
# Leading `_` required for Vite chunks like `_plugin-vue_export-helper-*.js`
ASSET_NAME_RE = re.compile(
r"[A-Za-z0-9][A-Za-z0-9_.-]*\.(?:js|css|woff2?|ttf|eot)"
r"[_A-Za-z0-9][A-Za-z0-9_.-]*\.(?:js|css|woff2?|ttf|eot)"
)
URL_RE = re.compile(r"""url\(["']?([^"')]+)["']?\)""")
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# Run tests/test_api.py on the deploy host (reads NEXUS_TEST_* from .env).
set -euo pipefail
cd /www/wwwroot/api.synaglobal.vip
export NEXUS_TEST_BASE="${NEXUS_TEST_BASE:-http://127.0.0.1:8600}"
python3 tests/test_api.py
@@ -0,0 +1,56 @@
# Changelog — test_api 健康检查与生产 Gate 跟进
**日期**: 2026-06-01
**类型**: 修复 / 运维跟进
## 摘要
- `tests/test_api.py``GET /health` 改为断言纯文本 `ok`(与 `server/api/health.py``PlainTextResponse` 一致),不再 `json.loads`
- 生产 `.env``NEXUS_TEST_ADMIN_PASSWORD` 须与数据库 `admin` 密码一致;原值 `Nexus@2026Test` 导致 Gate 3 登录 401。
## 动机
部署后 `python3 tests/test_api.py` 18/18 失败:健康检查因期望 JSON 解析失败;其余用例因未拿到 JWT 连锁失败。
## 涉及文件
- `tests/test_api.py` — 新增 `test_plain_text()``/health` 用例改用
- 生产 `/www/wwwroot/api.synaglobal.vip/.env``NEXUS_TEST_ADMIN_PASSWORD` 对齐真实管理员密码(运维操作,不入库)
## 前置条件
- `NEXUS_TEST_ADMIN_USER` / `NEXUS_TEST_ADMIN_PASSWORD` 与 MySQL `admins` 表一致
- 后端监听 `NEXUS_TEST_BASE`(默认 `http://127.0.0.1:8600`
## 迁移 / 重启
- 仅改测试脚本:无需重启;改 `.env` 后无需重启(测试进程读环境变量)
- 前端 assets prune:务必先 `python deploy/prune_frontend_assets.py --dry-run`,确认 `would_remove` 合理后再实删
## 验证
```bash
# 服务器
cd /www/wwwroot/api.synaglobal.vip
python3 tests/test_api.py
# 预期:Results: N/N passed, 0 failed
curl -s http://127.0.0.1:8600/health # ok
```
## 回滚
- 还原 `tests/test_api.py` 中 health 用例为旧版(不推荐,与 API 契约不符)
- `.env` 测试密码改回前值仅当故意使用独立测试账号时
## 2026-06-01 晚间追加(前端部署)
- `vite build` + tar/scp 部署至生产 `web/app/`
- **事故**`prune_frontend_assets.py` 正则首字符不允许 `_`,误删 `_plugin-vue_export-helper-*.js` → 全站白屏;已 scp 恢复并修复正则 `[_A-Za-z0-9]...`
- **后端**`RefreshRequest.refresh_token` 改为 Optional,空 body `{}` 不再 422
- **浏览器验证**`#/servers` 勾选 2 台后出现「已选择 2 台服务器」及批量按钮(含 `normalizeServerIds``ServersPage-Vzm34hgn.js`
## 关联
- `docs/changelog/2026-06-01-bug-remediation.md`
- `deploy/prune_frontend_assets.py`(Rolldown 闭包引用扫描;须匹配 `_plugin-vue_export-helper` 等 chunk
+4 -2
View File
@@ -74,7 +74,9 @@ class LoginRequest(BaseModel):
class RefreshRequest(BaseModel):
refresh_token: str = Field(..., min_length=1)
"""Body optional — refresh token is read from HttpOnly cookie first."""
refresh_token: Optional[str] = Field(default=None, min_length=1)
class LogoutRequest(BaseModel):
@@ -152,7 +154,7 @@ async def login(
async def refresh_token(
request: Request,
response: Response,
payload: Optional[RefreshRequest] = None,
payload: RefreshRequest | None = None,
service: AuthService = Depends(get_auth_service),
):
"""Exchange refresh token for new access token (with rotation + version-based reuse detection)
+26 -2
View File
@@ -120,15 +120,39 @@ def test(name: str, method: str, path: str, body=None, expect_code=200, headers=
return None
def test_plain_text(name: str, method: str, path: str, expect_body: str, expect_code=200):
"""Health and other probes that return plain text, not JSON."""
global PASS, FAIL
url = f"{BASE}{path}"
req = urllib.request.Request(url, method=method)
try:
resp = urllib.request.urlopen(req, timeout=10)
code = resp.getcode()
body = resp.read().decode().strip()
if code == expect_code and body == expect_body:
PASS += 1
print(f" [PASS] {name}")
return body
FAIL += 1
print(f" [FAIL] {name}: expected {expect_code} body={expect_body!r}, got {code} body={body!r}")
except urllib.error.HTTPError as e:
FAIL += 1
print(f" [FAIL] {name}: HTTP {e.code}{e.read().decode()[:200]}")
except Exception as e:
FAIL += 1
print(f" [FAIL] {name}: {e}")
return None
# ================================================================
print("=" * 50)
print("Nexus API End-to-End Tests")
print(f" Target: {BASE}")
print("=" * 50)
# --- Health (no auth) ---
# --- Health (no auth) — plain text per health.py (Shell guardian) ---
print("\n[1] Health")
test("GET /health", "GET", "/health")
test_plain_text("GET /health", "GET", "/health", expect_body="ok")
# --- Auth: Login ---
print("\n[2] Auth")