604 lines
18 KiB
Go
604 lines
18 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/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(), "", "", 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── /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(), "", "", 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
|