Files
uopi/internal/api/api_test.go
T
2026-05-21 07:41:56 +02:00

431 lines
12 KiB
Go

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 = `<interface id="" name="Test Panel" version="1" w="800" h="600"></interface>`
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 := `<interface id="" name="Updated Panel" version="2" w="1024" h="768"></interface>`
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