diff --git a/backend/internal/transport/http/middleware/auth.go b/backend/internal/transport/http/middleware/auth.go index b7b2786..34cc4c5 100644 --- a/backend/internal/transport/http/middleware/auth.go +++ b/backend/internal/transport/http/middleware/auth.go @@ -89,10 +89,11 @@ func getDefaultConfig() *AuthConfig { {Path: "/api/sico/project/asset", Method: http.MethodPost}, {Path: "/api/sico/project/sas_asset", Method: http.MethodGet}, {Path: "/api/sico/project/asset", Method: http.MethodDelete}, + {Path: "/api/sico/sandbox/apply", Method: http.MethodPost}, + {Path: "/api/sico/sandbox/release", Method: http.MethodPost}, }, ExcludedPrefixes: []string{ "/api/sico/docs/", - "/api/sico/sandbox", }, JWTAuth: jwtx.New(jwtx.NewStoreWithCache(newCacheFromEnv())), } diff --git a/backend/internal/transport/http/middleware/auth_exclusions_test.go b/backend/internal/transport/http/middleware/auth_exclusions_test.go new file mode 100644 index 0000000..e56f86f --- /dev/null +++ b/backend/internal/transport/http/middleware/auth_exclusions_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Sico Authors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestIsAuthExcluded_OnlyAllowsExpectedSandboxEndpoints(t *testing.T) { + t.Run("sandbox endpoints", func(t *testing.T) { + tests := []struct { + name string + method string + path string + want bool + }{ + { + name: "sandbox apply POST is explicitly public", + method: http.MethodPost, + path: "/api/sico/sandbox/apply", + want: true, + }, + { + name: "sandbox release POST is explicitly public", + method: http.MethodPost, + path: "/api/sico/sandbox/release", + want: true, + }, + { + name: "sandbox list GET is protected", + method: http.MethodGet, + path: "/api/sico/sandbox/list", + want: false, + }, + { + name: "sandbox reset POST is protected", + method: http.MethodPost, + path: "/api/sico/sandbox/reset", + want: false, + }, + { + name: "sandbox instance vnc GET is protected", + method: http.MethodGet, + path: "/api/sico/sandbox/instance/abc/vnc", + want: false, + }, + { + name: "sandbox emulator api ANY path is protected", + method: http.MethodGet, + path: "/api/sico/sandbox/resources/emulator/abc/api/status", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest(tt.method, tt.path, nil) + assert.Equal(t, tt.want, IsAuthExcluded(ctx)) + }) + } + }) +} diff --git a/backend/internal/transport/router/router_test.go b/backend/internal/transport/router/router_test.go new file mode 100644 index 0000000..25c076b --- /dev/null +++ b/backend/internal/transport/router/router_test.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Sico Authors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package router + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "sico-backend/internal/transport/http/middleware" +) + +func TestSandboxRoutesAreNotPublicByDefault(t *testing.T) { + gin.SetMode(gin.TestMode) + + engine := gin.New() + RegisterAPIs(engine) + + publicSandboxRoutes := map[string]bool{ + http.MethodPost + " /api/sico/sandbox/apply": true, + http.MethodPost + " /api/sico/sandbox/release": true, + } + discoveredSandboxRoutes := map[string]struct{}{} + + for _, route := range engine.Routes() { + if !strings.HasPrefix(route.Path, "/api/sico/sandbox") { + continue + } + + path := concreteSandboxRoute(route.Path) + key := route.Method + " " + path + discoveredSandboxRoutes[key] = struct{}{} + + assert.Equalf( + t, + publicSandboxRoutes[key], + isSandboxRouteAuthExcluded(route.Method, path), + "unexpected auth-exclusion for sandbox route %s", + key, + ) + } + + for expectedRoute := range publicSandboxRoutes { + assert.Containsf( + t, + discoveredSandboxRoutes, + expectedRoute, + "expected whitelisted sandbox route %s to stay registered", + expectedRoute, + ) + } +} + +func concreteSandboxRoute(path string) string { + parts := strings.Split(path, "/") + for i, part := range parts { + if strings.HasPrefix(part, ":") || strings.HasPrefix(part, "*") { + parts[i] = "sample" + } + } + return strings.Join(parts, "/") +} + +func isSandboxRouteAuthExcluded(method, path string) bool { + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + ctx.Request = httptest.NewRequest(method, path, nil) + return middleware.IsAuthExcluded(ctx) +}