package api_test import ( "bytes" "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "os" "strings" "testing" "github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/api" "github.com/uopi/uopi/internal/audit" "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/confmgr" "github.com/uopi/uopi/internal/controllogic" "github.com/uopi/uopi/internal/datasource/stub" "github.com/uopi/uopi/internal/panelacl" "github.com/uopi/uopi/internal/storage" ) // ── Test helpers ───────────────────────────────────────────────────────────── func setup(t *testing.T) (*httptest.Server, func()) { t.Helper() ctx, cancel := context.WithCancel(context.Background()) log := slog.New(slog.NewTextHandler(io.Discard, nil)) brk := broker.New(ctx, log) ds := stub.New() if err := ds.Connect(ctx); err != nil { t.Fatal("stub connect:", err) } brk.Register(ds) dir := t.TempDir() store, err := storage.New(dir) if err != nil { t.Fatal("storage.New:", err) } acl, err := panelacl.New(dir) if err != nil { t.Fatal("panelacl.New:", err) } clStore, err := controllogic.NewStore(dir) if err != nil { t.Fatal("controllogic.NewStore:", err) } cfgStore, err := confmgr.New(dir) if err != nil { t.Fatal("confmgr.New:", err) } clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log) mux := http.NewServeMux() api.New(brk, nil, store, cfgStore, access.New("", nil), acl, clStore, clEngine, audit.Nop(), "", "", 0, log).Register(mux, "/api/v1") srv := httptest.NewServer(mux) return srv, func() { srv.Close() cancel() } } func get(t *testing.T, srv *httptest.Server, path string) *http.Response { t.Helper() resp, err := http.Get(srv.URL + path) if err != nil { t.Fatal("GET", path, err) } return resp } func postJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response { t.Helper() b, err := json.Marshal(body) if err != nil { t.Fatal("json.Marshal:", err) } resp, err := http.Post(srv.URL+path, "application/json", bytes.NewReader(b)) if err != nil { t.Fatal("POST", path, err) } return resp } func postRaw(t *testing.T, srv *httptest.Server, path, ct string, body []byte) *http.Response { t.Helper() resp, err := http.Post(srv.URL+path, ct, bytes.NewReader(body)) if err != nil { t.Fatal("POST", path, err) } return resp } func putRaw(t *testing.T, srv *httptest.Server, path, ct string, body []byte) *http.Response { t.Helper() req, err := http.NewRequest(http.MethodPut, srv.URL+path, bytes.NewReader(body)) if err != nil { t.Fatal("NewRequest PUT:", err) } req.Header.Set("Content-Type", ct) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal("PUT", path, err) } return resp } func deleteReq(t *testing.T, srv *httptest.Server, path string) *http.Response { t.Helper() req, err := http.NewRequest(http.MethodDelete, srv.URL+path, nil) if err != nil { t.Fatal("NewRequest DELETE:", err) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal("DELETE", path, err) } return resp } func readJSON(t *testing.T, resp *http.Response, v any) { t.Helper() defer resp.Body.Close() if err := json.NewDecoder(resp.Body).Decode(v); err != nil { t.Fatal("decode response:", err) } } func assertStatus(t *testing.T, resp *http.Response, want int) { t.Helper() if resp.StatusCode != want { body, _ := io.ReadAll(resp.Body) resp.Body.Close() t.Fatalf("expected HTTP %d, got %d: %s", want, resp.StatusCode, bytes.TrimSpace(body)) } } // ── /api/v1/datasources ─────────────────────────────────────────────────────── func TestListDataSources(t *testing.T) { srv, teardown := setup(t) defer teardown() resp := get(t, srv, "/api/v1/datasources") assertStatus(t, resp, http.StatusOK) var sources []struct { Name string `json:"name"` Status string `json:"status"` } readJSON(t, resp, &sources) if len(sources) == 0 { t.Fatal("expected at least one data source") } found := false for _, s := range sources { if s.Name == "stub" { found = true if s.Status != "connected" { t.Errorf("stub status = %q, want connected", s.Status) } } } if !found { t.Error("stub data source not in list") } } // ── /api/v1/signals ─────────────────────────────────────────────────────────── func TestListSignalsMissingDs(t *testing.T) { srv, teardown := setup(t) defer teardown() resp := get(t, srv, "/api/v1/signals") assertStatus(t, resp, http.StatusBadRequest) } func TestListSignalsUnknownDs(t *testing.T) { srv, teardown := setup(t) defer teardown() resp := get(t, srv, "/api/v1/signals?ds=nonexistent") assertStatus(t, resp, http.StatusNotFound) } func TestListSignalsStub(t *testing.T) { srv, teardown := setup(t) defer teardown() resp := get(t, srv, "/api/v1/signals?ds=stub") assertStatus(t, resp, http.StatusOK) var signals []struct { Name string `json:"name"` Type string `json:"type"` } readJSON(t, resp, &signals) if len(signals) == 0 { t.Fatal("expected signals from stub data source") } } // ── /api/v1/signals/search ──────────────────────────────────────────────────── func TestSearchSignals(t *testing.T) { srv, teardown := setup(t) defer teardown() resp := get(t, srv, "/api/v1/signals/search?q=sine") assertStatus(t, resp, http.StatusOK) var signals []struct { Name string `json:"name"` } readJSON(t, resp, &signals) for _, s := range signals { if !strings.Contains(s.Name, "sine") { t.Errorf("signal %q does not match query 'sine'", s.Name) } } } func TestSearchSignalsEmpty(t *testing.T) { srv, teardown := setup(t) defer teardown() // Empty query should return all signals. resp := get(t, srv, "/api/v1/signals/search") assertStatus(t, resp, http.StatusOK) var signals []any readJSON(t, resp, &signals) if len(signals) == 0 { t.Error("expected signals for empty query") } } // ── /api/v1/interfaces ──────────────────────────────────────────────────────── const sampleXML = `` func TestInterfaceCRUD(t *testing.T) { srv, teardown := setup(t) defer teardown() // List — initially empty resp := get(t, srv, "/api/v1/interfaces") assertStatus(t, resp, http.StatusOK) var list []any readJSON(t, resp, &list) if len(list) != 0 { t.Fatalf("expected empty interface list, got %d", len(list)) } // Create resp = postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML)) assertStatus(t, resp, http.StatusCreated) var created struct { ID string `json:"id"` } readJSON(t, resp, &created) if created.ID == "" { t.Fatal("expected non-empty ID from create") } // Get resp = get(t, srv, "/api/v1/interfaces/"+created.ID) assertStatus(t, resp, http.StatusOK) body, _ := io.ReadAll(resp.Body) resp.Body.Close() if !bytes.Contains(body, []byte("Test Panel")) { t.Errorf("GET response missing interface name: %s", body) } // Update updated := `` resp = putRaw(t, srv, "/api/v1/interfaces/"+created.ID, "application/xml", []byte(updated)) assertStatus(t, resp, http.StatusNoContent) // Verify update resp = get(t, srv, "/api/v1/interfaces/"+created.ID) assertStatus(t, resp, http.StatusOK) body, _ = io.ReadAll(resp.Body) resp.Body.Close() if !bytes.Contains(body, []byte("Updated Panel")) { t.Errorf("GET after update missing new name: %s", body) } // List — now one entry resp = get(t, srv, "/api/v1/interfaces") assertStatus(t, resp, http.StatusOK) readJSON(t, resp, &list) if len(list) != 1 { t.Fatalf("expected 1 interface in list, got %d", len(list)) } // Clone req, _ := http.NewRequest(http.MethodPost, srv.URL+"/api/v1/interfaces/"+created.ID+"/clone", nil) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal("clone:", err) } assertStatus(t, resp, http.StatusCreated) var cloned struct { ID string `json:"id"` } readJSON(t, resp, &cloned) if cloned.ID == created.ID { t.Error("clone produced same ID as original") } // Delete original resp = deleteReq(t, srv, "/api/v1/interfaces/"+created.ID) assertStatus(t, resp, http.StatusNoContent) // Get after delete → 404 resp = get(t, srv, "/api/v1/interfaces/"+created.ID) assertStatus(t, resp, http.StatusNotFound) // Delete non-existent → 404 resp = deleteReq(t, srv, "/api/v1/interfaces/"+created.ID) assertStatus(t, resp, http.StatusNotFound) } func TestInterfaceInvalidXML(t *testing.T) { srv, teardown := setup(t) defer teardown() resp := postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte("not xml at all")) assertStatus(t, resp, http.StatusBadRequest) } func TestInterfacePathTraversal(t *testing.T) { srv, teardown := setup(t) defer teardown() // Attempt to read a file outside the storage dir via path traversal in the ID. resp := get(t, srv, "/api/v1/interfaces/../etc/passwd") // Should be 404 (invalid ID), not 200 or 500. if resp.StatusCode == http.StatusOK { t.Error("path traversal succeeded — expected 404") } } // ── Synthetic (disabled — synth is nil) ────────────────────────────────────── func TestSyntheticDisabled(t *testing.T) { srv, teardown := setup(t) defer teardown() resp := get(t, srv, "/api/v1/synthetic") assertStatus(t, resp, http.StatusOK) var out []any readJSON(t, resp, &out) if len(out) != 0 { t.Errorf("expected empty list when synthetic is nil, got %v", out) } } func TestSyntheticCreateDisabled(t *testing.T) { srv, teardown := setup(t) defer teardown() resp := postJSON(t, srv, "/api/v1/synthetic", map[string]any{"name": "test"}) assertStatus(t, resp, http.StatusServiceUnavailable) } // ── Channel Finder (not configured) ────────────────────────────────────────── func TestChannelFinderNotConfigured(t *testing.T) { srv, teardown := setup(t) defer teardown() resp := get(t, srv, "/api/v1/channel-finder?q=test") assertStatus(t, resp, http.StatusNotImplemented) } // ── /healthz ────────────────────────────────────────────────────────────────── func TestHealthz(t *testing.T) { // healthz is registered in server.go, not api.go, so test it directly. mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) srv := httptest.NewServer(mux) defer srv.Close() resp, err := http.Get(srv.URL + "/healthz") if err != nil { t.Fatal(err) } assertStatus(t, resp, http.StatusOK) body, _ := io.ReadAll(resp.Body) resp.Body.Close() if string(body) != "ok" { t.Errorf("healthz body = %q, want ok", body) } } // ── Storage ID validation ───────────────────────────────────────────────────── func TestStorageValidateID(t *testing.T) { dir := t.TempDir() store, err := storage.New(dir) if err != nil { t.Fatal(err) } // Create a real interface first to get a valid ID. id, err := store.Create([]byte(sampleXML), "") if err != nil { t.Fatal("create:", err) } // Valid ID works. data, err := store.Get(id) if err != nil { t.Fatalf("Get(%q): %v", id, err) } if len(data) == 0 { t.Error("expected data, got empty") } // Malicious IDs must be rejected. malicious := []string{"../etc/passwd", "../../secret", "a/b", "a\x00b", ""} for _, bad := range malicious { if _, err := store.Get(bad); err == nil { t.Errorf("Get(%q) should have failed, got nil error", bad) } if err := store.Update(bad, []byte(sampleXML), ""); err == nil { t.Errorf("Update(%q) should have failed", bad) } if err := store.Delete(bad); err == nil { t.Errorf("Delete(%q) should have failed", bad) } } } // ── /api/v1/admin ───────────────────────────────────────────────────────────── // adminSetup builds a server whose mux injects the user named by the // "X-Test-User" header into the request context (mirroring the real access // middleware), so admin-gating can be exercised. The policy restricts admin to // the "ops" group, of which "alice" is a member. func adminSetup(t *testing.T) (*httptest.Server, func()) { t.Helper() ctx, cancel := context.WithCancel(context.Background()) log := slog.New(slog.NewTextHandler(io.Discard, nil)) brk := broker.New(ctx, log) ds := stub.New() if err := ds.Connect(ctx); err != nil { t.Fatal("stub connect:", err) } brk.Register(ds) dir := t.TempDir() store, _ := storage.New(dir) acl, _ := panelacl.New(dir) clStore, _ := controllogic.NewStore(dir) cfgStore, _ := confmgr.New(dir) clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log) // "alice" is an admin (via the ops group); everyone else is a viewer. policy := access.New("", []access.GroupSpec{ {Name: "ops", Members: map[string]access.Role{"alice": access.RoleAdmin}}, }) if err := policy.EnablePersistence(dir); err != nil { t.Fatal("EnablePersistence:", err) } inner := http.NewServeMux() api.New(brk, nil, store, cfgStore, policy, acl, clStore, clEngine, audit.Nop(), "", "", 0, log).Register(inner, "/api/v1") mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if u := r.Header.Get("X-Test-User"); u != "" { r = r.WithContext(access.WithUser(r.Context(), u)) } inner.ServeHTTP(w, r) }) srv := httptest.NewServer(mux) return srv, func() { srv.Close(); cancel() } } func reqAs(t *testing.T, srv *httptest.Server, method, path, user string, body []byte) *http.Response { t.Helper() var rdr io.Reader if body != nil { rdr = bytes.NewReader(body) } req, err := http.NewRequest(method, srv.URL+path, rdr) if err != nil { t.Fatal("NewRequest:", err) } if user != "" { req.Header.Set("X-Test-User", user) } if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(method, path, err) } return resp } func TestAdminForbiddenForNonAdmin(t *testing.T) { srv, teardown := adminSetup(t) defer teardown() // "bob" is only a viewer → 403 on every admin route. assertStatus(t, reqAs(t, srv, http.MethodGet, "/api/v1/admin/access", "bob", nil), http.StatusForbidden) assertStatus(t, reqAs(t, srv, http.MethodGet, "/api/v1/admin/stats", "bob", nil), http.StatusForbidden) assertStatus(t, reqAs(t, srv, http.MethodPut, "/api/v1/admin/users/carol", "bob", []byte(`{"roles":{"public":"operator"}}`)), http.StatusForbidden) } func TestAdminUserAndGroupMutations(t *testing.T) { srv, teardown := adminSetup(t) defer teardown() // alice (admin) assigns carol an auditor role in a new "team" group. resp := reqAs(t, srv, http.MethodPut, "/api/v1/admin/users/carol", "alice", []byte(`{"roles":{"team":"auditor"}}`)) assertStatus(t, resp, http.StatusOK) var snap access.AccessSnapshot readJSON(t, resp, &snap) var carol *access.UserInfo for i := range snap.Users { if snap.Users[i].Name == "carol" { carol = &snap.Users[i] } } if carol == nil { t.Fatal("carol missing from snapshot") } if carol.EffectiveRole != "auditor" || carol.Roles["team"] != "auditor" { t.Errorf("carol = %+v, want auditor in team", carol) } // Create (with parent), rename, and delete a group. assertStatus(t, reqAs(t, srv, http.MethodPost, "/api/v1/admin/groups", "alice", []byte(`{"name":"eng","parent":"public"}`)), http.StatusCreated) resp = reqAs(t, srv, http.MethodPut, "/api/v1/admin/groups/eng", "alice", []byte(`{"name":"engineering","members":{"carol":"admin"}}`)) assertStatus(t, resp, http.StatusOK) readJSON(t, resp, &snap) if !hasGroup(snap, "engineering") || hasGroup(snap, "eng") { t.Errorf("groups after rename = %+v", snap.Groups) } assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/engineering", "alice", nil), http.StatusOK) assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/nope", "alice", nil), http.StatusNotFound) // The built-in public group cannot be deleted. assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/public", "alice", nil), http.StatusBadRequest) } func hasGroup(s access.AccessSnapshot, name string) bool { for _, g := range s.Groups { if g.Name == name { return true } } return false } func TestAdminStats(t *testing.T) { srv, teardown := adminSetup(t) defer teardown() resp := reqAs(t, srv, http.MethodGet, "/api/v1/admin/stats", "alice", nil) assertStatus(t, resp, http.StatusOK) var stats map[string]any readJSON(t, resp, &stats) for _, k := range []string{"uptimeSeconds", "wsConnections", "observedSignals", "goroutines", "dataSources"} { if _, ok := stats[k]; !ok { t.Errorf("stats missing key %q", k) } } } // ── Ensure os is used (blank import guard) ───────────────────────────────────── var _ = os.DevNull