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 TestHandleBuildReturnsConflictForActiveNodeJob(t *testing.T) { app := newTestApp(t) app.newJob("replace", "titan-15", "titan-22", "/dev/sdk") 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()) } if !strings.Contains(resp.Body.String(), "already has an active replace job") { t.Fatalf("expected active job message, got %q", resp.Body.String()) } } 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"}, } }