Files
cloud-ip-validator/internal/httpapi/auth_test.go
T
ayurishchevandClaude Sonnet 5.5 b7669c9e41 Add the Analytics section: check runs, analytics API and page
Runs (migration 0011): a run groups the cycles of one launch. It opens when an
address enters an idle queue, takes everything submitted or re-checked while it
is open and is finalized when all its addresses are done; a re-check after that
opens a new run, so results of different runs never mix. check_runs,
run_results (one result per address and run, with the verdict and the expected
and stored check counts), subnets, run_id on ip_queue and checks. Existing data
is split into runs at pauses of more than an hour; ingress checks get the
validator that held the address (also at write time from now on).

Analytics (internal/analytics): figures computed from the stored checks of the
latest cycle of each address in the run, as facts next to the verdict: summary,
reasons of partial, data quality, subnets, targets and the subnet x target
matrix by check type, ingress by site, error classes, validators, and the
address lists behind the indicators and error classes. API: analytics runs,
report, lists (JSON or CSV), subnet list; run and subnet filters for the
registry.

Dashboard: /analytics matching the approved mockup (run selector, indicators
with address lists and CSV, error-class dialogs, drill-down to the registry),
subnet list on /settings. Sidebar: the control-api link state, theme toggle and
logout moved to the top, the three dots next to the logo removed, sections
grouped.

Rebuilt bin/control-api and bin/admin-dashboard to match. Plan, summary and the
updated README, API, USAGE, DASHBOARD and ADMIN_CLEANUP docs are in docs/.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 18:36:03 +03:00

184 lines
5.7 KiB
Go

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"] != 39 || counts["agent"] != 5 || counts["open"] != 8 {
t.Fatalf("access counts = %v, want admin=39 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)
}
}