Files
uopi/internal/api/handlers_test.go
T
Martino Ferrari c0f7e662be Testing
2026-06-24 01:39:15 +02:00

439 lines
14 KiB
Go

package api_test
import (
"encoding/json"
"io"
"net/http"
"testing"
)
// These tests exercise the many handlers reachable through the default setup()
// harness, whose policy is unconfigured (anonymous caller → write/admin), so
// permission gates default to "allow" and the focus is handler behaviour.
// ── /me ───────────────────────────────────────────────────────────────────────
func TestGetMe(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := get(t, srv, "/api/v1/me")
assertStatus(t, resp, http.StatusOK)
var me map[string]any
readJSON(t, resp, &me)
for _, k := range []string{"user", "level", "groups", "canEditLogic", "canViewAudit", "canAdmin", "defaultZoom"} {
if _, ok := me[k]; !ok {
t.Errorf("/me missing key %q", k)
}
}
// Unconfigured policy → anonymous caller has write level.
if me["level"] != "write" {
t.Errorf("level = %v, want write", me["level"])
}
}
// ── /groups (signal group tree) ───────────────────────────────────────────────
func TestGroupsRoundTrip(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// Initially valid JSON.
resp := get(t, srv, "/api/v1/groups")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Replace with a JSON array → 204.
resp = putRaw(t, srv, "/api/v1/groups", "application/json", []byte(`[{"name":"A"},{"name":"B"}]`))
assertStatus(t, resp, http.StatusNoContent)
// Read back what we wrote.
resp = get(t, srv, "/api/v1/groups")
assertStatus(t, resp, http.StatusOK)
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var tree []map[string]any
if err := json.Unmarshal(body, &tree); err != nil {
t.Fatalf("groups body not a JSON array: %v (%s)", err, body)
}
if len(tree) != 2 {
t.Fatalf("expected 2 groups, got %d", len(tree))
}
// A non-array body is rejected.
resp = putRaw(t, srv, "/api/v1/groups", "application/json", []byte(`{"not":"array"}`))
assertStatus(t, resp, http.StatusBadRequest)
}
// ── /usergroups ───────────────────────────────────────────────────────────────
func TestListUserGroups(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := get(t, srv, "/api/v1/usergroups")
assertStatus(t, resp, http.StatusOK)
var names []string
readJSON(t, resp, &names)
// Unconfigured policy has no named groups; the handler must still return [].
if names == nil {
t.Error("expected non-nil (possibly empty) group list")
}
}
// ── /folders ──────────────────────────────────────────────────────────────────
func TestFolderCRUD(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// List — initially empty.
resp := get(t, srv, "/api/v1/folders")
assertStatus(t, resp, http.StatusOK)
var list []any
readJSON(t, resp, &list)
if len(list) != 0 {
t.Fatalf("expected no folders, got %d", len(list))
}
// Missing name → 400.
resp = postJSON(t, srv, "/api/v1/folders", map[string]any{"name": ""})
assertStatus(t, resp, http.StatusBadRequest)
// Create.
resp = postJSON(t, srv, "/api/v1/folders", map[string]any{"name": "Reactor"})
assertStatus(t, resp, http.StatusCreated)
var created struct {
ID string `json:"id"`
}
readJSON(t, resp, &created)
if created.ID == "" {
t.Fatal("expected folder id")
}
// Update.
resp = putRaw(t, srv, "/api/v1/folders/"+created.ID, "application/json",
[]byte(`{"name":"Reactor Hall"}`))
assertStatus(t, resp, http.StatusNoContent)
// Update with empty name → 400.
resp = putRaw(t, srv, "/api/v1/folders/"+created.ID, "application/json", []byte(`{"name":""}`))
assertStatus(t, resp, http.StatusBadRequest)
// Update unknown folder → 404.
resp = putRaw(t, srv, "/api/v1/folders/fld-nope", "application/json", []byte(`{"name":"x"}`))
assertStatus(t, resp, http.StatusNotFound)
// It now appears in the list.
resp = get(t, srv, "/api/v1/folders")
assertStatus(t, resp, http.StatusOK)
readJSON(t, resp, &list)
if len(list) != 1 {
t.Fatalf("expected 1 folder, got %d", len(list))
}
// Delete.
resp = deleteReq(t, srv, "/api/v1/folders/"+created.ID)
assertStatus(t, resp, http.StatusNoContent)
// Delete unknown → 404.
resp = deleteReq(t, srv, "/api/v1/folders/fld-nope")
assertStatus(t, resp, http.StatusNotFound)
}
// ── /interfaces/{id}/acl ──────────────────────────────────────────────────────
func TestPanelACL(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
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)
// GET ACL of a fresh panel.
resp = get(t, srv, "/api/v1/interfaces/"+created.ID+"/acl")
assertStatus(t, resp, http.StatusOK)
var acl map[string]any
readJSON(t, resp, &acl)
if _, ok := acl["perm"]; !ok {
t.Error("ACL response missing perm")
}
// PUT ACL — set a public read level.
resp = putRaw(t, srv, "/api/v1/interfaces/"+created.ID+"/acl", "application/json",
[]byte(`{"public":"read","grants":[]}`))
assertStatus(t, resp, http.StatusNoContent)
// PUT ACL on a non-existent panel → 404.
resp = putRaw(t, srv, "/api/v1/interfaces/nope/acl", "application/json",
[]byte(`{"public":"read"}`))
assertStatus(t, resp, http.StatusNotFound)
// PUT ACL referencing an unknown folder → 400.
resp = putRaw(t, srv, "/api/v1/interfaces/"+created.ID+"/acl", "application/json",
[]byte(`{"folder":"fld-nope"}`))
assertStatus(t, resp, http.StatusBadRequest)
}
// ── /interfaces/reorder ───────────────────────────────────────────────────────
func TestReorderInterfaces(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
mk := func() string {
resp := postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
assertStatus(t, resp, http.StatusCreated)
var c struct {
ID string `json:"id"`
}
readJSON(t, resp, &c)
return c.ID
}
a, b := mk(), mk()
// Reorder at root (no folder).
resp := postJSON(t, srv, "/api/v1/interfaces/reorder", map[string]any{"ids": []string{b, a}})
assertStatus(t, resp, http.StatusNoContent)
// Reorder into a non-existent folder → 404.
resp = postJSON(t, srv, "/api/v1/interfaces/reorder",
map[string]any{"folder": "fld-nope", "ids": []string{a}})
assertStatus(t, resp, http.StatusNotFound)
// Malformed body → 400.
resp = postRaw(t, srv, "/api/v1/interfaces/reorder", "application/json", []byte(`{not json`))
assertStatus(t, resp, http.StatusBadRequest)
}
// ── /interfaces/{id}/versions ─────────────────────────────────────────────────
func TestInterfaceVersioning(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
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)
id := created.ID
// Update once to create a backup (v1) and bump current to v2.
upd := `<interface id="" name="Test Panel" version="2" w="800" h="600"></interface>`
resp = putRaw(t, srv, "/api/v1/interfaces/"+id, "application/xml", []byte(upd))
assertStatus(t, resp, http.StatusNoContent)
// List versions — at least the backup.
resp = get(t, srv, "/api/v1/interfaces/"+id+"/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 versions, got %d", len(versions))
}
// Get the v1 backup.
resp = get(t, srv, "/api/v1/interfaces/"+id+"/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Bad version path → 400.
resp = get(t, srv, "/api/v1/interfaces/"+id+"/versions/abc")
assertStatus(t, resp, http.StatusBadRequest)
// Tag v1.
resp = putRaw(t, srv, "/api/v1/interfaces/"+id+"/versions/1/tag?tag=golden", "application/json", nil)
assertStatus(t, resp, http.StatusNoContent)
// Promote v1 → becomes new current.
resp = postRaw(t, srv, "/api/v1/interfaces/"+id+"/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusNoContent)
// Fork v1 → brand-new panel.
resp = postRaw(t, srv, "/api/v1/interfaces/"+id+"/versions/1/fork", "application/json", nil)
assertStatus(t, resp, http.StatusCreated)
var fork struct {
ID string `json:"id"`
}
readJSON(t, resp, &fork)
if fork.ID == "" || fork.ID == id {
t.Errorf("fork id = %q, want fresh id", fork.ID)
}
// Version ops on a missing panel → 404.
resp = get(t, srv, "/api/v1/interfaces/missing/versions")
assertStatus(t, resp, http.StatusNotFound)
}
// ── /controllogic ─────────────────────────────────────────────────────────────
func TestControlLogicCRUD(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// Empty list initially.
resp := get(t, srv, "/api/v1/controllogic")
assertStatus(t, resp, http.StatusOK)
var list []any
readJSON(t, resp, &list)
if len(list) != 0 {
t.Fatalf("expected no control logic, got %d", len(list))
}
// Create.
resp = postJSON(t, srv, "/api/v1/controllogic", map[string]any{"name": "Watchdog", "enabled": true})
assertStatus(t, resp, http.StatusCreated)
var g struct {
ID string `json:"id"`
Version int `json:"version"`
}
readJSON(t, resp, &g)
if g.ID == "" {
t.Fatal("expected control logic id")
}
// Get.
resp = get(t, srv, "/api/v1/controllogic/"+g.ID)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Get missing → 404.
resp = get(t, srv, "/api/v1/controllogic/nope")
assertStatus(t, resp, http.StatusNotFound)
// Update (bumps version, creates a backup).
resp = putRaw(t, srv, "/api/v1/controllogic/"+g.ID, "application/json",
[]byte(`{"name":"Watchdog v2","enabled":false}`))
assertStatus(t, resp, http.StatusOK)
// List versions.
resp = get(t, srv, "/api/v1/controllogic/"+g.ID+"/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 control-logic versions, got %d", len(versions))
}
// Promote v1.
resp = postRaw(t, srv, "/api/v1/controllogic/"+g.ID+"/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusOK)
// Fork v1 → new graph id.
resp = postRaw(t, srv, "/api/v1/controllogic/"+g.ID+"/versions/1/fork", "application/json", nil)
assertStatus(t, resp, http.StatusCreated)
var fork struct {
ID string `json:"id"`
}
readJSON(t, resp, &fork)
if fork.ID == "" || fork.ID == g.ID {
t.Errorf("fork id = %q, want fresh id", fork.ID)
}
// Delete.
resp = deleteReq(t, srv, "/api/v1/controllogic/"+g.ID)
assertStatus(t, resp, http.StatusNoContent)
// Delete missing → 404.
resp = deleteReq(t, srv, "/api/v1/controllogic/"+g.ID)
assertStatus(t, resp, http.StatusNotFound)
}
// ── /audit (Nop audit log, anonymous can view on an open policy) ───────────────
func TestGetAuditOpen(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := get(t, srv, "/api/v1/audit")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Bad start time → 400.
resp = get(t, srv, "/api/v1/audit?start=not-a-time")
assertStatus(t, resp, http.StatusBadRequest)
}
// ── /synthetic disabled guards ────────────────────────────────────────────────
// Synthetic is nil in the default harness, so every route must report 503
// (service unavailable) rather than panicking.
func TestSyntheticDisabledRoutes(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
cases := []struct {
method, path string
body []byte
}{
{http.MethodGet, "/api/v1/synthetic/x", nil},
{http.MethodPut, "/api/v1/synthetic/x", []byte(`{}`)},
{http.MethodDelete, "/api/v1/synthetic/x", nil},
{http.MethodPost, "/api/v1/synthetic/trace", []byte(`{}`)},
{http.MethodGet, "/api/v1/synthetic/x/versions", nil},
{http.MethodGet, "/api/v1/synthetic/x/versions/1", nil},
{http.MethodPost, "/api/v1/synthetic/x/versions/1/promote", nil},
{http.MethodPost, "/api/v1/synthetic/x/versions/1/fork", nil},
}
for _, c := range cases {
var resp *http.Response
switch c.method {
case http.MethodGet:
resp = get(t, srv, c.path)
case http.MethodPut:
resp = putRaw(t, srv, c.path, "application/json", c.body)
case http.MethodDelete:
resp = deleteReq(t, srv, c.path)
case http.MethodPost:
resp = postRaw(t, srv, c.path, "application/json", c.body)
}
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("%s %s: status %d, want 503", c.method, c.path, resp.StatusCode)
}
resp.Body.Close()
}
}
// ── /controllogic/{id}/versions/{version} ─────────────────────────────────────
func TestControlLogicGetVersion(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := postJSON(t, srv, "/api/v1/controllogic", map[string]any{"name": "G"})
assertStatus(t, resp, http.StatusCreated)
var g struct {
ID string `json:"id"`
}
readJSON(t, resp, &g)
// Bump so a v1 backup exists.
resp = putRaw(t, srv, "/api/v1/controllogic/"+g.ID, "application/json", []byte(`{"name":"G2"}`))
assertStatus(t, resp, http.StatusOK)
resp = get(t, srv, "/api/v1/controllogic/"+g.ID+"/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Bad version number → 400.
resp = get(t, srv, "/api/v1/controllogic/"+g.ID+"/versions/xyz")
assertStatus(t, resp, http.StatusBadRequest)
}
// ── /config/instances/diff ────────────────────────────────────────────────────
func TestDiffConfigInstancesMissing(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// Missing left id → 400.
resp := get(t, srv, "/api/v1/config/instances/diff?a=&b=")
assertStatus(t, resp, http.StatusBadRequest)
}