admin: /admin HTML 公开可访问,让 token 弹窗真正能触发

上一轮 (90eba22) 把 admin 页改成 token 弹窗模式,但 webHandler 还
挂在带 AdminAuth 的 group 里。结果无 token 访问 /admin 拿到的不是
HTML,而是 {"error":"missing bearer token"} 的 401 JSON,弹窗
变成死代码。

修复: 拆出 gPublic group,只放 webHandler (GET /admin 和 /admin/)。
API 路由 (/clusters*, /audit) 和 OpenAPI docs (/docs, /docs/spec)
继续受 AdminAuth 保护 (后者暴露内部 API 结构,不该公网可见)。

HTML 自身的 token 输入流不变 — 弹窗仍然在客户端拦截 401、清旧 token、
记录 pendingRetry、保存新 token 后自动重试。

补一个 TestAdminWeb_NoTokenReturnsHTML 锁定新契约:
  - GET /admin 无 token -> 200 + text/html (含 token-modal 元素)
  - GET /admin/ 同上
  - GET /admin/clusters 无 token -> 401 (回归保护)
  - GET /admin/clusters good-token -> 200 (回归保护)

旧 TestMount_RequiresAuth 测的全是 API 路径, 不受影响, 全绿。

验证:
  - go build / go vet / go test 全部通过
  - 端到端 curl 复现用户报告场景: /admin 无 token -> 200 + 弹窗 HTML
  - API + docs 鉴权行为不变

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-13 14:16:49 +08:00
co-authored by Claude
parent 90eba221d4
commit bf2640d8fd
2 changed files with 45 additions and 2 deletions
+6 -2
View File
@@ -19,9 +19,13 @@ import (
// Mount attaches the /admin sub-router to r, protecting every route with // Mount attaches the /admin sub-router to r, protecting every route with
// bearer-token admin authentication. // bearer-token admin authentication.
func Mount(r *gin.Engine, repo *storage.ClusterRepo, auditRepo *audit.Repo, adminTokens []string) { func Mount(r *gin.Engine, repo *storage.ClusterRepo, auditRepo *audit.Repo, adminTokens []string) {
// HTML page is public — its own modal prompts for the token.
// All other /admin/* endpoints (API + OpenAPI docs) still require auth.
gPublic := r.Group("/admin")
gPublic.GET("", webHandler)
gPublic.GET("/", webHandler)
g := r.Group("/admin", middleware.AdminAuth(adminTokens)) g := r.Group("/admin", middleware.AdminAuth(adminTokens))
g.GET("", webHandler)
g.GET("/", webHandler)
g.GET("/clusters", listClusters(repo)) g.GET("/clusters", listClusters(repo))
g.POST("/clusters", createCluster(repo, auditRepo)) g.POST("/clusters", createCluster(repo, auditRepo))
g.GET("/clusters/:id", getCluster(repo)) g.GET("/clusters/:id", getCluster(repo))
+39
View File
@@ -96,6 +96,45 @@ func TestMount_RequiresAuth(t *testing.T) {
} }
} }
func TestAdminWeb_NoTokenReturnsHTML(t *testing.T) {
r, _, _ := newTestAdmin(t)
// 1) /admin without token: 200 + HTML
w := doReq(t, r, "GET", "/admin", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("GET /admin without token: got %d, want 200", w.Code)
}
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html", ct)
}
body := w.Body.String()
if !strings.Contains(body, "token-modal") {
t.Errorf("body missing token-modal element; body excerpt: %.200s", body)
}
if !strings.Contains(body, "Admin Token Required") {
t.Errorf("body missing modal title")
}
// 2) /admin/ (trailing slash) same behavior
w2 := doReq(t, r, "GET", "/admin/", "", nil)
if w2.Code != http.StatusOK {
t.Fatalf("GET /admin/ without token: got %d, want 200", w2.Code)
}
// 3) API routes still require auth (no token = 401)
w3 := doReq(t, r, "GET", "/admin/clusters", "", nil)
if w3.Code != http.StatusUnauthorized {
t.Errorf("GET /admin/clusters without token: got %d, want 401", w3.Code)
}
// 4) API routes still require auth (valid token = 200)
w4 := doReq(t, r, "GET", "/admin/clusters", "good-token", nil)
if w4.Code != http.StatusOK {
t.Errorf("GET /admin/clusters with good-token: got %d, want 200", w4.Code)
}
}
func TestListClusters(t *testing.T) { func TestListClusters(t *testing.T) {
r, _, _ := newTestAdmin(t) r, _, _ := newTestAdmin(t)