package api_test import ( "bytes" "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "os" "strings" "testing" "github.com/uopi/uopi/internal/api" "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/datasource/stub" "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) } mux := http.NewServeMux() api.New(brk, nil, store, "", "", 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) } } } // ── Ensure os is used (blank import guard) ───────────────────────────────────── var _ = os.DevNull