visionA/visionA-backend/cmd/api-server/all_endpoints_require_auth_test.go
jim800121chen 22f0837ba8 feat(visionA-backend): Phase 0 → 0.7 雲端後端(雙 binary + OIDC BFF + stage 部署)
從 edge-ai-platform POC 轉為正式產品的雲端後端,含以下整合階段:

- Phase 0:雛形骨架 — `cmd/api-server` (REST :3721) + `cmd/remote-proxy`
  (tunnel :3800 / internal :3801) 雙 binary 共用 internal/,沿用 POC 的
  WebSocket+yamux tunnel 協定但解耦 relay 與 API
- Phase 0.6:OIDC BFF 接 Innovedus Member Center
  - internal/oidc package(coreos/go-oidc + PKCE S256 + state + nonce)
  - internal/usersession package(HMAC-SHA256 cookie + RotateSessionID
    防 session fixation, OWASP ASVS V3.2.1)
  - 4 個 OIDC handler(/api/auth/login|callback|me|logout)+ AuthMiddleware
  - 完全拔除 StaticAuthProvider,OIDC 是唯一認證路徑
  - 9 個 ADR(含 ADR-010 BFF / ADR-011 取代 static auth /
    ADR-012 pending session shared cookie / ADR-013 PKCE-only public client)
- Phase 0.7:A1 改造 + security audit 修復
  - OIDC ClientSecret 變選填,支援 stage MC 的 public PKCE-only client
    (AuthStyleInParams 強制 token endpoint 不送 client_secret)
  - 預留 ServiceClient* 欄位給未來 client_credentials grant
  - 移除 13+ 處 resolveUserID(uc, StaticUserID) fallback 改 strict mode
    (Audit C1:multi-tenant 隔離破口)
  - Pairing exchange MarkUsed 失敗 abort + revoke session token(Audit M3)
  - 新增 all_endpoints_require_auth_test 整合測試(51 endpoint × 401)

驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:21:20 +08:00

199 lines
8.0 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 換
//
// 任何往這份清單裡新加 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,
}
// publicPrefixes 是「整個 path prefix 都不走 OIDC AuthMiddleware」的清單。
//
// - /storage/* — 用 HMAC presigned URL 驗簽api-spec.md §10不是 cookie
// - /ws/* — 雛形 stub 一律 501註冊在 r 而非 apiGroupstubs.go:70-85
// 目前無認證 → 501**未來補實作 WebSocket proxy 時必須套 auth**
// 屆時應從這份清單移除。TODO(B7): 移到 protected。
var publicPrefixes = []string{
"/storage/",
"/ws/",
}
// 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)
}
}