package httpapi import ( "context" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "cloudipvalidator/internal/config" "cloudipvalidator/internal/db" "cloudipvalidator/internal/openstack" "cloudipvalidator/internal/orchestrator" ) const ( testAdminToken = "admin-token-for-tests" testAgentToken = "agent-token-for-tests" ) func newAuthTestServer(t *testing.T, admin, agent string) (*Server, *httptest.Server) { t.Helper() ctx := context.Background() d, err := db.Open(ctx, filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { d.Close() }) cfg := &config.ControlAPI{ Orchestrator: config.OrchestratorConfig{ PollIntervalSeconds: 1, SelfCheckTimeoutSeconds: 10, MaxSelfCheckRetries: 3, CheckingWindowSeconds: 120, MaxRetries: 3, LeaseTTLSeconds: 180, HeartbeatTimeoutSeconds: 30, }, Inbound: config.InboundConfig{Ports: []int{22}, ICMP: true}, } if err := d.BootstrapFromConfig(ctx, cfg); err != nil { t.Fatalf("bootstrap: %v", err) } log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) orch := orchestrator.New(d, openstack.NewMockClient(), cfg, log) t.Cleanup(func() { orch.CancelScan() }) srv := New(d, orch, log).WithAuth(admin, agent) ts := httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) return srv, ts } // concretePath fills the {wildcards} of a ServeMux pattern with dummy values. func concretePath(pattern string) (method, path string) { method, path, _ = strings.Cut(pattern, " ") var b strings.Builder for { i := strings.IndexByte(path, '{') if i < 0 { b.WriteString(path) break } j := strings.IndexByte(path, '}') b.WriteString(path[:i]) b.WriteString("1") path = path[j+1:] } return method, b.String() } func callWithToken(t *testing.T, ts *httptest.Server, pattern, token string) *http.Response { t.Helper() method, path := concretePath(pattern) req, err := http.NewRequest(method, ts.URL+path, strings.NewReader("{}")) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("Content-Type", "application/json") if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := ts.Client().Do(req) if err != nil { t.Fatalf("%s: %v", pattern, err) } resp.Body.Close() return resp } func TestRouteTableIsClassified(t *testing.T) { srv, _ := newAuthTestServer(t, "", "") counts := map[string]int{} for _, rt := range srv.Routes() { counts[rt.Access]++ if rt.Access == "invalid" { t.Fatalf("route %q has no valid access level", rt.Pattern) } if strings.Contains(rt.Pattern, "/api/v1/admin/") && rt.Access != "admin" { t.Fatalf("admin route %q is %s, want admin", rt.Pattern, rt.Access) } } if counts["admin"] != 34 || counts["agent"] != 5 || counts["open"] != 8 { t.Fatalf("access counts = %v, want admin=34 agent=5 open=8", counts) } } func TestAuthMatrixOverRouteTable(t *testing.T) { srv, ts := newAuthTestServer(t, testAdminToken, testAgentToken) for _, rt := range srv.Routes() { rt := rt t.Run(rt.Pattern, func(t *testing.T) { tokens := []struct { name, token string allowed bool }{ {"none", "", rt.Access == "open"}, {"wrong", "definitely-wrong", rt.Access == "open"}, {"admin token", testAdminToken, rt.Access == "open" || rt.Access == "admin"}, {"agent token", testAgentToken, rt.Access == "open" || rt.Access == "agent"}, } for _, tc := range tokens { resp := callWithToken(t, ts, rt.Pattern, tc.token) if tc.allowed && resp.StatusCode == http.StatusUnauthorized { t.Fatalf("%s: got 401, want request to pass auth", tc.name) } if !tc.allowed { if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("%s: status=%d, want 401", tc.name, resp.StatusCode) } if resp.Header.Get("WWW-Authenticate") != "Bearer" { t.Fatalf("%s: missing WWW-Authenticate: Bearer", tc.name) } } } }) } } func TestEmptyTokensLeaveEverythingOpen(t *testing.T) { srv, ts := newAuthTestServer(t, "", "") for _, rt := range srv.Routes() { if resp := callWithToken(t, ts, rt.Pattern, ""); resp.StatusCode == http.StatusUnauthorized { t.Fatalf("%s: got 401 with no tokens configured", rt.Pattern) } } } func TestOnlyConfiguredLevelIsEnforced(t *testing.T) { _, ts := newAuthTestServer(t, testAdminToken, "") if resp := callWithToken(t, ts, "GET /api/v1/admin/status", ""); resp.StatusCode != http.StatusUnauthorized { t.Fatalf("admin without token: status=%d, want 401", resp.StatusCode) } if resp := callWithToken(t, ts, "POST /api/v1/agents/{id}/results", ""); resp.StatusCode == http.StatusUnauthorized { t.Fatalf("agent route must stay open while agent token is unset") } } func TestHealthzAlwaysOpenAndBearerParsing(t *testing.T) { _, ts := newAuthTestServer(t, testAdminToken, testAgentToken) if resp := callWithToken(t, ts, "GET /healthz", ""); resp.StatusCode != http.StatusOK { t.Fatalf("healthz: status=%d, want 200", resp.StatusCode) } // Raw token without the Bearer scheme must not authenticate. req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/admin/status", nil) req.Header.Set("Authorization", testAdminToken) resp, err := ts.Client().Do(req) if err != nil { t.Fatalf("request: %v", err) } resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("scheme-less token: status=%d, want 401", resp.StatusCode) } // Lowercase scheme is accepted (RFC 7235: case-insensitive). req, _ = http.NewRequest(http.MethodGet, ts.URL+"/api/v1/admin/status", nil) req.Header.Set("Authorization", "bearer "+testAdminToken) resp, err = ts.Client().Do(req) if err != nil { t.Fatalf("request: %v", err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("lowercase bearer: status=%d, want 200", resp.StatusCode) } }