diff --git a/cmd/control-plane/main_test.go b/cmd/control-plane/main_test.go index 454229b..55b1864 100644 --- a/cmd/control-plane/main_test.go +++ b/cmd/control-plane/main_test.go @@ -143,6 +143,181 @@ func TestControlPlaneAuthentication(t *testing.T) { } } +type controlPlaneRouteCase struct { + method, pattern, path, body string + wantAuthenticatedStatus int +} + +func TestControlPlaneRouteRegistrationMatrix(t *testing.T) { + app := fiber.New() + app.Use(authenticate("operator", "unit-test-password")) + registerHubWithNetwork(app, nil, nil, nil) + registerPhaseA(app, nil, nil) + routes := controlPlaneRouteMatrix() + assertControlPlaneRouteMatrix(t, app, routes) + + for _, route := range routes { + if response := do(app, route.method, route.path, route.body); response.Code != http.StatusUnauthorized { + t.Fatalf("registered route %s %s returned %d without credentials, want %d", route.method, route.path, + response.Code, http.StatusUnauthorized) + } + } + if response := do(app, http.MethodPost, "/api/not-registered", ""); response.Code != http.StatusUnauthorized { + t.Fatalf("unregistered route returned %d without credentials, want %d", response.Code, http.StatusUnauthorized) + } +} + +func TestControlPlaneRegisteredRouteMatrix(t *testing.T) { + databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") + if databaseURL == "" { + t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run control-plane route coverage") + } + databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL) + ctx := context.Background() + phaseAStore, err := phasea.Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = phaseAStore.Close() }) + hubStore, err := hub.Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = hubStore.Close() }) + + webDirectory := t.TempDir() + if err := os.WriteFile(filepath.Join(webDirectory, "index.html"), []byte("index"), 0o600); err != nil { + t.Fatal(err) + } + app := newHandlerWithStores(webDirectory, "operator", "unit-test-password", phaseAStore, hubStore) + routes := controlPlaneRouteMatrix() + assertControlPlaneRouteMatrix(t, app, routes) + + for _, route := range routes { + route := route + t.Run(route.method+" "+route.pattern, func(t *testing.T) { + if response := do(app, route.method, route.path, route.body); response.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated %s %s returned %d, want %d", route.method, route.path, response.Code, http.StatusUnauthorized) + } + response := do(app, route.method, route.path, route.body, "operator", "unit-test-password") + if response.Code != route.wantAuthenticatedStatus { + t.Fatalf("authenticated %s %s returned %d, want %d: %s", route.method, route.path, response.Code, + route.wantAuthenticatedStatus, response.Body.String()) + } + }) + } + + for _, route := range []struct { + method, path string + want int + }{ + {http.MethodPost, "/api/not-registered", http.StatusMethodNotAllowed}, + {http.MethodDelete, "/api/not-registered", http.StatusMethodNotAllowed}, + } { + t.Run("unregistered "+route.method, func(t *testing.T) { + if response := do(app, route.method, route.path, ""); response.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated unregistered %s %s returned %d, want %d", route.method, route.path, response.Code, http.StatusUnauthorized) + } + if response := do(app, route.method, route.path, "", "operator", "unit-test-password"); response.Code != route.want { + t.Fatalf("authenticated unregistered %s %s returned %d, want %d", route.method, route.path, response.Code, route.want) + } + }) + } +} + +func assertControlPlaneRouteMatrix(t *testing.T, app *fiber.App, routes []controlPlaneRouteCase) { + t.Helper() + expected := make(map[string]struct{}, len(routes)) + for _, route := range routes { + key := controlPlaneRouteKey(route.method, route.pattern) + if _, exists := expected[key]; exists { + t.Fatalf("duplicate route matrix entry: %s", key) + } + expected[key] = struct{}{} + } + actual := registeredControlPlaneRoutes(app) + for key := range expected { + if _, exists := actual[key]; !exists { + t.Errorf("route matrix is missing registered route: %s", key) + } + } + for key := range actual { + if _, exists := expected[key]; !exists { + t.Errorf("registered API route is missing from route matrix: %s", key) + } + } +} + +func controlPlaneRouteKey(method, path string) string { return method + " " + path } + +func registeredControlPlaneRoutes(app *fiber.App) map[string]struct{} { + routes := map[string]struct{}{} + for _, methodRoutes := range app.Stack() { + for _, route := range methodRoutes { + if !strings.HasPrefix(route.Path, "/api/") { + continue + } + switch route.Method { + case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete: + routes[controlPlaneRouteKey(route.Method, route.Path)] = struct{}{} + } + } + } + return routes +} + +func controlPlaneRouteMatrix() []controlPlaneRouteCase { + return []controlPlaneRouteCase{ + {http.MethodGet, "/api/browsers", "/api/browsers", "", http.StatusOK}, + {http.MethodGet, "/api/browsers/:alias", "/api/browsers/missing", "", http.StatusNotFound}, + {http.MethodPost, "/api/browsers", "/api/browsers", "", http.StatusBadRequest}, + {http.MethodPost, "/api/browsers/:alias/:action", "/api/browsers/missing/start", "", http.StatusNotFound}, + {http.MethodDelete, "/api/browsers/:alias", "/api/browsers/missing", "", http.StatusNotFound}, + + {http.MethodGet, "/api/network-exits", "/api/network-exits", "", http.StatusOK}, + {http.MethodGet, "/api/network-exits/:id", "/api/network-exits/missing", "", http.StatusNotFound}, + {http.MethodPost, "/api/network-exits", "/api/network-exits", "", http.StatusBadRequest}, + {http.MethodPost, "/api/network-exits/:id/check", "/api/network-exits/missing/check", "", http.StatusNotFound}, + {http.MethodPost, "/api/network-exits/:id/disable", "/api/network-exits/missing/disable", "", http.StatusNotFound}, + + {http.MethodGet, "/api/browser-images", "/api/browser-images", "", http.StatusOK}, + {http.MethodPost, "/api/browser-images", "/api/browser-images", "", http.StatusBadRequest}, + {http.MethodPut, "/api/browser-images/:version", "/api/browser-images/999.0.0", `{"image_ref":"registry.example/browser:missing"}`, http.StatusNotFound}, + {http.MethodDelete, "/api/browser-images/:version", "/api/browser-images/999.0.0", "", http.StatusNotFound}, + + {http.MethodGet, "/api/gateways", "/api/gateways", "", http.StatusOK}, + {http.MethodPost, "/api/gateways", "/api/gateways", "", http.StatusBadRequest}, + {http.MethodDelete, "/api/gateways/:name", "/api/gateways/missing", "", http.StatusNotFound}, + + {http.MethodPost, "/api/phase-a/accounts", "/api/phase-a/accounts", "", http.StatusBadRequest}, + {http.MethodGet, "/api/phase-a/accounts", "/api/phase-a/accounts", "", http.StatusOK}, + {http.MethodGet, "/api/phase-a/accounts/:id", "/api/phase-a/accounts/missing", "", http.StatusNotFound}, + {http.MethodPost, "/api/phase-a/accounts/:id/pause", "/api/phase-a/accounts/missing/pause", "", http.StatusNotFound}, + {http.MethodPost, "/api/phase-a/accounts/:id/resume", "/api/phase-a/accounts/missing/resume", "", http.StatusNotFound}, + {http.MethodPost, "/api/phase-a/accounts/:id/revoke", "/api/phase-a/accounts/missing/revoke", "", http.StatusNotFound}, + + {http.MethodPost, "/api/phase-a/drafts", "/api/phase-a/drafts", "", http.StatusBadRequest}, + {http.MethodGet, "/api/phase-a/drafts", "/api/phase-a/drafts", "", http.StatusOK}, + {http.MethodGet, "/api/phase-a/drafts/:id", "/api/phase-a/drafts/missing", "", http.StatusNotFound}, + + {http.MethodPost, "/api/phase-a/confirmations", "/api/phase-a/confirmations", "", http.StatusBadRequest}, + {http.MethodGet, "/api/phase-a/confirmations", "/api/phase-a/confirmations", "", http.StatusOK}, + {http.MethodGet, "/api/phase-a/confirmations/:id", "/api/phase-a/confirmations/missing", "", http.StatusNotFound}, + + {http.MethodPost, "/api/phase-a/tasks", "/api/phase-a/tasks", "", http.StatusBadRequest}, + {http.MethodGet, "/api/phase-a/tasks", "/api/phase-a/tasks", "", http.StatusOK}, + {http.MethodGet, "/api/phase-a/tasks/:id", "/api/phase-a/tasks/missing", "", http.StatusNotFound}, + {http.MethodGet, "/api/phase-a/attempts/:id", "/api/phase-a/attempts/missing", "", http.StatusNotFound}, + {http.MethodPost, "/api/phase-a/tasks/:id/cancel", "/api/phase-a/tasks/missing/cancel", "", http.StatusNotFound}, + {http.MethodPost, "/api/phase-a/tasks/:id/verify", "/api/phase-a/tasks/missing/verify", "", http.StatusBadRequest}, + {http.MethodPost, "/api/phase-a/tasks/:id/resume", "/api/phase-a/tasks/missing/resume", "", http.StatusNotFound}, + {http.MethodPost, "/api/phase-a/tasks/:id/finish", "/api/phase-a/tasks/missing/finish", "", http.StatusNotFound}, + + {http.MethodPost, "/api/phase-a/mock/execute", "/api/phase-a/mock/execute", "", http.StatusBadRequest}, + {http.MethodGet, "/api/phase-a/audit", "/api/phase-a/audit", "", http.StatusOK}, + } +} + func TestOperatorNotificationFiltersAndRedacts(t *testing.T) { previousLevel := logrus.GetLevel() t.Cleanup(func() { logrus.SetLevel(previousLevel) })