metis/pkg/service/server_test.go

451 lines
15 KiB
Go
Raw Normal View History

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)
}
}
2026-03-31 20:53:59 -03:00
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"},
}
}