// 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 在做) // ❌ 不能驗:跨用戶 authorization(user 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 而非 apiGroup(stubs.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 → 通過 middleware,handler 拿 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),實際 %d;body=%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) } }