2026-10-01 11:35:24 +03:00
|
|
|
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}))
|
2026-10-01 19:31:11 +03:00
|
|
|
orch := orchestrator.New(d, openstack.NewMockClient(), cfg, log)
|
|
|
|
|
t.Cleanup(func() { orch.CancelScan() })
|
|
|
|
|
srv := New(d, orch, log).WithAuth(admin, agent)
|
2026-10-01 11:35:24 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-01 19:31:11 +03:00
|
|
|
if counts["admin"] != 34 || counts["agent"] != 5 || counts["open"] != 7 {
|
|
|
|
|
t.Fatalf("access counts = %v, want admin=34 agent=5 open=7", counts)
|
2026-10-01 11:35:24 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|