package service import ( "crypto/sha256" "encoding/hex" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "metis/pkg/sentinel" ) func TestUIAuthGuardsState(t *testing.T) { app := newTestApp(t) handler := app.Handler() req := httptest.NewRequest(http.MethodGet, "/api/state", nil) resp := httptest.NewRecorder() handler.ServeHTTP(resp, req) if resp.Code != http.StatusForbidden { t.Fatalf("expected forbidden, got %d", resp.Code) } req = httptest.NewRequest(http.MethodGet, "/api/state", nil) req.Header.Set("X-Auth-Request-User", "brad") req.Header.Set("X-Auth-Request-Groups", "admin") resp = httptest.NewRecorder() handler.ServeHTTP(resp, req) if resp.Code != http.StatusOK { t.Fatalf("expected ok, got %d: %s", resp.Code, resp.Body.String()) } } func TestUIAuthAcceptsForwardedSlashGroups(t *testing.T) { app := newTestApp(t) handler := app.Handler() req := httptest.NewRequest(http.MethodGet, "/api/state", nil) req.Header.Set("X-Forwarded-User", "brad") req.Header.Set("X-Forwarded-Groups", "/admin,/ops") resp := httptest.NewRecorder() handler.ServeHTTP(resp, req) if resp.Code != http.StatusOK { t.Fatalf("expected ok, got %d: %s", resp.Code, resp.Body.String()) } } func TestUIAuthRejectsUserWithoutAllowedGroup(t *testing.T) { app := newTestApp(t) handler := app.Handler() req := httptest.NewRequest(http.MethodGet, "/api/state", nil) req.Header.Set("X-Forwarded-Email", "Brad.Stein@gmail.com") resp := httptest.NewRecorder() handler.ServeHTTP(resp, req) if resp.Code != http.StatusForbidden { t.Fatalf("expected forbidden, got %d: %s", resp.Code, resp.Body.String()) } } func TestStateJSONUsesLowerCaseNodeFields(t *testing.T) { app := newTestApp(t) handler := app.Handler() req := httptest.NewRequest(http.MethodGet, "/api/state", nil) req.Header.Set("X-Auth-Request-User", "brad") req.Header.Set("X-Auth-Request-Groups", "admin") resp := httptest.NewRecorder() handler.ServeHTTP(resp, req) if resp.Code != http.StatusOK { t.Fatalf("expected ok, got %d: %s", resp.Code, resp.Body.String()) } body := resp.Body.String() if !strings.Contains(body, `"name":"titan-15"`) { t.Fatalf("expected lowercase node name field in json, got %s", body) } } func TestStateFiltersNodesWithoutCompleteReplacementDefinition(t *testing.T) { dir := t.TempDir() baseImage := filepath.Join(dir, "base.img") if err := os.WriteFile(baseImage, []byte("test-image"), 0o644); err != nil { t.Fatal(err) } sum := sha256.Sum256([]byte("test-image")) inventoryPath := filepath.Join(dir, "inventory.yaml") inv := ` classes: - name: ready arch: arm64 os: armbian image: file://` + baseImage + ` checksum: sha256:` + hex.EncodeToString(sum[:]) + ` k3s_version: v1.31.5+k3s1 - name: incomplete arch: arm64 os: ubuntu image: file://` + baseImage + ` nodes: - name: titan-ready class: ready hostname: titan-ready ip: 192.168.22.240 k3s_role: agent k3s_url: https://192.168.22.7:6443 k3s_token: token ssh_user: atlas ssh_authorized_keys: - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOb8oMX6u0z3sH/p/WBGlvPXXdbGETCKzWYwR/dd6fZb titan-bastion - name: titan-incomplete class: incomplete hostname: titan-incomplete ip: 192.168.22.241 k3s_role: agent k3s_url: https://192.168.22.7:6443 k3s_token: token ssh_user: atlas ssh_authorized_keys: - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOb8oMX6u0z3sH/p/WBGlvPXXdbGETCKzWYwR/dd6fZb titan-bastion ` if err := os.WriteFile(inventoryPath, []byte(inv), 0o644); err != nil { t.Fatal(err) } settings := Settings{ BindAddr: ":0", InventoryPath: inventoryPath, CacheDir: filepath.Join(dir, "cache"), ArtifactDir: filepath.Join(dir, "artifacts"), HistoryPath: filepath.Join(dir, "history.jsonl"), SnapshotsPath: filepath.Join(dir, "snapshots.json"), TargetsPath: filepath.Join(dir, "targets.json"), DefaultFlashHost: "titan-22", FlashHosts: []string{"titan-22"}, LocalHost: "titan-22", AllowedGroups: []string{"admin", "maintenance"}, MaxDeviceBytes: 300000000000, } app, err := NewApp(settings) if err != nil { t.Fatalf("new app: %v", err) } state := app.State("titan-22") if len(state.Nodes) != 1 || state.Nodes[0].Name != "titan-ready" { t.Fatalf("expected only titan-ready in state nodes, got %+v", state.Nodes) } } func TestInternalSnapshotAndWatch(t *testing.T) { app := newTestApp(t) handler := app.Handler() payload := `{"node":"titan-15","collected_at":"2026-03-31T12:00:00Z","snapshot":{"hostname":"titan-15","kernel":"6.6.63","os_image":"Armbian","k3s_version":"v1.31.5+k3s1","containerd":"2.0.0","package_sample":{"containerd":"2.0.0"}}}` req := httptest.NewRequest(http.MethodPost, "/internal/sentinel/snapshot", strings.NewReader(payload)) req.Header.Set("Content-Type", "application/json") resp := httptest.NewRecorder() handler.ServeHTTP(resp, req) if resp.Code != http.StatusOK { t.Fatalf("snapshot failed: %d %s", resp.Code, resp.Body.String()) } req = httptest.NewRequest(http.MethodPost, "/internal/sentinel/watch", nil) resp = httptest.NewRecorder() handler.ServeHTTP(resp, req) if resp.Code != http.StatusOK { t.Fatalf("watch failed: %d %s", resp.Code, resp.Body.String()) } var event Event if err := json.Unmarshal(resp.Body.Bytes(), &event); err != nil { t.Fatalf("decode watch response: %v", err) } if event.Kind != "sentinel.watch" { t.Fatalf("unexpected event kind: %s", event.Kind) } metricsReq := httptest.NewRequest(http.MethodGet, "/metrics", nil) metricsResp := httptest.NewRecorder() handler.ServeHTTP(metricsResp, metricsReq) body := metricsResp.Body.String() if !strings.Contains(body, `metis_sentinel_snapshots_total{node="titan-15",status="ok"} 1`) { t.Fatalf("missing snapshot metric: %s", body) } if !strings.Contains(body, `metis_sentinel_watch_total{status="ok"} 1`) { t.Fatalf("missing watch metric: %s", body) } } func TestRequestValuesJSONBody(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/jobs/replace", strings.NewReader(`{"node":"titan-13","host":"titan-20","device":"hosttmp:///tmp"}`)) req.Header.Set("Content-Type", "application/json") values := requestValues(req) if values["node"] != "titan-13" { t.Fatalf("expected node titan-13, got %q", values["node"]) } if values["host"] != "titan-20" { t.Fatalf("expected host titan-20, got %q", values["host"]) } if values["device"] != "hosttmp:///tmp" { t.Fatalf("expected device hosttmp:///tmp, got %q", values["device"]) } } func TestRequestValuesFormAndAuthHelpers(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/jobs/replace", strings.NewReader("node=titan-13&host=titan-20&device=/dev/sdz")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") values := requestValues(req) if values["device"] != "/dev/sdz" { t.Fatalf("form requestValues = %#v", values) } app := newTestApp(t) req = httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("X-Auth-Request-User", "brad") req.Header.Set("X-Auth-Request-Groups", "admin") if user, ok := app.authorize(req); !ok || user.Name != "brad" { t.Fatalf("authorize = %#v ok=%v", user, ok) } req = httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("X-Forwarded-User", "brad") req.Header.Set("X-Forwarded-Groups", "/admin") if user, ok := app.authorize(req); !ok || user.Name != "brad" { t.Fatalf("forwarded authorize = %#v ok=%v", user, ok) } } func TestHandleBuildReturnsConflictForDuplicateActiveNodeJob(t *testing.T) { app := newTestApp(t) app.newJob("build", "titan-15", "", "") handler := app.Handler() req := httptest.NewRequest(http.MethodPost, "/api/jobs/build", strings.NewReader(`{"node":"titan-15"}`)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Auth-Request-User", "brad") req.Header.Set("X-Auth-Request-Groups", "admin") resp := httptest.NewRecorder() handler.ServeHTTP(resp, req) if resp.Code != http.StatusConflict { t.Fatalf("expected conflict, got %d: %s", resp.Code, resp.Body.String()) } } func TestHTTPHandlersExerciseErrorBranches(t *testing.T) { kube := fakeKubeServer(t) installKubeFactory(t, kube) app := newTestApp(t) app.settings.RunnerImageARM64 = "" app.settings.TargetsPath = t.TempDir() handler := app.Handler() req := httptest.NewRequest(http.MethodPut, "/internal/sentinel/snapshot", nil) resp := httptest.NewRecorder() handler.ServeHTTP(resp, req) if resp.Code != http.StatusMethodNotAllowed { t.Fatalf("expected method not allowed, got %d", resp.Code) } req = httptest.NewRequest(http.MethodPost, "/internal/sentinel/snapshot", strings.NewReader("{")) req.Header.Set("Content-Type", "application/json") resp = httptest.NewRecorder() handler.ServeHTTP(resp, req) if resp.Code != http.StatusBadRequest { t.Fatalf("expected bad request, got %d", resp.Code) } req = httptest.NewRequest(http.MethodGet, "/api/devices?host=titan-22", nil) req.Header.Set("X-Auth-Request-User", "brad") req.Header.Set("X-Auth-Request-Groups", "admin") resp = httptest.NewRecorder() handler.ServeHTTP(resp, req) if resp.Code != http.StatusBadRequest { t.Fatalf("expected bad request from missing runner image, got %d", resp.Code) } req = httptest.NewRequest(http.MethodPost, "/api/jobs/build", strings.NewReader("node=")) req.Header.Set("X-Auth-Request-User", "brad") req.Header.Set("X-Auth-Request-Groups", "admin") resp = httptest.NewRecorder() handler.ServeHTTP(resp, req) if resp.Code != http.StatusBadRequest { t.Fatalf("expected build validation error, got %d", resp.Code) } req = httptest.NewRequest(http.MethodPost, "/api/jobs/replace", strings.NewReader("node=titan-15")) req.Header.Set("X-Auth-Request-User", "brad") req.Header.Set("X-Auth-Request-Groups", "admin") resp = httptest.NewRecorder() handler.ServeHTTP(resp, req) if resp.Code != http.StatusBadRequest { t.Fatalf("expected replace validation error, got %d", resp.Code) } req = httptest.NewRequest(http.MethodPost, "/api/sentinel/watch", nil) req.Header.Set("X-Auth-Request-User", "brad") req.Header.Set("X-Auth-Request-Groups", "admin") resp = httptest.NewRecorder() handler.ServeHTTP(resp, req) if resp.Code != http.StatusInternalServerError { t.Fatalf("expected watch error from unwritable targets path, got %d", resp.Code) } _, ok := app.authorize(httptest.NewRequest(http.MethodGet, "/", nil)) if ok { t.Fatal("authorize should return false for empty headers") } if got := splitHeaderList("a, /b"); len(got) != 2 || got[0] != "a" || got[1] != "/b" { t.Fatal("splitHeaderList failed") } values := requestValues(httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"node":"n1"}`))) if values["node"] != "n1" { t.Fatalf("requestValues JSON parse failed: %#v", values) } } func TestHTTPHandlersAdditionalErrorBranches(t *testing.T) { app := newTestApp(t) handler := app.Handler() blocked := filepath.Join(t.TempDir(), "blocked") if err := os.WriteFile(blocked, []byte("block"), 0o644); err != nil { t.Fatal(err) } app.settings.SnapshotsPath = filepath.Join(blocked, "snapshots.json") payload := `{"node":"titan-15","snapshot":{"hostname":"titan-15"}}` req := httptest.NewRequest(http.MethodPost, "/internal/sentinel/snapshot", strings.NewReader(payload)) resp := httptest.NewRecorder() handler.ServeHTTP(resp, req) if resp.Code != http.StatusBadRequest { t.Fatalf("expected snapshot persistence error, got %d", resp.Code) } for _, path := range []string{"/internal/sentinel/watch", "/api/jobs/build", "/api/jobs/replace", "/api/sentinel/watch"} { req = httptest.NewRequest(http.MethodGet, path, nil) req.Header.Set("X-Auth-Request-User", "brad") req.Header.Set("X-Auth-Request-Groups", "admin") resp = httptest.NewRecorder() handler.ServeHTTP(resp, req) if resp.Code != http.StatusMethodNotAllowed { t.Fatalf("expected method-not-allowed for %s, got %d", path, resp.Code) } } req = httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("X-Auth-Request-User", "brad") req.Header.Set("X-Auth-Request-Groups", "viewers") if _, ok := app.authorize(req); ok { t.Fatal("authorize should reject non-allowed groups") } } func TestWatchHandlersReturnErrorsWhenTargetsCannotPersist(t *testing.T) { app := newTestApp(t) blocked := filepath.Join(t.TempDir(), "blocked") if err := os.WriteFile(blocked, []byte("block"), 0o644); err != nil { t.Fatal(err) } app.settings.TargetsPath = filepath.Join(blocked, "targets.json") handler := app.Handler() req := httptest.NewRequest(http.MethodPost, "/internal/sentinel/watch", nil) resp := httptest.NewRecorder() handler.ServeHTTP(resp, req) if resp.Code != http.StatusInternalServerError { t.Fatalf("expected internal watch error, got %d", resp.Code) } req = httptest.NewRequest(http.MethodPost, "/api/sentinel/watch", nil) req.Header.Set("X-Auth-Request-User", "brad") req.Header.Set("X-Auth-Request-Groups", "admin") resp = httptest.NewRecorder() handler.ServeHTTP(resp, req) if resp.Code != http.StatusInternalServerError { t.Fatalf("expected api watch error, got %d", resp.Code) } } func newTestApp(t *testing.T) *App { t.Helper() dir := t.TempDir() baseImage := filepath.Join(dir, "base.img") if err := os.WriteFile(baseImage, []byte("test-image"), 0o644); err != nil { t.Fatal(err) } sum := sha256.Sum256([]byte("test-image")) inventoryPath := filepath.Join(dir, "inventory.yaml") inv := ` classes: - name: rpi4 arch: arm64 os: armbian image: file://` + baseImage + ` checksum: sha256:` + hex.EncodeToString(sum[:]) + ` k3s_version: v1.31.5+k3s1 nodes: - name: titan-15 class: rpi4 hostname: titan-15 ip: 192.168.22.43 k3s_role: agent k3s_url: https://192.168.22.7:6443 k3s_token: token ssh_user: atlas ssh_authorized_keys: - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOb8oMX6u0z3sH/p/WBGlvPXXdbGETCKzWYwR/dd6fZb titan-bastion ` if err := os.WriteFile(inventoryPath, []byte(inv), 0o644); err != nil { t.Fatal(err) } settings := Settings{ BindAddr: ":0", InventoryPath: inventoryPath, CacheDir: filepath.Join(dir, "cache"), ArtifactDir: filepath.Join(dir, "artifacts"), HistoryPath: filepath.Join(dir, "history.jsonl"), SnapshotsPath: filepath.Join(dir, "snapshots.json"), TargetsPath: filepath.Join(dir, "targets.json"), DefaultFlashHost: "titan-22", FlashHosts: []string{"titan-22"}, LocalHost: "titan-22", AllowedGroups: []string{"admin", "maintainer"}, MaxDeviceBytes: 300000000000, } app, err := NewApp(settings) if err != nil { t.Fatalf("new app: %v", err) } if err := app.StoreSnapshot(SnapshotRecord{ Node: "titan-17", CollectedAt: time.Now().UTC().Add(-10 * time.Minute), Snapshot: sentinelSnapshot("titan-17", "6.6.63"), }); err != nil { t.Fatalf("seed snapshot: %v", err) } return app } func sentinelSnapshot(hostname, kernel string) sentinel.Snapshot { return sentinel.Snapshot{ Hostname: hostname, Kernel: kernel, OSImage: "Armbian", K3sVersion: "v1.31.5+k3s1", Containerd: "2.0.0", PackageSample: map[string]string{"containerd": "2.0.0"}, } }