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 := `` 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) }