visionA/visionA-backend/cmd/api-server/all_endpoints_require_auth_test.go
jim800121chen 4f50ad7350 feat(ws): flash-progress WS tunnel proxy(後端,載入模型進度回顯)
flash「載入模型到裝置」後端缺口:flash-progress WS 從 501 stub 換真
tunnel proxy,複用 inference WS 的 path-agnostic handler。

- camera.go registerWebSocketRoutes:加 /ws/devices/:id/flash-progress
  (複用 newWebSocketProxyHandler、掛 wsAuthGroup same-origin cookie)
- stubs.go:刪 flash-progress 501 stub(成對防 radix panic)+ 更新註解
- api.go:更新 wsAuthGroup 註解(現含 inference + flash-progress 兩條裝置級 WS)
- camera_ws_test.go:+3 test(NoForwarder/TunnelDisconnected/CoexistsWithInference)
- all_endpoints_require_auth_test.go:收窄白名單,flash-progress 從 skipped→
  covered 納入「無 cookie 應 401」回歸檢查(守得住證明:移除 auth 即 FAIL)

契約:WS /ws/devices/:id/flash-progress、payload raw {percent,stage,message?,error?}
透明轉發。Reviewer 0C/0M/1Mi 通過(Mi 註解已修)。build/vet/全回歸綠。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 06:30:36 +08:00

218 lines
9.3 KiB
Go
Raw Permalink 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.

