package api import ( "context" "net/http" "net/http/httptest" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "visiona-backend/internal/session" ) // TestNewProxyHandler_NoForwarder 驗證沒注入 Forwarder 時回 501。 func TestNewProxyHandler_NoForwarder(t *testing.T) { r := gin.New() r.Use(RequestIDMiddleware()) g := r.Group("/api") g.POST("/devices/scan", newProxyHandler(Deps{}, proxyOptions{})) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/devices/scan", nil)) assert.Equal(t, http.StatusNotImplemented, w.Code) } // TestNewProxyHandler_TunnelDisconnected 驗證沒 session 時回 502 TUNNEL_DISCONNECTED。 // // 這裡用 fakeSessionStore(List 回空)+ 非 nil forwarder 的「半個」 proxy handler; // 因為 nil forwarder 的 path 會先 return 501(見上方 test)。我們用真實 forwarder // 但不 dial — 直接在 pickActiveSessionToken 回 ErrSessionNotFound 就攔掉了。 // // Phase 0.7 security fix C1:handler 強制要求 UserContext;用 injectStaticUserContext // 顯式注入避免回 500(見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)。 func TestNewProxyHandler_TunnelDisconnected(t *testing.T) { r := gin.New() r.Use(RequestIDMiddleware()) r.Use(injectStaticUserContext("demo-user", "")) g := r.Group("/api") g.POST("/devices/scan", newProxyHandler(Deps{ SessionStore: &fakeSessionStore{}, // List 回空 Forwarder: session.NewForwarder("http://localhost:0", nil), }, proxyOptions{})) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/devices/scan", nil)) assert.Equal(t, http.StatusBadGateway, w.Code) assert.Contains(t, w.Body.String(), ErrCodeTunnelDisconnect) } // TestPickActiveSessionToken 驗證 helper 回第一筆 match 的 session。 func TestPickActiveSessionToken(t *testing.T) { store := &fakeSessionStore{ sessions: []*session.Summary{ {Token: "t-other", UserID: "other"}, {Token: "t-me", UserID: "demo-user"}, }, } tok, err := pickActiveSessionToken(context.Background(), store, "demo-user", nil) assert.NoError(t, err) assert.Equal(t, "t-me", tok) } // TestPickActiveSessionToken_Empty 驗證沒 session 時回 ErrSessionNotFound。 func TestPickActiveSessionToken_Empty(t *testing.T) { store := &fakeSessionStore{} _, err := pickActiveSessionToken(context.Background(), store, "demo-user", nil) assert.ErrorIs(t, err, session.ErrSessionNotFound) } // TestCopyProxyRequestHeaders 驗證 header 複製規則: // - Origin 被剝掉(避免 local agent CORSMiddleware 對 tunnel 中繼請求回 403) // - Authorization 被剝掉(local agent 雛形無對應 auth 系統) // - hop-by-hop(Connection 等)被略過 // - 其他 header(Content-Type、custom X-From-Api)原樣保留 // - header 名稱比對 case-insensitive(大小寫變形也要剝) func TestCopyProxyRequestHeaders(t *testing.T) { src := http.Header{} src.Set("Origin", "http://stage-9527.innovedus.com") src.Set("Authorization", "Bearer secret-token") src.Set("Connection", "keep-alive") src.Set("Content-Type", "application/json") src.Set("X-From-Api", "1") // 多值 header 也要保留全部值 src.Add("X-Multi", "a") src.Add("X-Multi", "b") dst := http.Header{} copyProxyRequestHeaders(src, dst) // 被剝掉的 assert.Empty(t, dst.Get("Origin"), "Origin 應被剝掉") assert.Empty(t, dst.Get("Authorization"), "Authorization 應被剝掉") assert.Empty(t, dst.Get("Connection"), "hop-by-hop Connection 應被略過") // 被保留的 assert.Equal(t, "application/json", dst.Get("Content-Type"), "Content-Type 應保留") assert.Equal(t, "1", dst.Get("X-From-Api"), "custom header 應保留") assert.Equal(t, []string{"a", "b"}, dst.Values("X-Multi"), "多值 header 應完整保留") } // TestCopyProxyRequestHeaders_OriginCaseInsensitive 明確驗證大小寫變形的 Origin // 仍被剝掉(Go http.Header 會 canonicalize、但用直接寫入 map 模擬非 canonical 情況)。 func TestCopyProxyRequestHeaders_OriginCaseInsensitive(t *testing.T) { // 直接寫入非 canonical key,繞過 http.Header.Set 的 canonicalization, // 模擬 range 迴圈碰到 "ORIGIN" / "origin" 這類變形時 EqualFold 仍要命中。 src := http.Header{ "ORIGIN": {"http://stage-9527.innovedus.com"}, "origin": {"http://192.168.0.130"}, } dst := http.Header{} copyProxyRequestHeaders(src, dst) assert.Empty(t, dst.Get("Origin"), "大小寫變形的 Origin 也應被剝掉") assert.Empty(t, dst.Values("ORIGIN")) assert.Empty(t, dst.Values("origin")) }