// all_endpoints_require_auth_test.go — Phase 0.7 security regression test.
//
// 對齊 .autoflow/05-implementation/review/phase-0.7-security-audit.md s1。
//
// 目的:對所有 protected endpoint 發無 cookie request必須回 401。
//
// 為什麼需要:
//
// Phase 0.7 audit 發現 visionA-backend 13+ 處 handler 用 resolveUserID 寬鬆 fallback
// 到 demo-user。即使 Backend 完成 Fix #1-#5移除 fallback、改 strict mode
// 未來任何一條漏套 AuthMiddleware 的新 endpoint 都會立刻打破 multi-tenant 隔離。
//
// 這條測試是 C1 fallback 的長期防呆 — 任何未來 endpoint 漏保護都會立刻被 fail
//
// - 新 endpoint 註冊時忘了套 middleware
// - middleware 順序錯誤
// - router group 套錯(例如把保護的 path 加在 r 而非 apiGroup
//
// 能力與限制:
//
// ✅ 能驗:每個 protected endpoint 都有套 AuthMiddleware沒帶 cookie → 401
// ✅ 能驗:未來新增 endpoint 漏套會立刻 fail
// ❌ 不能驗middleware 內部邏輯middleware_test.go 在做)
// ❌ 不能驗:跨用戶 authorizationuser A 不能存取 B 的資源 — 另一條測試)
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// publicPaths 是「不需要認證」的明確 endpoint 清單method + path 精確匹配)。
//
// 對齊 audit 報告 endpoint × middleware 對照表:這些 endpoint 故意設計成 public —
//
// - /healthz → K8s liveness/readiness 用,不能要 cookie
// - /api/auth/login → 起始 OIDC 登入流程user 還沒登入
// - /api/auth/callback → OIDC IdP 302 回來user 還沒登入
// - /api/pairing/exchange → agent 還沒 session token用 pairing token 換
//
// /ws/* stub endpoints仍是 501、尚未實作 WebSocket proxy、註冊在 r 而非 wsAuthGroup
// 故不走 AuthMiddleware也逐條列在這裡。**逐條精確列出**、而非用 /ws/ prefix 一次放行,
// 是為了讓「已升級成 authed 的 WS route」如 GET /ws/devices/:id/inference 與
// GET /ws/devices/:id/flash-progress皆掛在 wsAuthGroup、走 same-origin cookie
// AuthMiddleware自動落入主測試的「必須 401」檢查。
// 未來任一條 stub 補實作並套 auth 後,把它從這份清單移除即可納入回歸檢查。
//
// 任何往這份清單裡新加 endpoint 的 PR 都該特別 review — 你正在繞過 OIDC 保護。
var publicPaths = map[string]bool{
"GET /healthz": true,
"GET /api/auth/login": true,
"GET /api/auth/callback": true,
"POST /api/pairing/exchange": true,
// /ws/* 仍為 501 stub 的 endpoint見 internal/api/stubs.go registerWebSocketStubs
// 注意GET /ws/devices/:id/inference 與 GET /ws/devices/:id/flash-progress 已升級為
// authed掛 wsAuthGroup走 same-origin cookie AuthMiddleware**刻意不在此清單**
// 因此會被主測試納入「無 cookie 應回 401」的檢查。
"GET /ws/devices/events": true,
"GET /ws/server-logs": true,
"GET /ws/system": true,
"GET /ws/clusters/:id/inference": true,
"GET /ws/clusters/:id/flash-progress": true,
"GET /ws/pairing/status": true,
}
// publicPrefixes 是「整個 path prefix 都不走 OIDC AuthMiddleware」的清單。
//
// - /storage/* — 用 HMAC presigned URL 驗簽api-spec.md §10不是 cookie
//
// 為什麼這裡「不」放 /ws//ws/* 底下同時有 501 stub無 auth與已升級為 authed 的
// route/ws/devices/:id/inference掛 wsAuthGroup。用 prefix 一次放行會連 authed
// route 一起豁免、讓回歸測試對它失效。故 /ws/ 的 stub 逐條列在 publicPaths、authed
// route 則刻意不列 → 自動被主測試納入「必須 401」檢查。
var publicPrefixes = []string{
"/storage/",
}
// pathParamReplacements 把 gin route 的 path param:id / :token / *filepath
// 換成具體值,讓 router 能 match 到實際 handler。
//
// 注意:這裡的具體值不需要是「資料庫真的存在的 ID」 — 我們只在乎 router 路由正確
// middleware 是 router-level 的,沒到 handler 之前就會被 401 擋下)。
func replacePathParams(path string) string {
// 順序很重要:*filepath 用 catch-all要先處理其他 :param 用 simple replace。
if idx := strings.Index(path, "*"); idx >= 0 {
// 例:/storage/*filepath → /storage/anything
return path[:idx] + "anything"
}
// :param → "test-value"(任意非空字串就行)
parts := strings.Split(path, "/")
for i, p := range parts {
if strings.HasPrefix(p, ":") {
parts[i] = "test-value"
}
}
return strings.Join(parts, "/")
}
// TestAllAPIEndpointsRequire401WithoutCookie 對所有 protected endpoint 發無 cookie
// request必須回 401。
//
// Phase 0.7 security regression test (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md s1)。
//
// 流程:
// 1. 從 fixture 拿 *gin.Engine列出所有註冊的 routes
// 2. 對每條 route跳過 publicPaths / publicPrefixes 名單;其他全部要求 401
// 3. 發 request 時不帶 cookie、不帶 Authorization header
// 4. 驗 status code == 401 UNAUTHORIZED
//
// 失敗時的解讀:
// - 某個 protected endpoint 回 200/500/501 而非 401 → 該路徑沒套 AuthMiddleware
// 檢查是不是註冊在 r 而非 apiGroup或 middleware 順序錯誤。
// - 某個 endpoint 回 200 帶 demo-user 資料 → C1 fallback 還在沒移除Backend Fix #1-#5
// 未完成)。應在 Backend 修完後再跑,或先標 t.Skip。
func TestAllAPIEndpointsRequire401WithoutCookie(t *testing.T) {
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer f.Close()
require.NotNil(t, f.router, "fixture.router 必須非 nil — 確認 setupFixture 有 set router")
routes := f.router.Routes()
require.NotEmpty(t, routes, "router 應至少註冊一條 route")
var (
coveredCount int // 真正測 401 的數量
skippedCount int // 跳過public的數量
)
for _, route := range routes {
key := route.Method + " " + route.Path
// 跳過明確 public 的 endpoint
if publicPaths[key] {
skippedCount++
continue
}
skipByPrefix := false
for _, p := range publicPrefixes {
if strings.HasPrefix(route.Path, p) {
skipByPrefix = true
break
}
}
if skipByPrefix {
skippedCount++
continue
}
coveredCount++
// 用 t.Run 讓每個 endpoint 是獨立 subtest — 失敗時看得到具體哪條
t.Run(key, func(t *testing.T) {
actualPath := replacePathParams(route.Path)
req := httptest.NewRequest(route.Method, actualPath, nil)
// **故意不**設 cookie、不設 Authorization header — 模擬完全沒認證
w := httptest.NewRecorder()
f.router.ServeHTTP(w, req)
// 必須是 401。不可以是
// - 200 / 2xx → 通過 middlewarehandler 拿 fallback userID 回了資料C1 latent break
// - 500 → middleware 沒套或內部 panic更糟
// - 501 → handler 是 stub 但 middleware 沒擋下middleware 沒套)
// - 404 → router path mismatch測試 setup bug
// - 502 → proxy handler 沒被 middleware 擋下middleware 沒套)
assert.Equal(t, http.StatusUnauthorized, w.Code,
"%s 應回 401沒帶 cookie實際 %dbody=%s\n"+
"可能原因:\n"+
" 1. 該路徑沒套 AuthMiddleware檢查 NewRouter 是註冊在 r 還是 apiGroup\n"+
" 2. middleware 註冊順序錯誤AuthMiddleware 必須在 handler 之前)\n"+
" 3. C1 fallback 還在 — Backend Fix #1-#5 未完成(看 audit 報告)",
key, w.Code, w.Body.String())
})
}
// 確保我們真的有測到 endpoint不是測試 setup 出錯導致全部 skip
require.Greater(t, coveredCount, 10,
"預期至少 10+ 個 protected endpoint 被 cover實際 covered=%d, skipped=%d。"+
"若異常偏低代表 fixture 或路由註冊出問題", coveredCount, skippedCount)
t.Logf("covered %d protected endpoints, skipped %d public endpoints",
coveredCount, skippedCount)
}
// TestPublicEndpointsListIsExhaustive 防呆:確認 publicPaths 與 publicPrefixes
// 真的對應到實際 router 上有的 endpoint而不是過期清單。
//
// 為什麼需要:如果未來把某個 public endpoint 改名(例 /api/auth/login → /api/auth/oidc/login
// 但忘了更新 publicPaths主測試會把新的 path 當 protected 然後驗 401。雖然該驗
// 也是對的(新 path 就是 protected但會讓人誤以為「主測試覆蓋的 public 已經包含 login」。
//
// 這條測試讓「publicPaths 列了卻沒對應實際路由的 entry」變成 fail。
func TestPublicEndpointsListMatchesRouter(t *testing.T) {
f := setupFixture(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
defer f.Close()
require.NotNil(t, f.router)
routes := f.router.Routes()
registered := make(map[string]bool, len(routes))
for _, r := range routes {
registered[r.Method+" "+r.Path] = true
}
for key := range publicPaths {
assert.True(t, registered[key],
"publicPaths 列了 %q 但 router 沒有這條 route — 是否已重新命名?", key)
}
}