This commit is contained in:
Martino Ferrari
2026-06-24 01:39:15 +02:00
parent 11120bedca
commit c0f7e662be
76 changed files with 4368 additions and 210 deletions
+146
View File
@@ -0,0 +1,146 @@
package api_test
import (
"net/http"
"testing"
)
// TestConfigRuleCRUD exercises the full config-rule REST surface: list, create,
// get, update, versioning (list/get/promote/fork), check, preview, and delete,
// plus the principal error paths.
func TestConfigRuleCRUD(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// A set the rule (and preview) can bind to.
resp := postJSON(t, srv, "/api/v1/config/sets", map[string]any{
"name": "S",
"parameters": []map[string]any{
{"key": "voltage", "ds": "stub", "signal": "setpoint", "type": "float64", "default": 12.0},
},
})
assertStatus(t, resp, http.StatusCreated)
var set struct {
ID string `json:"id"`
}
readJSON(t, resp, &set)
// Empty list initially.
resp = get(t, srv, "/api/v1/config/rules")
assertStatus(t, resp, http.StatusOK)
var list []map[string]any
readJSON(t, resp, &list)
if len(list) != 0 {
t.Fatalf("expected no rules, got %d", len(list))
}
// Create.
resp = postJSON(t, srv, "/api/v1/config/rules", map[string]any{
"name": "cap",
"setId": set.ID,
"source": "voltage: <=24",
})
assertStatus(t, resp, http.StatusCreated)
var rule struct {
ID string `json:"id"`
Version int `json:"version"`
}
readJSON(t, resp, &rule)
if rule.ID == "" {
t.Fatal("rule missing id")
}
// Create with invalid CUE → 400.
resp = postJSON(t, srv, "/api/v1/config/rules", map[string]any{
"name": "bad",
"setId": set.ID,
"source": "voltage: <=",
})
assertStatus(t, resp, http.StatusBadRequest)
// Create with malformed JSON → 400.
resp = postRaw(t, srv, "/api/v1/config/rules", "application/json", []byte(`{not json`))
assertStatus(t, resp, http.StatusBadRequest)
// Get.
resp = get(t, srv, "/api/v1/config/rules/"+rule.ID)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Get missing → 404.
resp = get(t, srv, "/api/v1/config/rules/nope")
assertStatus(t, resp, http.StatusNotFound)
// Update (bumps version, creates backup).
resp = putRaw(t, srv, "/api/v1/config/rules/"+rule.ID, "application/json",
[]byte(`{"name":"cap2","setId":"`+set.ID+`","source":"voltage: <=30"}`))
assertStatus(t, resp, http.StatusOK)
// List versions.
resp = get(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 rule versions, got %d", len(versions))
}
// Get v1.
resp = get(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Bad version path → 400.
resp = get(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions/xyz")
assertStatus(t, resp, http.StatusBadRequest)
// Promote v1.
resp = postRaw(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusOK)
// Fork v1 → new id.
resp = postRaw(t, srv, "/api/v1/config/rules/"+rule.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 == rule.ID {
t.Errorf("fork id = %q, want fresh id", fork.ID)
}
// Check (live validation of an unsaved source against sample values).
resp = postJSON(t, srv, "/api/v1/config/rules/check", map[string]any{
"source": "voltage: <=24",
"values": map[string]any{"voltage": 20.0},
})
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Check with malformed JSON → 400.
resp = postRaw(t, srv, "/api/v1/config/rules/check", "application/json", []byte(`{`))
assertStatus(t, resp, http.StatusBadRequest)
// Preview against the bound set's live signals.
resp = postJSON(t, srv, "/api/v1/config/rules/preview", map[string]any{
"setId": set.ID,
"source": "voltage: <=24",
})
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Preview with an unknown set → 404.
resp = postJSON(t, srv, "/api/v1/config/rules/preview", map[string]any{
"setId": "does-not-exist",
"source": "voltage: <=24",
})
assertStatus(t, resp, http.StatusNotFound)
// Delete.
resp = deleteReq(t, srv, "/api/v1/config/rules/"+rule.ID)
assertStatus(t, resp, http.StatusNoContent)
// Delete missing → 404.
resp = deleteReq(t, srv, "/api/v1/config/rules/"+rule.ID)
assertStatus(t, resp, http.StatusNotFound)
}
+158
View File
@@ -0,0 +1,158 @@
package api_test
import (
"net/http"
"testing"
)
// TestConfigSetVersioning exercises the set update/version/promote/fork/diff and
// snapshot endpoints that the base end-to-end test does not reach.
func TestConfigSetVersioning(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
mk := func(name string) string {
resp := postJSON(t, srv, "/api/v1/config/sets", map[string]any{
"name": name,
"parameters": []map[string]any{
{"key": "sp", "ds": "stub", "signal": "setpoint", "type": "float64", "default": 1.0},
},
})
assertStatus(t, resp, http.StatusCreated)
var s struct {
ID string `json:"id"`
}
readJSON(t, resp, &s)
return s.ID
}
id := mk("Set A")
// Update → version bump + backup.
resp := putRaw(t, srv, "/api/v1/config/sets/"+id, "application/json",
[]byte(`{"name":"Set A v2","parameters":[{"key":"sp","ds":"stub","signal":"setpoint","type":"float64","default":2.0}]}`))
assertStatus(t, resp, http.StatusOK)
// List versions.
resp = get(t, srv, "/api/v1/config/sets/"+id+"/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 set versions, got %d", len(versions))
}
// Get a specific version.
resp = get(t, srv, "/api/v1/config/sets/"+id+"/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Promote v1.
resp = postRaw(t, srv, "/api/v1/config/sets/"+id+"/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusOK)
// Fork v1 → new set id.
resp = postRaw(t, srv, "/api/v1/config/sets/"+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)
}
// Diff the original against the fork.
resp = get(t, srv, "/api/v1/config/sets/diff?a="+id+"&b="+fork.ID)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Diff with a missing left id → 400.
resp = get(t, srv, "/api/v1/config/sets/diff?a=&b="+fork.ID)
assertStatus(t, resp, http.StatusBadRequest)
// Snapshot the set into a new instance.
resp = postJSON(t, srv, "/api/v1/config/sets/"+id+"/snapshot", map[string]any{"name": "snap-1"})
assertStatus(t, resp, http.StatusCreated)
resp.Body.Close()
}
// TestConfigInstanceVersioning exercises the instance update/version/promote/
// fork/validate and live-diff endpoints.
func TestConfigInstanceVersioning(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// A set to bind instances to.
resp := postJSON(t, srv, "/api/v1/config/sets", map[string]any{
"name": "Set B",
"parameters": []map[string]any{
{"key": "sp", "ds": "stub", "signal": "setpoint", "type": "float64", "default": 1.0},
},
})
assertStatus(t, resp, http.StatusCreated)
var set struct {
ID string `json:"id"`
}
readJSON(t, resp, &set)
// Create an instance.
resp = postJSON(t, srv, "/api/v1/config/instances", map[string]any{
"name": "inst", "setId": set.ID, "values": map[string]any{"sp": 5.0},
})
assertStatus(t, resp, http.StatusCreated)
var inst struct {
ID string `json:"id"`
}
readJSON(t, resp, &inst)
// Get it.
resp = get(t, srv, "/api/v1/config/instances/"+inst.ID)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// List instances.
resp = get(t, srv, "/api/v1/config/instances")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Update → version bump.
resp = putRaw(t, srv, "/api/v1/config/instances/"+inst.ID, "application/json",
[]byte(`{"name":"inst v2","setId":"`+set.ID+`","values":{"sp":7.0}}`))
assertStatus(t, resp, http.StatusOK)
// List versions.
resp = get(t, srv, "/api/v1/config/instances/"+inst.ID+"/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 instance versions, got %d", len(versions))
}
// Get a version.
resp = get(t, srv, "/api/v1/config/instances/"+inst.ID+"/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Promote + fork.
resp = postRaw(t, srv, "/api/v1/config/instances/"+inst.ID+"/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusOK)
resp = postRaw(t, srv, "/api/v1/config/instances/"+inst.ID+"/versions/1/fork", "application/json", nil)
assertStatus(t, resp, http.StatusCreated)
resp.Body.Close()
// Validate against the (empty) rule set → 200.
resp = postJSON(t, srv, "/api/v1/config/instances/"+inst.ID+"/validate", nil)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Live diff against current signal values → 200.
resp = get(t, srv, "/api/v1/config/instances/"+inst.ID+"/livediff")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Delete the instance.
resp = deleteReq(t, srv, "/api/v1/config/instances/"+inst.ID)
assertStatus(t, resp, http.StatusNoContent)
}
+438
View File
@@ -0,0 +1,438 @@
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)
}
+112
View File
@@ -0,0 +1,112 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/panelacl"
)
// TestExtractLogicBlock covers the three branches of the <logic> extractor.
func TestExtractLogicBlock(t *testing.T) {
if got := extractLogicBlock([]byte(`<i><logic><n/></logic></i>`)); got != "<logic><n/></logic>" {
t.Errorf("extract = %q", got)
}
if got := extractLogicBlock([]byte(`<i></i>`)); got != "" {
t.Errorf("no logic: want empty, got %q", got)
}
if got := extractLogicBlock([]byte(`<i><logic>unterminated`)); got != "" {
t.Errorf("unterminated: want empty, got %q", got)
}
}
// TestDataTypeName covers every DataType→token mapping plus the default.
func TestDataTypeName(t *testing.T) {
cases := map[datasource.DataType]string{
datasource.TypeFloat64: "float64",
datasource.TypeFloat64Array: "float64[]",
datasource.TypeString: "string",
datasource.TypeInt64: "int64",
datasource.TypeBool: "bool",
datasource.TypeEnum: "enum",
datasource.DataType(255): "unknown",
}
for typ, want := range cases {
if got := dataTypeName(typ); got != want {
t.Errorf("dataTypeName(%v) = %q, want %q", typ, got, want)
}
}
}
// TestSynVisible covers each visibility branch of synVisible.
func TestSynVisible(t *testing.T) {
cases := []struct {
name string
def synthetic.SignalDef
user string
panel string
groups []string
want bool
}{
{"user own", synthetic.SignalDef{Visibility: "user", Owner: "alice"}, "alice", "", nil, true},
{"user other", synthetic.SignalDef{Visibility: "user", Owner: "alice"}, "bob", "", nil, false},
{"panel match", synthetic.SignalDef{Visibility: "panel", Panel: "p1"}, "bob", "p1", nil, true},
{"panel mismatch", synthetic.SignalDef{Visibility: "panel", Panel: "p1"}, "bob", "p2", nil, false},
{"global", synthetic.SignalDef{Visibility: "global"}, "", "", nil, true},
{"legacy empty", synthetic.SignalDef{}, "", "", nil, true},
{"group member", synthetic.SignalDef{Visibility: "group", Owner: "alice", Groups: []string{"ops"}}, "bob", "", []string{"ops"}, true},
{"group outsider", synthetic.SignalDef{Visibility: "group", Owner: "alice", Groups: []string{"ops"}}, "bob", "", []string{"hr"}, false},
}
for _, tc := range cases {
if got := synVisible(tc.def, tc.user, tc.panel, tc.groups); got != tc.want {
t.Errorf("%s: synVisible = %v, want %v", tc.name, got, tc.want)
}
}
}
// TestPanelScope covers the nil/public→global, group, and private branches.
func TestPanelScope(t *testing.T) {
if sc, _ := panelScope(nil); sc != access.ScopeGlobal {
t.Errorf("nil acl: scope = %q, want global", sc)
}
if sc, _ := panelScope(&panelacl.PanelACL{Public: "read"}); sc != access.ScopeGlobal {
t.Errorf("public acl: scope = %q, want global", sc)
}
sc, groups := panelScope(&panelacl.PanelACL{
Grants: []panelacl.Grant{{Kind: "group", Name: "ops"}, {Kind: "user", Name: "x"}},
})
if sc != access.ScopeGroup || len(groups) != 1 || groups[0] != "ops" {
t.Errorf("group acl: scope=%q groups=%v", sc, groups)
}
if sc, _ := panelScope(&panelacl.PanelACL{Owner: "alice"}); sc != access.ScopePrivate {
t.Errorf("private acl: scope = %q, want private", sc)
}
}
// TestClientIP covers the XFF, X-Real-IP, host:port, and bare-RemoteAddr cases.
func TestClientIP(t *testing.T) {
mk := func(set func(*http.Request)) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/", nil)
set(r)
return r
}
if got := clientIP(mk(func(r *http.Request) { r.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8") })); got != "1.2.3.4" {
t.Errorf("XFF list: got %q", got)
}
if got := clientIP(mk(func(r *http.Request) { r.Header.Set("X-Forwarded-For", "9.9.9.9") })); got != "9.9.9.9" {
t.Errorf("XFF single: got %q", got)
}
if got := clientIP(mk(func(r *http.Request) { r.Header.Set("X-Real-IP", "8.8.8.8") })); got != "8.8.8.8" {
t.Errorf("X-Real-IP: got %q", got)
}
if got := clientIP(mk(func(r *http.Request) { r.RemoteAddr = "10.0.0.1:5555" })); got != "10.0.0.1" {
t.Errorf("host:port: got %q", got)
}
if got := clientIP(mk(func(r *http.Request) { r.RemoteAddr = "bare-addr" })); got != "bare-addr" {
t.Errorf("bare addr: got %q", got)
}
}
+175
View File
@@ -0,0 +1,175 @@
package api_test
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"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/datasource/synthetic"
"github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/storage"
)
// setupSynthetic builds an API server whose synthetic data source is enabled, so
// the synthetic CRUD/versioning/trace handlers run their real bodies.
func setupSynthetic(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)
synthDS := synthetic.New(dir, brk, log)
if err := synthDS.Connect(ctx); err != nil {
t.Fatal("synthetic connect:", err)
}
brk.Register(synthDS)
mux := http.NewServeMux()
api.New(brk, synthDS, store, cfgStore, access.New("", nil), acl, clStore, clEngine, audit.Nop(), "", "", 0, log).Register(mux, "/api/v1")
srv := httptest.NewServer(mux)
return srv, func() { srv.Close(); cancel() }
}
// putJSON marshals body and issues a PUT, mirroring postJSON.
func putJSON(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)
}
return putRaw(t, srv, path, "application/json", b)
}
// syntheticBody returns a minimal valid graph signal sourced from the stub's
// "sine" signal through a gain op.
func syntheticBody(name string) map[string]any {
return map[string]any{
"name": name,
"visibility": "global",
"graph": map[string]any{
"output": "out",
"nodes": []map[string]any{
{"id": "a", "kind": "source", "ds": "stub", "signal": "sine"},
{"id": "g", "kind": "op", "op": "gain", "inputs": []string{"a"},
"params": map[string]any{"k": 2.0}},
{"id": "out", "kind": "output", "inputs": []string{"g"}},
},
},
}
}
func TestSyntheticCRUDEnabled(t *testing.T) {
srv, teardown := setupSynthetic(t)
defer teardown()
// List — initially empty.
resp := get(t, srv, "/api/v1/synthetic")
assertStatus(t, resp, http.StatusOK)
var list []any
readJSON(t, resp, &list)
if len(list) != 0 {
t.Fatalf("expected no synthetic signals, got %d", len(list))
}
// Create.
resp = postJSON(t, srv, "/api/v1/synthetic", syntheticBody("doubled"))
assertStatus(t, resp, http.StatusCreated)
resp.Body.Close()
// Duplicate create → 400 (AddSignal rejects an existing name).
resp = postJSON(t, srv, "/api/v1/synthetic", syntheticBody("doubled"))
assertStatus(t, resp, http.StatusBadRequest)
// Invalid JSON → 400.
resp = postRaw(t, srv, "/api/v1/synthetic", "application/json", []byte(`{bad`))
assertStatus(t, resp, http.StatusBadRequest)
// Get.
resp = get(t, srv, "/api/v1/synthetic/doubled")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Get missing → 404.
resp = get(t, srv, "/api/v1/synthetic/nope")
assertStatus(t, resp, http.StatusNotFound)
// Update (changes gain factor).
upd := syntheticBody("doubled")
upd["graph"].(map[string]any)["nodes"].([]map[string]any)[1]["params"] = map[string]any{"k": 3.0}
resp = putJSON(t, srv, "/api/v1/synthetic/doubled", upd)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Update missing → 404.
resp = putJSON(t, srv, "/api/v1/synthetic/nope", syntheticBody("nope"))
assertStatus(t, resp, http.StatusNotFound)
// Versions list (the update created a backup).
resp = get(t, srv, "/api/v1/synthetic/doubled/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 synthetic versions, got %d", len(versions))
}
// Get v1.
resp = get(t, srv, "/api/v1/synthetic/doubled/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Bad version → 400.
resp = get(t, srv, "/api/v1/synthetic/doubled/versions/xyz")
assertStatus(t, resp, http.StatusBadRequest)
// Promote v1.
resp = postRaw(t, srv, "/api/v1/synthetic/doubled/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Fork v1 → new signal.
resp = postRaw(t, srv, "/api/v1/synthetic/doubled/versions/1/fork", "application/json", nil)
assertStatus(t, resp, http.StatusCreated)
resp.Body.Close()
// Trace an unsaved graph.
resp = postJSON(t, srv, "/api/v1/synthetic/trace", syntheticBody("scratch"))
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Trace with invalid JSON → 400.
resp = postRaw(t, srv, "/api/v1/synthetic/trace", "application/json", []byte(`{bad`))
assertStatus(t, resp, http.StatusBadRequest)
// Delete.
resp = deleteReq(t, srv, "/api/v1/synthetic/doubled")
assertStatus(t, resp, http.StatusNoContent)
// Delete missing → 404.
resp = deleteReq(t, srv, "/api/v1/synthetic/doubled")
assertStatus(t, resp, http.StatusNotFound)
}
+91
View File
@@ -0,0 +1,91 @@
package audit
import (
"log/slog"
"path/filepath"
"testing"
"time"
)
// TestNopRecorder covers the disabled-auditing no-op implementation.
func TestNopRecorder(t *testing.T) {
rec := Nop()
rec.Record(Event{Action: "x"}) // must not panic
got, err := rec.Query(Filter{})
if err != nil {
t.Fatalf("Nop Query: %v", err)
}
if len(got) != 0 {
t.Errorf("Nop Query returned %d events, want 0", len(got))
}
if err := rec.Close(); err != nil {
t.Errorf("Nop Close: %v", err)
}
}
// TestQueryFilters covers the End, DS, and Signal (LIKE) filter branches plus
// the default-outcome and zero-time stamping in Record.
func TestQueryFilters(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.db")
rec, err := NewSQLite(path, slog.Default())
if err != nil {
t.Fatal("NewSQLite:", err)
}
defer rec.Close()
// Anchor base in the past so the zero-Time 'c' record (stamped with now)
// sorts after a/b and stays out of the End window below.
base := time.Now().Add(-time.Hour)
rec.Record(Event{Time: base, Actor: "a", ActorType: ActorUser, Action: "signal.write", DS: "epics", Signal: "PV:TEMP"})
rec.Record(Event{Time: base.Add(time.Second), Actor: "b", ActorType: ActorUser, Action: "signal.write", DS: "synthetic", Signal: "PV:FLOW"})
// Zero Time and empty Outcome exercise the defaulting branches in Record.
rec.Record(Event{Actor: "c", ActorType: ActorUser, Action: "interface.update"})
if err := rec.Close(); err != nil {
t.Fatal("Close:", err)
}
rec, err = NewSQLite(path, slog.Default())
if err != nil {
t.Fatal("reopen:", err)
}
defer rec.Close()
// End filter: only events at or before base.
upTo, err := rec.Query(Filter{End: base.Add(500 * time.Millisecond)})
if err != nil {
t.Fatal("Query end:", err)
}
if len(upTo) != 1 || upTo[0].Actor != "a" {
t.Errorf("end filter = %+v, want one 'a' event", upTo)
}
// DS filter.
byDS, err := rec.Query(Filter{DS: "synthetic"})
if err != nil {
t.Fatal("Query ds:", err)
}
if len(byDS) != 1 || byDS[0].Signal != "PV:FLOW" {
t.Errorf("ds filter = %+v, want one PV:FLOW event", byDS)
}
// Signal LIKE filter (substring match).
bySignal, err := rec.Query(Filter{Signal: "TEMP"})
if err != nil {
t.Fatal("Query signal:", err)
}
if len(bySignal) != 1 || bySignal[0].DS != "epics" {
t.Errorf("signal filter = %+v, want one epics event", bySignal)
}
// The zero-time/empty-outcome record was persisted with a default outcome.
def, err := rec.Query(Filter{Actor: "c"})
if err != nil {
t.Fatal("Query actor c:", err)
}
if len(def) != 1 || def[0].Outcome != OutcomeOK {
t.Errorf("defaulted record = %+v, want one event with outcome ok", def)
}
if def[0].Time.IsZero() {
t.Error("zero Time should have been stamped with now")
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
// BenchmarkFanOut measures the end-to-end latency and throughput of the broker
// fan-out with varying numbers of downstream clients.
func BenchmarkFanOut1Client(b *testing.B) { benchFanOut(b, 1) }
func BenchmarkFanOut1Client(b *testing.B) { benchFanOut(b, 1) }
func BenchmarkFanOut10Clients(b *testing.B) { benchFanOut(b, 10) }
func BenchmarkFanOut20Clients(b *testing.B) { benchFanOut(b, 20) }
func BenchmarkFanOut100Clients(b *testing.B) { benchFanOut(b, 100) }
+2 -2
View File
@@ -141,9 +141,9 @@ func TestStress_RapidSubscribeUnsubscribe(t *testing.T) {
}
const (
nSignals = 20
nSignals = 20
nGoroutines = 50
duration = 2 * time.Second
duration = 2 * time.Second
)
brk, cancel := newBrokerN(t, nSignals)
+48
View File
@@ -0,0 +1,48 @@
package broker_test
import (
"context"
"testing"
"time"
"github.com/uopi/uopi/internal/broker"
)
// TestDataSourcesAndSource covers the registry accessors.
func TestDataSourcesAndSource(t *testing.T) {
b, cancel := newBroker(t)
defer cancel()
all := b.DataSources()
if len(all) != 1 || all[0].Name() != "stub" {
t.Fatalf("DataSources = %v, want one 'stub'", all)
}
if ds, ok := b.Source("stub"); !ok || ds.Name() != "stub" {
t.Errorf("Source(stub) = %v,%v want stub,true", ds, ok)
}
if _, ok := b.Source("nope"); ok {
t.Error("Source(nope): want ok=false")
}
}
// TestReadNow covers the one-shot read happy path plus the unknown-DS and
// context-timeout error branches.
func TestReadNow(t *testing.T) {
b, cancel := newBroker(t)
defer cancel()
ctx, c := context.WithTimeout(context.Background(), time.Second)
defer c()
v, err := b.ReadNow(ctx, broker.SignalRef{DS: "stub", Name: "sine_1hz"})
if err != nil {
t.Fatalf("ReadNow: %v", err)
}
if v.Timestamp.IsZero() {
t.Error("ReadNow returned a zero-timestamp value")
}
// Unknown data source.
if _, err := b.ReadNow(ctx, broker.SignalRef{DS: "ghost", Name: "x"}); err == nil {
t.Error("ReadNow(unknown ds): want error")
}
}
+1 -1
View File
@@ -177,7 +177,7 @@ type LDAPConfig struct {
// TLSConfig enables built-in HTTPS so uopi can terminate TLS itself (e.g. for
// Basic auth) without a reverse proxy. When Enabled, Cert and Key are required.
type TLSConfig struct {
Enabled bool `toml:"enabled"`
Enabled bool `toml:"enabled"`
// Cert and Key are paths to the PEM certificate and private key.
Cert string `toml:"cert"`
Key string `toml:"key"`
+82
View File
@@ -112,6 +112,88 @@ func TestEnvOverrides(t *testing.T) {
}
}
func TestEnvOverridesAuthTLSAndMisc(t *testing.T) {
t.Setenv("UOPI_SERVER_MAX_UPDATE_RATE_HZ", "25.5")
t.Setenv("UOPI_SERVER_TRUSTED_USER_HEADER", "X-Forwarded-User")
t.Setenv("UOPI_SERVER_DEFAULT_USER", "svc")
t.Setenv("UOPI_SERVER_KERBEROS_ENABLED", "true")
t.Setenv("UOPI_SERVER_KERBEROS_KEYTAB", "/etc/krb.keytab")
t.Setenv("UOPI_SERVER_KERBEROS_SERVICE_PRINCIPAL", "HTTP/host")
t.Setenv("UOPI_SERVER_BASIC_AUTH_ENABLED", "YES")
t.Setenv("UOPI_SERVER_BASIC_AUTH_PAM_SERVICE", "login")
t.Setenv("UOPI_SERVER_TLS_ENABLED", "true")
t.Setenv("UOPI_SERVER_TLS_CERT", "/c.pem")
t.Setenv("UOPI_SERVER_TLS_KEY", "/k.pem")
t.Setenv("UOPI_SERVER_LDAP_ENABLED", "true")
t.Setenv("UOPI_SERVER_LDAP_URI", "ldaps://a ldaps://b")
t.Setenv("UOPI_SERVER_LDAP_SEARCH_BASE", "dc=x,dc=y")
t.Setenv("UOPI_SERVER_LDAP_BIND_DN", "cn=svc")
t.Setenv("UOPI_SERVER_LDAP_BIND_PASSWORD", "secret")
t.Setenv("UOPI_AUDIT_ENABLED", "true")
t.Setenv("UOPI_AUDIT_DB_PATH", "/audit.db")
t.Setenv("UOPI_EPICS_AUTO_SYNC_FILTER", "area=SR")
t.Setenv("UOPI_EPICS_AUTO_SYNC_FROM_ARCHIVER", "true")
t.Setenv("EPICS_PVA_ADDR_LIST", "10.1.1.1 10.1.1.2")
t.Setenv("UOPI_UI_DEFAULT_ZOOM", "1.5")
cfg, err := config.Load("")
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.MaxUpdateRateHz != 25.5 {
t.Errorf("MaxUpdateRateHz = %v, want 25.5", cfg.Server.MaxUpdateRateHz)
}
if cfg.Server.TrustedUserHeader != "X-Forwarded-User" {
t.Errorf("TrustedUserHeader = %q", cfg.Server.TrustedUserHeader)
}
if cfg.Server.DefaultUser != "svc" {
t.Errorf("DefaultUser = %q", cfg.Server.DefaultUser)
}
if !cfg.Server.Kerberos.Enabled || cfg.Server.Kerberos.Keytab != "/etc/krb.keytab" || cfg.Server.Kerberos.ServicePrincipal != "HTTP/host" {
t.Errorf("Kerberos = %+v", cfg.Server.Kerberos)
}
if !cfg.Server.BasicAuth.Enabled || cfg.Server.BasicAuth.PAMService != "login" {
t.Errorf("BasicAuth = %+v", cfg.Server.BasicAuth)
}
if !cfg.Server.TLS.Enabled || cfg.Server.TLS.Cert != "/c.pem" || cfg.Server.TLS.Key != "/k.pem" {
t.Errorf("TLS = %+v", cfg.Server.TLS)
}
if !cfg.Server.LDAP.Enabled || len(cfg.Server.LDAP.URIs) != 2 ||
cfg.Server.LDAP.SearchBase != "dc=x,dc=y" || cfg.Server.LDAP.BindDN != "cn=svc" ||
cfg.Server.LDAP.BindPassword != "secret" {
t.Errorf("LDAP = %+v", cfg.Server.LDAP)
}
if !cfg.Audit.Enabled || cfg.Audit.DBPath != "/audit.db" {
t.Errorf("Audit = %+v", cfg.Audit)
}
if cfg.Datasource.EPICS.AutoSyncFilter != "area=SR" || !cfg.Datasource.EPICS.AutoSyncFromArchiver {
t.Errorf("EPICS sync = %+v", cfg.Datasource.EPICS)
}
if len(cfg.Datasource.PVA.AddrList) != 2 {
t.Errorf("PVA AddrList = %+v", cfg.Datasource.PVA.AddrList)
}
if cfg.UI.DefaultZoom != 1.5 {
t.Errorf("UI.DefaultZoom = %v, want 1.5", cfg.UI.DefaultZoom)
}
}
func TestEnvInvalidNumbersIgnored(t *testing.T) {
t.Setenv("UOPI_SERVER_MAX_UPDATE_RATE_HZ", "not-a-number")
t.Setenv("UOPI_UI_DEFAULT_ZOOM", "xyz")
cfg, err := config.Load("")
if err != nil {
t.Fatalf("Load: %v", err)
}
// Invalid floats are ignored, leaving the defaults intact.
if cfg.Server.MaxUpdateRateHz != config.Default().Server.MaxUpdateRateHz {
t.Errorf("invalid MaxUpdateRateHz should be ignored, got %v", cfg.Server.MaxUpdateRateHz)
}
if cfg.UI.DefaultZoom != config.Default().UI.DefaultZoom {
t.Errorf("invalid DefaultZoom should be ignored, got %v", cfg.UI.DefaultZoom)
}
}
func TestEnvTrimsWhitespace(t *testing.T) {
t.Setenv("UOPI_SERVER_LISTEN", " :8888 ")
cfg, err := config.Load("")
+75
View File
@@ -0,0 +1,75 @@
package confmgr
import (
"reflect"
"testing"
)
// TestCoerceSnapshot covers the alternate and error branches of coerceSnapshot
// that the happy-path Snapshot tests do not reach.
func TestCoerceSnapshot(t *testing.T) {
enum := Parameter{Type: TypeEnum, EnumValues: []string{"off", "low", "high"}}
cases := []struct {
name string
p Parameter
raw any
want any
wantErr bool
}{
{"float err", Parameter{Type: TypeFloat}, struct{}{}, nil, true},
{"int round", Parameter{Type: TypeInt}, 2.6, int64(3), false},
{"int err", Parameter{Type: TypeInt}, struct{}{}, nil, true},
{"bool from string", Parameter{Type: TypeBool}, "true", true, false},
{"bool bad string", Parameter{Type: TypeBool}, "maybe", nil, true},
{"bool from numeric", Parameter{Type: TypeBool}, 0.0, false, false},
{"bool unconvertible", Parameter{Type: TypeBool}, struct{}{}, nil, true},
{"string passthrough", Parameter{Type: TypeString}, "x", "x", false},
{"string from numeric", Parameter{Type: TypeString}, 42.0, "42", false},
{"enum string in range", enum, "low", "low", false},
{"enum string out of range", enum, "nope", nil, true},
{"enum index", enum, int64(2), "high", false},
{"enum index out of range", enum, 9.0, nil, true},
{"enum non-numeric", enum, struct{}{}, nil, true},
{"array native", Parameter{Type: TypeFloatArray}, []float64{1, 2}, []float64{1, 2}, false},
{"array err", Parameter{Type: TypeFloatArray}, 1.0, nil, true},
{"default passthrough", Parameter{Type: ParamType("weird")}, "asis", "asis", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := tc.p.coerceSnapshot(tc.raw)
if tc.wantErr {
if err == nil {
t.Errorf("coerceSnapshot(%v): want error", tc.raw)
}
return
}
if err != nil {
t.Fatalf("coerceSnapshot(%v): %v", tc.raw, err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("coerceSnapshot(%v) = %v (%T), want %v (%T)", tc.raw, got, got, tc.want, tc.want)
}
})
}
}
// TestSnapshotCoerceFailureRecorded ensures a coercion failure (vs read failure)
// is counted in res.Failed and kept out of Values.
func TestSnapshotCoerceFailureRecorded(t *testing.T) {
set := ConfigSet{
ID: "s",
Name: "s",
Parameters: []Parameter{
{Key: "n", DS: "d", Signal: "N", Type: TypeInt},
},
}
res := Snapshot(set, func(_, _ string) (any, error) {
return "not-a-number", nil // reads fine, fails coercion
})
if res.Captured != 0 || res.Failed != 1 {
t.Fatalf("summary: captured=%d failed=%d", res.Captured, res.Failed)
}
if _, ok := res.Values["n"]; ok {
t.Error("coercion-failed parameter must not appear in values")
}
}
+4 -4
View File
@@ -50,10 +50,10 @@ type RuleViolation struct {
// violation. Transformed holds the parameter values the rule(s) derived or
// overrode (keys are parameter keys; values are the new concrete values).
type RuleResult struct {
OK bool `json:"ok"`
Violations []RuleViolation `json:"violations,omitempty"`
Transformed map[string]any `json:"transformed,omitempty"`
CompileError string `json:"compileError,omitempty"`
OK bool `json:"ok"`
Violations []RuleViolation `json:"violations,omitempty"`
Transformed map[string]any `json:"transformed,omitempty"`
CompileError string `json:"compileError,omitempty"`
}
// RuleError wraps a failing RuleResult so it can flow through the store's
+9 -9
View File
@@ -36,18 +36,18 @@ func (t ParamType) valid() bool {
// signal (DS + Signal), a value type, an optional default, and validation
// metadata. Parameters may be grouped for presentation via Group/Subgroup.
type Parameter struct {
Key string `json:"key"` // unique within the set
Label string `json:"label,omitempty"` // human-friendly name
Group string `json:"group,omitempty"` // top-level grouping
Key string `json:"key"` // unique within the set
Label string `json:"label,omitempty"` // human-friendly name
Group string `json:"group,omitempty"` // top-level grouping
Subgroup string `json:"subgroup,omitempty"`
DS string `json:"ds"` // target data source
Signal string `json:"signal"` // target signal name
Type ParamType `json:"type"` // value kind
DS string `json:"ds"` // target data source
Signal string `json:"signal"` // target signal name
Type ParamType `json:"type"` // value kind
Default any `json:"default,omitempty"`
Mandatory bool `json:"mandatory,omitempty"`
Min *float64 `json:"min,omitempty"` // numeric lower bound
Max *float64 `json:"max,omitempty"` // numeric upper bound
EnumValues []string `json:"enumValues,omitempty"` // allowed values for enum
Min *float64 `json:"min,omitempty"` // numeric lower bound
Max *float64 `json:"max,omitempty"` // numeric upper bound
EnumValues []string `json:"enumValues,omitempty"` // allowed values for enum
Unit string `json:"unit,omitempty"`
Description string `json:"description,omitempty"`
}
+134
View File
@@ -0,0 +1,134 @@
package confmgr
import (
"testing"
)
// TestParamTypeValid covers the valid/invalid branches of ParamType.valid.
func TestParamTypeValid(t *testing.T) {
for _, ok := range []ParamType{TypeFloat, TypeInt, TypeBool, TypeString, TypeEnum, TypeFloatArray} {
if !ok.valid() {
t.Errorf("%q should be valid", ok)
}
}
if ParamType("bogus").valid() {
t.Error("bogus type should be invalid")
}
}
// TestCheckValue exercises every type branch of Parameter.checkValue, including
// the type-mismatch and range-violation error paths.
func TestCheckValue(t *testing.T) {
cases := []struct {
name string
p Parameter
v any
wantErr bool
}{
{"float ok", Parameter{Type: TypeFloat}, 1.5, false},
{"float from string", Parameter{Type: TypeFloat}, "2.5", false},
{"float not numeric", Parameter{Type: TypeFloat}, true, true},
{"float below min", Parameter{Type: TypeFloat, Min: fptr(0)}, -1.0, true},
{"float above max", Parameter{Type: TypeFloat, Max: fptr(10)}, 11.0, true},
{"int ok", Parameter{Type: TypeInt}, 4.0, false},
{"int non-integer", Parameter{Type: TypeInt}, 4.5, true},
{"bool ok", Parameter{Type: TypeBool}, true, false},
{"bool wrong type", Parameter{Type: TypeBool}, 1.0, true},
{"string ok", Parameter{Type: TypeString}, "hi", false},
{"string wrong type", Parameter{Type: TypeString}, 1.0, true},
{"enum ok", Parameter{Type: TypeEnum, EnumValues: []string{"a", "b"}}, "b", false},
{"enum not string", Parameter{Type: TypeEnum, EnumValues: []string{"a"}}, 1.0, true},
{"enum not allowed", Parameter{Type: TypeEnum, EnumValues: []string{"a"}}, "z", true},
{"array ok", Parameter{Type: TypeFloatArray}, []any{1.0, 2.0}, false},
{"array native", Parameter{Type: TypeFloatArray}, []float64{1, 2}, false},
{"array not array", Parameter{Type: TypeFloatArray}, 1.0, true},
{"array elem below min", Parameter{Type: TypeFloatArray, Min: fptr(0)}, []any{-1.0}, true},
{"array elem above max", Parameter{Type: TypeFloatArray, Max: fptr(5)}, []any{9.0}, true},
{"array bad elem", Parameter{Type: TypeFloatArray}, []any{"nope"}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.p.checkValue(tc.v)
if tc.wantErr && err == nil {
t.Errorf("checkValue(%v): want error", tc.v)
}
if !tc.wantErr && err != nil {
t.Errorf("checkValue(%v): unexpected error %v", tc.v, err)
}
})
}
}
// TestValidateInvalidType covers the invalid-type branch of ConfigSet.Validate.
func TestValidateInvalidType(t *testing.T) {
set := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "s", Type: ParamType("weird")},
}}
if err := set.Validate(); err == nil {
t.Error("invalid parameter type: want error")
}
}
// TestValidateEnumRequiresValues covers the enum-without-values branch.
func TestValidateEnumRequiresValues(t *testing.T) {
set := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "s", Type: TypeEnum},
}}
if err := set.Validate(); err == nil {
t.Error("enum without values: want error")
}
}
// TestValidateMinGreaterThanMax covers the min>max branch.
func TestValidateMinGreaterThanMax(t *testing.T) {
set := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "s", Type: TypeFloat, Min: fptr(10), Max: fptr(1)},
}}
if err := set.Validate(); err == nil {
t.Error("min>max: want error")
}
}
// TestValidateAgainstMandatoryNoDefault covers the mandatory-without-default
// branch of ValidateAgainst.
func TestValidateAgainstMandatoryNoDefault(t *testing.T) {
set := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "req", DS: "d", Signal: "s", Type: TypeFloat, Mandatory: true},
}}
inst := ConfigInstance{SetID: "x", Values: map[string]any{}}
if err := inst.ValidateAgainst(set); err == nil {
t.Error("missing mandatory value: want error")
}
// Providing the value clears the error.
inst.Values["req"] = 1.0
if err := inst.ValidateAgainst(set); err != nil {
t.Errorf("with value: unexpected error %v", err)
}
}
// TestResolveAndNormalize covers Resolve fallbacks and array normalization.
func TestResolveAndNormalize(t *testing.T) {
p := Parameter{Key: "v", Type: TypeFloat, Default: 7.0}
inst := ConfigInstance{Values: map[string]any{}}
if got, ok := inst.Resolve(p); !ok || got != 7.0 {
t.Errorf("Resolve default: got %v,%v want 7,true", got, ok)
}
inst.Values["v"] = 3.0
if got, ok := inst.Resolve(p); !ok || got != 3.0 {
t.Errorf("Resolve value: got %v,%v want 3,true", got, ok)
}
// Optional param without default → no value.
if _, ok := inst.Resolve(Parameter{Key: "none", Type: TypeFloat}); ok {
t.Error("Resolve no-default: want ok=false")
}
arr := Parameter{Type: TypeFloatArray}
out := arr.normalize([]any{1.0, 2.0, 3.0})
if got, ok := out.([]float64); !ok || len(got) != 3 {
t.Errorf("normalize array: got %T %v", out, out)
}
// Non-array passthrough.
if got := arr.normalize("scalar"); got != "scalar" {
t.Errorf("normalize passthrough: got %v", got)
}
}
+4 -4
View File
@@ -26,11 +26,11 @@ type SnapshotEntry struct {
// SnapshotResult summarises a snapshot run. Values holds the captured,
// type-coerced values ready to populate a new ConfigInstance.
type SnapshotResult struct {
SetID string `json:"setId"`
SetID string `json:"setId"`
Entries []SnapshotEntry `json:"entries"`
Captured int `json:"captured"`
Failed int `json:"failed"`
Values map[string]any `json:"-"`
Captured int `json:"captured"`
Failed int `json:"failed"`
Values map[string]any `json:"-"`
}
// Snapshot reads the current value of every parameter's target signal via read
+3 -3
View File
@@ -20,9 +20,9 @@ func TestSnapshotCapturesAndCoerces(t *testing.T) {
}
live := map[string]any{
"PSU:V": 24.5,
"PSU:N": 3.0, // float reading → coerced to int64
"PSU:EN": int64(1), // numeric → bool true
"PSU:MODE": int64(2), // enum index → "high"
"PSU:N": 3.0, // float reading → coerced to int64
"PSU:EN": int64(1), // numeric → bool true
"PSU:MODE": int64(2), // enum index → "high"
"PSU:WF": []float64{1, 2, 3},
"PSU:LBL": "ready",
}
+3 -3
View File
@@ -89,9 +89,9 @@ func New(storageDir string) (*Store, error) {
// header parses the metadata fields shared by sets and instances.
type header struct {
ID string `json:"id"`
Name string `json:"name"`
Version int `json:"version"`
ID string `json:"id"`
Name string `json:"name"`
Version int `json:"version"`
Tag string `json:"tag"`
SetID string `json:"setId"`
Enabled *bool `json:"enabled"`
+118
View File
@@ -0,0 +1,118 @@
package confmgr
import (
"testing"
)
// TestValidateIDRejectsBadChars covers the invalid-character branch of
// validateID, surfaced through the typed getters as ErrNotFound.
func TestValidateIDRejectsBadChars(t *testing.T) {
s, _ := New(t.TempDir())
for _, bad := range []string{"", "bad/slash", "has space", "dot.dot"} {
if _, err := s.GetSet(bad); err == nil {
t.Errorf("GetSet(%q): want error", bad)
}
}
}
// TestNotFoundPaths covers the ErrNotFound branches across the revision API.
func TestNotFoundPaths(t *testing.T) {
s, _ := New(t.TempDir())
if _, err := s.GetSet("missing"); err != ErrNotFound {
t.Errorf("GetSet missing: want ErrNotFound, got %v", err)
}
if _, err := s.GetSetVersion("missing", 1); err != ErrNotFound {
t.Errorf("GetSetVersion missing: want ErrNotFound, got %v", err)
}
if _, err := s.Versions(KindSet, "missing"); err != ErrNotFound {
t.Errorf("Versions missing: want ErrNotFound, got %v", err)
}
if err := s.Promote(KindSet, "missing", 1); err != ErrNotFound {
t.Errorf("Promote missing: want ErrNotFound, got %v", err)
}
if _, err := s.Fork(KindSet, "missing", 1); err != ErrNotFound {
t.Errorf("Fork missing: want ErrNotFound, got %v", err)
}
if _, err := s.UpdateSet("missing", sampleSet(), ""); err != ErrNotFound {
t.Errorf("UpdateSet missing: want ErrNotFound, got %v", err)
}
// A valid id that exists but a version that was never written.
created, _ := s.CreateSet(sampleSet(), "")
if _, err := s.GetSetVersion(created.ID, 99); err != ErrNotFound {
t.Errorf("GetSetVersion bogus version: want ErrNotFound, got %v", err)
}
}
// TestInstancePinnedSetVersion covers the SetVersion>0 branch of setForInstance:
// the instance is validated against a specific (pinned) revision of its set.
func TestInstancePinnedSetVersion(t *testing.T) {
s, _ := New(t.TempDir())
set, _ := s.CreateSet(sampleSet(), "")
// Bump the set to v2 so a distinct earlier revision exists.
if _, err := s.UpdateSet(set.ID, sampleSet(), "v2"); err != nil {
t.Fatal(err)
}
inst := ConfigInstance{
Name: "pinned",
SetID: set.ID,
SetVersion: 1, // pin to the original schema revision
Values: map[string]any{"voltage": 24.0},
}
out, err := s.CreateInstance(inst, "")
if err != nil {
t.Fatalf("CreateInstance pinned: %v", err)
}
if out.SetVersion != 1 {
t.Errorf("SetVersion: want 1, got %d", out.SetVersion)
}
// Updating the pinned instance also exercises the pinned UpdateInstance path.
out.Values["voltage"] = 30.0
v2, err := s.UpdateInstance(out.ID, out, "bump")
if err != nil {
t.Fatalf("UpdateInstance pinned: %v", err)
}
if v2.Version != 2 {
t.Errorf("instance version: want 2, got %d", v2.Version)
}
}
// TestUpdateInstanceMissingSet covers the load-set error branch of
// UpdateInstance (set referenced by the instance does not exist).
func TestUpdateInstanceMissingSet(t *testing.T) {
s, _ := New(t.TempDir())
inst := ConfigInstance{Name: "x", SetID: "nope", Values: map[string]any{}}
if _, err := s.UpdateInstance("anything", inst, ""); err == nil {
t.Error("UpdateInstance with missing set: want error")
}
}
// TestListSkipsVersionedFiles checks List returns only current revisions and
// reflects metadata after updates.
func TestListSkipsVersionedFiles(t *testing.T) {
s, _ := New(t.TempDir())
a, _ := s.CreateSet(sampleSet(), "")
b, _ := s.CreateSet(sampleSet(), "")
// Create backups for a so the dir holds versioned files too.
_, _ = s.UpdateSet(a.ID, sampleSet(), "")
_, _ = s.UpdateSet(a.ID, sampleSet(), "")
metas, err := s.List(KindSet)
if err != nil {
t.Fatal(err)
}
if len(metas) != 2 {
t.Fatalf("List: want 2 current sets, got %d", len(metas))
}
ids := map[string]bool{}
for _, m := range metas {
ids[m.ID] = true
}
if !ids[a.ID] || !ids[b.ID] {
t.Errorf("List missing expected ids: %v", ids)
}
}
+3 -2
View File
@@ -23,14 +23,15 @@ type writableSource struct {
func newWritableSource() *writableSource { return &writableSource{written: map[string]any{}} }
func (s *writableSource) Name() string { return "tgt" }
func (s *writableSource) Connect(context.Context) error { return nil }
func (s *writableSource) Name() string { return "tgt" }
func (s *writableSource) Connect(context.Context) error { return nil }
func (s *writableSource) ListSignals(context.Context) ([]datasource.Metadata, error) {
return nil, nil
}
func (s *writableSource) GetMetadata(_ context.Context, sig string) (datasource.Metadata, error) {
return datasource.Metadata{Name: sig, Writable: true}, nil
}
// Subscribe delivers the signal's last-written value once (if any), so a
// one-shot ReadNow (used by config snapshot) resolves immediately.
func (s *writableSource) Subscribe(_ context.Context, sig string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
+6 -6
View File
@@ -3,12 +3,12 @@
// Fields, in order: minute hour day-of-month month day-of-week.
// Each field supports:
//
// * any value
// */n every n (step over the whole range)
// a-b inclusive range
// a-b/n range with step
// a,b,c comma-separated list of the above
// N a single value
// - any value
// */n every n (step over the whole range)
// a-b inclusive range
// a-b/n range with step
// a,b,c comma-separated list of the above
// N a single value
//
// Day-of-week is 0-6 with 0 = Sunday (7 is also accepted as Sunday). When both
// day-of-month and day-of-week are restricted (neither is "*"), the schedule
+4 -4
View File
@@ -98,10 +98,10 @@ func NewEngine(root context.Context, brk *broker.Broker, store *Store, cfg *conf
rec = audit.Nop()
}
return &Engine{
broker: brk,
store: store,
cfg: cfg,
audit: rec,
broker: brk,
store: store,
cfg: cfg,
audit: rec,
log: log,
root: root,
live: map[string]float64{},
@@ -0,0 +1,93 @@
package controllogic
import (
"math"
"testing"
"github.com/uopi/uopi/internal/confmgr"
)
func TestFormatAny(t *testing.T) {
cases := []struct {
in any
want string
}{
{3.5, "3.5"},
{float64(42), "42"},
{"hello", "hello"},
{true, "true"},
{int64(7), "7"},
}
for _, c := range cases {
if got := formatAny(c.in); got != c.want {
t.Errorf("formatAny(%v) = %q, want %q", c.in, got, c.want)
}
}
}
func TestTestThreshold(t *testing.T) {
cases := []struct {
val float64
op string
cmp float64
want bool
}{
{1, "<", 2, true},
{3, "<", 2, false},
{2, ">=", 2, true},
{1, ">=", 2, false},
{2, "<=", 2, true},
{3, "<=", 2, false},
{2, "==", 2, true},
{2, "!=", 3, true},
{5, ">", 2, true}, // default branch
{1, "", 0, true}, // empty op → ">"
{math.NaN(), "<", 1, false}, // NaN never satisfies
}
for _, c := range cases {
if got := testThreshold(c.val, c.op, c.cmp); got != c.want {
t.Errorf("testThreshold(%v,%q,%v) = %v, want %v", c.val, c.op, c.cmp, got, c.want)
}
}
}
func TestParseFloat(t *testing.T) {
cases := []struct {
in string
want float64
}{
{"3.14", 3.14},
{" 10 ", 10},
{"", 0},
{"not-a-number", 0},
}
for _, c := range cases {
if got := parseFloat(c.in); got != c.want {
t.Errorf("parseFloat(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func TestCoerceParamValue(t *testing.T) {
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeInt}, 3.9); v != int64(3) {
t.Errorf("int coerce = %v, want 3", v)
}
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeBool}, 0); v != false {
t.Errorf("bool 0 = %v, want false", v)
}
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeBool}, 1); v != true {
t.Errorf("bool 1 = %v, want true", v)
}
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeString}, 2.5); v != "2.5" {
t.Errorf("string coerce = %v, want \"2.5\"", v)
}
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeFloat}, 1.25); v != 1.25 {
t.Errorf("float coerce = %v, want 1.25", v)
}
}
func TestSign(t *testing.T) {
if sign(5) != 1 || sign(-5) != -1 || sign(0) != 0 {
t.Errorf("sign mismatch: %d %d %d", sign(5), sign(-5), sign(0))
}
}
+180
View File
@@ -0,0 +1,180 @@
package controllogic
import (
"context"
"io"
"log/slog"
"sync"
"testing"
"time"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
)
// pushSource is a DataSource whose Subscribe captures the broker's delivery
// channel per signal, letting a test push successive values to drive level/edge
// triggers. Writes are recorded so action.write effects can be asserted.
type pushSource struct {
mu sync.Mutex
chans map[string]chan<- datasource.Value
written map[string]any
}
func newPushSource() *pushSource {
return &pushSource{chans: map[string]chan<- datasource.Value{}, written: map[string]any{}}
}
func (s *pushSource) Name() string { return "tgt" }
func (s *pushSource) Connect(context.Context) error { return nil }
func (s *pushSource) ListSignals(context.Context) ([]datasource.Metadata, error) {
return nil, nil
}
func (s *pushSource) GetMetadata(_ context.Context, sig string) (datasource.Metadata, error) {
return datasource.Metadata{Name: sig, Writable: true}, nil
}
func (s *pushSource) Subscribe(_ context.Context, sig string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
s.mu.Lock()
s.chans[sig] = ch
s.mu.Unlock()
return func() {}, nil
}
func (s *pushSource) Write(_ context.Context, signal string, value any) error {
s.mu.Lock()
s.written[signal] = value
s.mu.Unlock()
return nil
}
func (s *pushSource) History(context.Context, string, time.Time, time.Time, int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
func (s *pushSource) chanFor(sig string) (chan<- datasource.Value, bool) {
s.mu.Lock()
defer s.mu.Unlock()
ch, ok := s.chans[sig]
return ch, ok
}
func (s *pushSource) get(sig string) (any, bool) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.written[sig]
return v, ok
}
// TestEngineReloadThresholdTrigger drives a real Reload generation: a
// trigger.threshold on tgt:IN (>5) wired to an action.write of 42 to tgt:OUT.
// Pushing IN below then above the threshold must fire exactly the rising edge.
func TestEngineReloadThresholdTrigger(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx := t.Context()
src := newPushSource()
brk := broker.New(ctx, log)
brk.Register(src)
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatal("NewStore:", err)
}
g := Graph{
Name: "watchdog",
Enabled: true,
Nodes: []Node{
{ID: "t1", Kind: "trigger.threshold", Params: map[string]string{
"signal": "tgt:IN", "op": ">", "value": "5",
}},
{ID: "a1", Kind: "action.write", Params: map[string]string{
"target": "tgt:OUT", "expr": "42",
}},
},
Wires: []Wire{{From: "t1", To: "a1"}},
}
if err := store.Save(g); err != nil {
t.Fatal("Save:", err)
}
e := NewEngine(ctx, brk, store, nil, audit.Nop(), log)
e.Reload()
// t.Context() is cancelled at test cleanup, tearing the generation down.
// Wait until the engine has subscribed to tgt:IN.
var ch chan<- datasource.Value
waitFor(t, time.Second, func() bool {
c, ok := src.chanFor("IN")
if ok {
ch = c
}
return ok
})
// Below threshold: no fire (prev state seeds to false).
ch <- datasource.Value{Data: 0.0, Timestamp: time.Now()}
// Rising edge above threshold: fires the action.
ch <- datasource.Value{Data: 10.0, Timestamp: time.Now()}
waitFor(t, 2*time.Second, func() bool {
v, ok := src.get("OUT")
return ok && toNum(v) == 42
})
if v, ok := src.get("OUT"); !ok || toNum(v) != 42 {
t.Fatalf("OUT = %v (ok=%v), want 42 after rising-edge trigger", v, ok)
}
}
// TestEngineReloadTimerIfWrite covers the timer trigger, startTriggers, and a
// flow.if then-branch driving an action.write.
func TestEngineReloadTimerIfWrite(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx := t.Context()
src := newPushSource()
brk := broker.New(ctx, log)
brk.Register(src)
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatal("NewStore:", err)
}
g := Graph{
Name: "ticker",
Enabled: true,
Nodes: []Node{
{ID: "t1", Kind: "trigger.timer", Params: map[string]string{"interval": "50"}},
{ID: "if1", Kind: "flow.if", Params: map[string]string{"cond": "2 > 1"}},
{ID: "a1", Kind: "action.write", Params: map[string]string{"target": "tgt:OUT2", "expr": "7"}},
},
Wires: []Wire{
{From: "t1", To: "if1"},
{From: "if1", FromPort: "then", To: "a1"},
},
}
if err := store.Save(g); err != nil {
t.Fatal("Save:", err)
}
e := NewEngine(ctx, brk, store, nil, audit.Nop(), log)
e.Reload()
waitFor(t, 2*time.Second, func() bool {
v, ok := src.get("OUT2")
return ok && toNum(v) == 7
})
}
// waitFor polls cond until it returns true or the deadline elapses.
func waitFor(t *testing.T, d time.Duration, cond func() bool) {
t.Helper()
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
if !cond() {
t.Fatalf("condition not met within %s", d)
}
}
+3 -3
View File
@@ -52,9 +52,9 @@ type callNode struct {
args []exprNode
}
func (n numNode) eval(R Resolver) float64 { return n.v }
func (n sigNode) eval(R Resolver) float64 { return R(n.ds, n.name) }
func (n varNode) eval(R Resolver) float64 { return R("local", n.name) }
func (n numNode) eval(R Resolver) float64 { return n.v }
func (n sigNode) eval(R Resolver) float64 { return R(n.ds, n.name) }
func (n varNode) eval(R Resolver) float64 { return R("local", n.name) }
func (n unNode) eval(R Resolver) float64 {
if n.op == "-" {
return -n.a.eval(R)
+11 -11
View File
@@ -61,17 +61,17 @@ type NodeGroup struct {
// Graph is a named, independently-enableable control-logic flow.
type Graph struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Version int `json:"version,omitempty"` // git-style revision; bumped on each Save
Tag string `json:"tag,omitempty"` // optional revision label (e.g. "restored from v3")
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
Scope string `json:"scope,omitempty"` // access.Scope* visibility token
ScopeGroups []string `json:"scopeGroups,omitempty"` // groups for ScopeGroup visibility
Nodes []Node `json:"nodes"`
Wires []Wire `json:"wires"`
Groups []NodeGroup `json:"groups,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Version int `json:"version,omitempty"` // git-style revision; bumped on each Save
Tag string `json:"tag,omitempty"` // optional revision label (e.g. "restored from v3")
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
Scope string `json:"scope,omitempty"` // access.Scope* visibility token
ScopeGroups []string `json:"scopeGroups,omitempty"` // groups for ScopeGroup visibility
Nodes []Node `json:"nodes"`
Wires []Wire `json:"wires"`
Groups []NodeGroup `json:"groups,omitempty"`
}
func (n Node) param(key string) string {
+77
View File
@@ -0,0 +1,77 @@
package controllogic
import (
"os"
"path/filepath"
"reflect"
"testing"
)
// TestStoreDeleteAndReload covers Delete (with trash backup), the ErrNotFound
// branches, List, and load() re-reading a persisted store from disk.
func TestStoreDeleteAndReload(t *testing.T) {
dir := t.TempDir()
s, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
g := Graph{ID: "g1", Name: "one", Enabled: true,
Nodes: []Node{{ID: "n1", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}}}}
if err := s.Save(g); err != nil {
t.Fatalf("Save: %v", err)
}
if err := s.Save(Graph{ID: "g2", Name: "two"}); err != nil {
t.Fatalf("Save g2: %v", err)
}
if got := s.List(); len(got) != 2 {
t.Fatalf("List: want 2, got %d", len(got))
}
// Reload from disk: a fresh Store over the same dir must see both graphs.
s2, err := NewStore(dir)
if err != nil {
t.Fatalf("reopen: %v", err)
}
if got, err := s2.Get("g1"); err != nil || got.Name != "one" {
t.Errorf("reloaded g1 = %+v, %v", got, err)
}
// Delete writes a trash backup then removes the item.
if err := s.Delete("g1"); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := s.Get("g1"); err != ErrNotFound {
t.Errorf("Get after delete: want ErrNotFound, got %v", err)
}
if err := s.Delete("g1"); err != ErrNotFound {
t.Errorf("Delete missing: want ErrNotFound, got %v", err)
}
// A trash file should now exist for g1.
trashDir := filepath.Join(dir, "trash", "controllogic")
entries, err := os.ReadDir(trashDir)
if err != nil || len(entries) == 0 {
t.Errorf("trash dir = %v entries, err %v; want >=1", len(entries), err)
}
}
// TestSplitCSV covers the comma-filter parser, including trimming and the
// empty-input (nil) case.
func TestSplitCSV(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"", nil},
{" ", nil},
{"a", []string{"a"}},
{" a , b ,, c ", []string{"a", "b", "c"}},
}
for _, tc := range cases {
if got := splitCSV(tc.in); !reflect.DeepEqual(got, tc.want) {
t.Errorf("splitCSV(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
+5 -5
View File
@@ -36,11 +36,11 @@ type archiveResponse []struct {
}
type archivePoint struct {
Secs int64 `json:"secs"`
Nanos int64 `json:"nanos"`
Val any `json:"val"`
Severity int `json:"severity"`
Status int `json:"status"`
Secs int64 `json:"secs"`
Nanos int64 `json:"nanos"`
Val any `json:"val"`
Severity int `json:"severity"`
Status int `json:"status"`
}
// fetchArchiveHistory queries the EPICS Archive Appliance JSON API for
+4 -5
View File
@@ -67,14 +67,13 @@ type caChannel struct {
// EPICS is the Channel Access data source.
type EPICS struct {
caAddrList string
archiveURL string
cfURL string
caAddrList string
archiveURL string
cfURL string
autoSyncFilter string
autoSyncFromArchiver bool
// caCtx is the CA context created in Connect().
Stored as unsafe.Pointer
// caCtx is the CA context created in Connect(). Stored as unsafe.Pointer
// because the C type (ca_client_context *) is opaque. Every goroutine
// that calls CA functions must call caAttachContext(caCtx) first, because
// Go goroutines can run on any OS thread and CA contexts are thread-local.
+3 -3
View File
@@ -28,9 +28,9 @@ func Available() bool { return true }
// EPICS is the pure-Go Channel Access data source.
type EPICS struct {
caAddrList string
archiveURL string
cfURL string
caAddrList string
archiveURL string
cfURL string
autoSyncFilter string
autoSyncFromArchiver bool
pvNames []string // pre-fetched at connect time for ListSignals
+1 -1
View File
@@ -18,7 +18,7 @@ const updateInterval = 100 * time.Millisecond // 10 Hz default
type signalDef struct {
meta datasource.Metadata
interval time.Duration // 0 → updateInterval
interval time.Duration // 0 → updateInterval
fn func(t time.Time) any
}
+7 -7
View File
@@ -4,13 +4,13 @@ package synthetic
// SignalDef describes one synthetic signal.
type SignalDef struct {
Name string `json:"name"`
DS string `json:"ds"` // upstream data source name
Signal string `json:"signal"` // upstream signal name (or "" for constant)
Inputs []InputRef `json:"inputs"` // alternative multi-input format
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
Graph *Graph `json:"graph,omitempty"` // DAG form (preferred when present)
Meta MetaOverride `json:"meta"` // optional metadata overrides
Name string `json:"name"`
DS string `json:"ds"` // upstream data source name
Signal string `json:"signal"` // upstream signal name (or "" for constant)
Inputs []InputRef `json:"inputs"` // alternative multi-input format
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
Graph *Graph `json:"graph,omitempty"` // DAG form (preferred when present)
Meta MetaOverride `json:"meta"` // optional metadata overrides
// Visibility controls who sees this signal in the signal tree:
// "global" — listed in every panel's edit mode
+4 -4
View File
@@ -13,10 +13,10 @@ import (
// each node's inputs already resolved. Op-node state maps persist across
// evaluations (for stateful nodes like moving_average / lua).
type runtimeGraph struct {
order []*rtNode // topological order (sources first, output last)
sources []rtSource // source nodes, in topological order
outputID string // id of the output node
outType dsp.ValType // best-effort output type (scalar/array/unknown)
order []*rtNode // topological order (sources first, output last)
sources []rtSource // source nodes, in topological order
outputID string // id of the output node
outType dsp.ValType // best-effort output type (scalar/array/unknown)
}
type rtNode struct {
@@ -18,8 +18,8 @@ type seqSource struct {
seq []datasource.Value
}
func (s *seqSource) Name() string { return s.name }
func (s *seqSource) Connect(context.Context) error { return nil }
func (s *seqSource) Name() string { return s.name }
func (s *seqSource) Connect(context.Context) error { return nil }
func (s *seqSource) ListSignals(context.Context) ([]datasource.Metadata, error) { return nil, nil }
func (s *seqSource) GetMetadata(context.Context, string) (datasource.Metadata, error) {
return datasource.Metadata{Name: "x", Type: datasource.TypeFloat64}, nil
+28
View File
@@ -0,0 +1,28 @@
package datasource
import (
"context"
"testing"
)
// TestWithUserAndUserFrom covers the context identity round-trip, including the
// empty-user passthrough and the missing-identity fallback.
func TestWithUserAndUserFrom(t *testing.T) {
base := context.Background()
// No identity present.
if u, ok := UserFrom(base); ok || u != "" {
t.Errorf("UserFrom(empty) = %q,%v want \"\",false", u, ok)
}
// Empty user must not attach a value.
if ctx := WithUser(base, ""); ctx != base {
t.Error("WithUser with empty user should return the original context")
}
// Real identity round-trips.
ctx := WithUser(base, "alice")
if u, ok := UserFrom(ctx); !ok || u != "alice" {
t.Errorf("UserFrom = %q,%v want alice,true", u, ok)
}
}
+145
View File
@@ -0,0 +1,145 @@
package dsp
import (
"math"
"testing"
)
// TestExprFunctions exercises every built-in function branch of parseCall plus
// the two-argument forms and the right-associative power operator.
func TestExprFunctions(t *testing.T) {
st := map[string]any{}
cases := []struct {
expr string
inputs []float64
want float64
}{
{"exp(a)", []float64{1}, math.E},
{"log(a)", []float64{math.E}, 1},
{"ln(a)", []float64{math.E}, 1},
{"log2(a)", []float64{8}, 3},
{"log10(a)", []float64{1000}, 3},
{"sqrt(a)", []float64{9}, 3},
{"abs(a)", []float64{-4}, 4},
{"sin(a)", []float64{0}, 0},
{"cos(a)", []float64{0}, 1},
{"tan(a)", []float64{0}, 0},
{"asin(a)", []float64{1}, math.Pi / 2},
{"acos(a)", []float64{1}, 0},
{"atan(a)", []float64{1}, math.Pi / 4},
{"atan2(a, b)", []float64{1, 1}, math.Pi / 4},
{"pow(a, b)", []float64{2, 10}, 1024},
{"floor(a)", []float64{2.9}, 2},
{"ceil(a)", []float64{2.1}, 3},
{"round(a)", []float64{2.5}, 3},
{"min(a, b)", []float64{3, 7}, 3},
{"max(a, b)", []float64{3, 7}, 7},
{"a ^ b", []float64{2, 3}, 8},
{"2 ^ 3 ^ 2", []float64{}, 512}, // right-associative: 2^(3^2)
{"-a ^ 2", []float64{3}, -9}, // unary minus binds outside power
}
for _, tc := range cases {
t.Run(tc.expr, func(t *testing.T) {
n := &ExprNode{Expr: tc.expr}
got, err := n.Process(tc.inputs, st)
if err != nil {
t.Fatalf("Process(%q): %v", tc.expr, err)
}
if math.Abs(got-tc.want) > 1e-9 {
t.Errorf("Process(%q) = %v, want %v", tc.expr, got, tc.want)
}
})
}
}
// TestExprFunctionErrors covers the error branches of parseCall/parseFactor.
func TestExprFunctionErrors(t *testing.T) {
st := map[string]any{}
cases := []string{
"bogus(a)", // unknown function
"sqrt(a", // missing ')'
"sqrt(", // empty / unexpected end inside call
"@", // unexpected character
"(a + 1", // missing closing parenthesis
"1.2.3", // invalid number
}
for _, expr := range cases {
t.Run(expr, func(t *testing.T) {
n := &ExprNode{Expr: expr}
if _, err := n.Process([]float64{1}, st); err == nil {
t.Errorf("Process(%q): want error", expr)
}
})
}
}
// TestArrayNodeScalarAdapters covers the legacy scalar Node interface
// (Type + Process) on the array nodes, which the array-path tests skip.
func TestArrayNodeScalarAdapters(t *testing.T) {
st := map[string]any{}
// Reductions: Process treats its float64 inputs as a single-element array,
// so each reduction over one value returns that value.
reductions := []struct {
node ArrayNode
typ string
}{
{&SumNode{}, "sum"},
{&MeanNode{}, "mean"},
{&MinNode{}, "min"},
{&MaxNode{}, "max"},
{&IndexNode{I: 0}, "index"},
}
for _, r := range reductions {
t.Run(r.typ, func(t *testing.T) {
if r.node.Type() != r.typ {
t.Errorf("Type() = %q, want %q", r.node.Type(), r.typ)
}
got, err := r.node.Process([]float64{42}, st)
if err != nil {
t.Fatalf("Process: %v", err)
}
if got != 42 {
t.Errorf("Process = %v, want 42", got)
}
})
}
// LengthNode over a single scalar input → length 1.
ln := &LengthNode{}
if ln.Type() != "length" {
t.Errorf("LengthNode.Type() = %q", ln.Type())
}
if got, err := ln.Process([]float64{7}, st); err != nil || got != 1 {
t.Errorf("LengthNode.Process = %v, %v; want 1", got, err)
}
// SliceNode.Process returns the first element of the resulting slice.
sn := &SliceNode{Start: 0, End: 0}
if sn.Type() != "slice" {
t.Errorf("SliceNode.Type() = %q", sn.Type())
}
if got, err := sn.Process([]float64{5}, st); err != nil || got != 5 {
t.Errorf("SliceNode.Process = %v, %v; want 5", got, err)
}
// FFTNode.Process returns the first magnitude bin (DC term = the value).
fn := &FFTNode{}
if fn.Type() != "fft" {
t.Errorf("FFTNode.Type() = %q", fn.Type())
}
if got, err := fn.Process([]float64{3}, st); err != nil || math.Abs(got-3) > 1e-9 {
t.Errorf("FFTNode.Process = %v, %v; want 3", got, err)
}
}
// TestArrayNodeProcessErrors covers the error propagation through the scalar
// Process adapters.
func TestArrayNodeProcessErrors(t *testing.T) {
st := map[string]any{}
// IndexNode with an out-of-range index propagates the reduction error.
n := &IndexNode{I: 5}
if _, err := n.Process([]float64{1}, st); err == nil {
t.Error("IndexNode.Process out of range: want error")
}
}
+5 -5
View File
@@ -13,11 +13,11 @@ var startTime = time.Now()
// Counters and gauges — updated by callers in ws.go and api.go.
var (
wsConns atomic.Int64 // current open WebSocket connections (gauge)
msgIn atomic.Int64 // total WS messages received (counter)
msgOut atomic.Int64 // total WS messages sent (counter)
writeOps atomic.Int64 // total signal write operations (counter)
historyReqs atomic.Int64 // total history requests served (counter)
wsConns atomic.Int64 // current open WebSocket connections (gauge)
msgIn atomic.Int64 // total WS messages received (counter)
msgOut atomic.Int64 // total WS messages sent (counter)
writeOps atomic.Int64 // total signal write operations (counter)
historyReqs atomic.Int64 // total history requests served (counter)
)
// IncWsConns increments the active WebSocket connection gauge.
+19
View File
@@ -0,0 +1,19 @@
//go:build !pam
package pamauth
import (
"errors"
"testing"
)
// TestStubUnavailable verifies the default (non-PAM) build reports PAM as
// unavailable and Authenticate always fails with ErrUnavailable.
func TestStubUnavailable(t *testing.T) {
if Available {
t.Error("Available should be false in the non-PAM build")
}
if err := Authenticate("login", "alice", "secret"); !errors.Is(err, ErrUnavailable) {
t.Errorf("Authenticate = %v, want ErrUnavailable", err)
}
}
+80
View File
@@ -0,0 +1,80 @@
package panelacl
import "testing"
// TestPermString covers the Perm→token rendering.
func TestPermString(t *testing.T) {
cases := map[Perm]string{PermNone: "none", PermRead: "read", PermWrite: "write"}
for p, want := range cases {
if got := p.String(); got != want {
t.Errorf("Perm(%d).String() = %q, want %q", p, got, want)
}
}
}
// TestPlaceAndDeletePanel covers PlacePanel (organizational-only record that
// stays open) and DeletePanel (present + absent).
func TestPlaceAndDeletePanel(t *testing.T) {
s := newStore(t)
if _, err := s.CreateFolder("f1", "Folder 1", "", "alice"); err != nil {
t.Fatal(err)
}
// Placing a legacy panel into a folder must not lock it (no owner record).
if err := s.PlacePanel("legacy", "f1", 2.5); err != nil {
t.Fatalf("PlacePanel: %v", err)
}
if got := s.PanelPerm("legacy", "bob", nil); got != PermWrite {
t.Errorf("placed legacy panel perm = %v, want write", got)
}
// DeletePanel on a missing record is a no-op success.
if err := s.DeletePanel("never"); err != nil {
t.Errorf("DeletePanel missing: %v", err)
}
// DeletePanel on the placed record removes it.
if err := s.DeletePanel("legacy"); err != nil {
t.Fatalf("DeletePanel: %v", err)
}
}
// TestFoldersAndGetFolder covers the folder accessors, FolderPerm on an owner
// vs a stranger, and reload from the persisted index.
func TestFoldersAndGetFolder(t *testing.T) {
dir := t.TempDir()
s, err := New(dir)
if err != nil {
t.Fatal("New:", err)
}
if _, err := s.CreateFolder("root", "Root", "", "alice"); err != nil {
t.Fatal(err)
}
all := s.Folders()
if len(all) != 1 {
t.Fatalf("Folders: want 1, got %d", len(all))
}
if f, err := s.GetFolder("root"); err != nil || f.Name != "Root" {
t.Errorf("GetFolder = %+v, %v", f, err)
}
if _, err := s.GetFolder("ghost"); err != ErrNotFound {
t.Errorf("GetFolder missing: want ErrNotFound, got %v", err)
}
// Owner has write on the folder; an unrelated user has none.
if got := s.FolderPerm("root", "alice", nil); got != PermWrite {
t.Errorf("owner FolderPerm = %v, want write", got)
}
if got := s.FolderPerm("root", "bob", nil); got != PermNone {
t.Errorf("stranger FolderPerm = %v, want none", got)
}
// Reload: a fresh Store over the same dir still sees the folder.
s2, err := New(dir)
if err != nil {
t.Fatalf("reopen: %v", err)
}
if _, err := s2.GetFolder("root"); err != nil {
t.Errorf("reloaded GetFolder: %v", err)
}
}
+1 -1
View File
@@ -74,7 +74,7 @@ type PanelACL struct {
Folder string `json:"folder,omitempty"` // folder id the panel belongs to ("" = root)
Public string `json:"public,omitempty"` // "" | "read" | "write"
Grants []Grant `json:"grants,omitempty"`
Order float64 `json:"order,omitempty"` // sort position within its folder
Order float64 `json:"order,omitempty"` // sort position within its folder
}
// Folder is a node in the panel-organisation hierarchy. Permissions set on a
+3 -3
View File
@@ -31,9 +31,9 @@ type DebugHub struct {
seq uint64 // unique simulate route ids
mu sync.Mutex
subs map[*wsClient]*debugSub // one debug sub per client
routes map[string]map[*wsClient]struct{} // route id → watching clients
liveCount map[string]int // live-subscribed graph id → count
subs map[*wsClient]*debugSub // one debug sub per client
routes map[string]map[*wsClient]struct{} // route id → watching clients
liveCount map[string]int // live-subscribed graph id → count
}
type debugSub struct {
+223
View File
@@ -0,0 +1,223 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/uopi/uopi/internal/access"
)
// -------------------------------------------------------------------------- //
// accessMiddleware //
// -------------------------------------------------------------------------- //
// okHandler records that it ran and returns 200.
func okHandler(ran *bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
*ran = true
w.WriteHeader(http.StatusOK)
})
}
func TestAccessMiddlewareWriteUserPassesMutation(t *testing.T) {
// A configured policy with an operator (write) user.
policy := access.New("", []access.GroupSpec{
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
})
var ran bool
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
req := httptest.NewRequest(http.MethodPost, "/api/v1/interfaces", nil)
req.Header.Set(testUserHeader, "alice")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !ran {
t.Errorf("write user POST: code=%d ran=%v, want 200/true", rec.Code, ran)
}
}
func TestAccessMiddlewareReadUserBlockedFromMutation(t *testing.T) {
// Configured policy → an unlisted user is a read-only viewer.
policy := access.New("", []access.GroupSpec{
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
})
var ran bool
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
req := httptest.NewRequest(http.MethodPost, "/api/v1/interfaces", nil)
req.Header.Set(testUserHeader, "bob") // viewer
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("read-only POST: code=%d, want 403", rec.Code)
}
if ran {
t.Error("handler must not run for a forbidden mutation")
}
}
func TestAccessMiddlewareReadUserAllowedToGet(t *testing.T) {
policy := access.New("", []access.GroupSpec{
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
})
var ran bool
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
req := httptest.NewRequest(http.MethodGet, "/api/v1/interfaces", nil)
req.Header.Set(testUserHeader, "bob") // viewer
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !ran {
t.Errorf("read-only GET: code=%d ran=%v, want 200/true", rec.Code, ran)
}
}
func TestAccessMiddlewareMeAlwaysReachable(t *testing.T) {
policy := access.New("", []access.GroupSpec{
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
})
var ran bool
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
// Even a mutating method on /me is allowed through (identity discovery).
req := httptest.NewRequest(http.MethodPost, "/api/v1/me", nil)
req.Header.Set(testUserHeader, "bob")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !ran {
t.Errorf("/me must always be reachable: code=%d ran=%v", rec.Code, ran)
}
}
func TestAccessMiddlewareStoresUserOnContext(t *testing.T) {
policy := access.New("", nil) // unconfigured → everyone write
var got string
h := accessMiddleware(policy, testUserHeader, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = access.UserFrom(r.Context())
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/foo", nil)
req.Header.Set(testUserHeader, "carol")
h.ServeHTTP(httptest.NewRecorder(), req)
if got != "carol" {
t.Errorf("context user = %q, want carol", got)
}
}
// -------------------------------------------------------------------------- //
// httpsRedirectHandler //
// -------------------------------------------------------------------------- //
func TestHTTPSRedirectHandler(t *testing.T) {
cases := []struct {
name string
tlsAddr string
host string
target string
want string
}{
{"default port omitted", ":443", "example.com", "/panels?x=1", "https://example.com/panels?x=1"},
{"custom port preserved", ":8443", "host.local:80", "/a", "https://host.local:8443/a"},
{"root path", "0.0.0.0:443", "h", "/", "https://h/"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
h := httpsRedirectHandler(c.tlsAddr)
req := httptest.NewRequest(http.MethodGet, c.target, nil)
req.Host = c.host
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusMovedPermanently {
t.Fatalf("code = %d, want 301", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != c.want {
t.Errorf("Location = %q, want %q", loc, c.want)
}
})
}
}
// -------------------------------------------------------------------------- //
// parseDialogTarget //
// -------------------------------------------------------------------------- //
func TestParseDialogTarget(t *testing.T) {
cases := []struct {
in string
ds, name string
ok bool
}{
{"epics:SR:CURRENT", "epics", "SR:CURRENT", true}, // splits on first ':' only
{"bareName", "srv", "bareName", true}, // defaults to srv
{" spaced ", "srv", "spaced", true},
{"", "", "", false},
{" ", "", "", false},
}
for _, c := range cases {
ds, name, ok := parseDialogTarget(c.in)
if ds != c.ds || name != c.name || ok != c.ok {
t.Errorf("parseDialogTarget(%q) = (%q,%q,%v), want (%q,%q,%v)",
c.in, ds, name, ok, c.ds, c.name, c.ok)
}
}
}
// -------------------------------------------------------------------------- //
// formatAuditValue //
// -------------------------------------------------------------------------- //
func TestFormatAuditValue(t *testing.T) {
cases := []struct {
in any
want string
}{
{"hello", "hello"},
{float64(3.5), "3.5"},
{float64(42), "42"},
{true, "true"},
{false, "false"},
{nil, ""},
{[]any{1.0, 2.0}, "[1,2]"}, // default → JSON
{map[string]any{"a": 1.0}, `{"a":1}`},
}
for _, c := range cases {
if got := formatAuditValue(c.in); got != c.want {
t.Errorf("formatAuditValue(%v) = %q, want %q", c.in, got, c.want)
}
}
}
// -------------------------------------------------------------------------- //
// clientIP //
// -------------------------------------------------------------------------- //
func TestClientIP(t *testing.T) {
t.Run("X-Forwarded-For first hop", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Forwarded-For", "203.0.113.7, 10.0.0.1")
if got := clientIP(req); got != "203.0.113.7" {
t.Errorf("got %q, want 203.0.113.7", got)
}
})
t.Run("X-Real-IP", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Real-IP", "198.51.100.4")
if got := clientIP(req); got != "198.51.100.4" {
t.Errorf("got %q, want 198.51.100.4", got)
}
})
t.Run("RemoteAddr fallback", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.9:54321"
if got := clientIP(req); got != "192.0.2.9" {
t.Errorf("got %q, want 192.0.2.9", got)
}
})
}
+5 -5
View File
@@ -25,11 +25,11 @@ import (
const apiPrefix = "/api/v1"
type Server struct {
httpServer *http.Server
tlsCert string
tlsKey string
tlsRedirect string // plain-HTTP addr that 301-redirects to HTTPS; empty = off
log *slog.Logger
httpServer *http.Server
tlsCert string
tlsKey string
tlsRedirect string // plain-HTTP addr that 301-redirects to HTTPS; empty = off
log *slog.Logger
}
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
+5 -5
View File
@@ -197,11 +197,11 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// ── wsClient ──────────────────────────────────────────────────────────────────
type wsClient struct {
conn *websocket.Conn
broker *broker.Broker
log *slog.Logger
user string // end-user identity from the trusted proxy header ("" if none)
ip string // client address, for audit attribution
conn *websocket.Conn
broker *broker.Broker
log *slog.Logger
user string // end-user identity from the trusted proxy header ("" if none)
ip string // client address, for audit attribution
policy *access.Policy // global access-level enforcement
audit audit.Recorder // signal-write audit recorder (never nil)
dialogs *DialogHub // control-logic dialog fan-out (nil if disabled)
+374
View File
@@ -0,0 +1,374 @@
package storage_test
import (
"errors"
"strings"
"testing"
"github.com/uopi/uopi/internal/storage"
)
// mkVersioned creates an interface and pushes it to version 3 by updating
// twice, returning the id. After this the store holds: current (v3) plus
// backups id.v1.xml and id.v2.xml.
func mkVersioned(t *testing.T) (*storage.Store, string) {
t.Helper()
s := newStore(t)
id, err := s.Create([]byte(`<interface id="vers" name="V1" version="1"><widget/></interface>`), "")
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := s.Update(id, []byte(`<interface id="vers" name="V2" version="1"><widget/></interface>`), "second"); err != nil {
t.Fatalf("Update→v2: %v", err)
}
if err := s.Update(id, []byte(`<interface id="vers" name="V3" version="1"><widget/></interface>`), ""); err != nil {
t.Fatalf("Update→v3: %v", err)
}
return s, id
}
// -------------------------------------------------------------------------- //
// Groups //
// -------------------------------------------------------------------------- //
func TestReadGroupsDefaultsEmptyArray(t *testing.T) {
s := newStore(t)
data, err := s.ReadGroups()
if err != nil {
t.Fatalf("ReadGroups: %v", err)
}
if string(data) != "[]" {
t.Errorf("ReadGroups default = %q, want %q", data, "[]")
}
}
func TestWriteThenReadGroups(t *testing.T) {
s := newStore(t)
want := `[{"id":"a","name":"Area A"}]`
if err := s.WriteGroups([]byte(want)); err != nil {
t.Fatalf("WriteGroups: %v", err)
}
got, err := s.ReadGroups()
if err != nil {
t.Fatalf("ReadGroups: %v", err)
}
if string(got) != want {
t.Errorf("ReadGroups = %q, want %q", got, want)
}
}
// -------------------------------------------------------------------------- //
// Versions //
// -------------------------------------------------------------------------- //
func TestVersionsNewestFirstWithCurrentFlag(t *testing.T) {
s, id := mkVersioned(t)
vs, err := s.Versions(id)
if err != nil {
t.Fatalf("Versions: %v", err)
}
if len(vs) != 3 {
t.Fatalf("expected 3 versions, got %d", len(vs))
}
if vs[0].Version != 3 || vs[1].Version != 2 || vs[2].Version != 1 {
t.Errorf("not newest-first: %d, %d, %d", vs[0].Version, vs[1].Version, vs[2].Version)
}
if !vs[0].Current {
t.Error("v3 should be flagged Current")
}
if vs[1].Current || vs[2].Current {
t.Error("backup revisions must not be flagged Current")
}
// The "second" tag was stamped on the revision that became backup v2.
if vs[1].Tag != "second" {
t.Errorf("v2 tag = %q, want %q", vs[1].Tag, "second")
}
}
func TestVersionsNotFound(t *testing.T) {
s := newStore(t)
if _, err := s.Versions("ghost"); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Versions missing: want ErrNotFound, got %v", err)
}
}
func TestVersionsInvalidID(t *testing.T) {
s := newStore(t)
if _, err := s.Versions("../x"); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Versions bad ID: want ErrNotFound, got %v", err)
}
}
// -------------------------------------------------------------------------- //
// GetVersion //
// -------------------------------------------------------------------------- //
func TestGetVersionCurrentAndBackup(t *testing.T) {
s, id := mkVersioned(t)
cur, err := s.GetVersion(id, 3)
if err != nil {
t.Fatalf("GetVersion(3): %v", err)
}
if !strings.Contains(string(cur), `name="V3"`) {
t.Errorf("GetVersion(3) wrong content: %s", cur)
}
old, err := s.GetVersion(id, 1)
if err != nil {
t.Fatalf("GetVersion(1): %v", err)
}
if !strings.Contains(string(old), `name="V1"`) {
t.Errorf("GetVersion(1) wrong content: %s", old)
}
}
func TestGetVersionMissingRevision(t *testing.T) {
s, id := mkVersioned(t)
if _, err := s.GetVersion(id, 99); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("GetVersion(99): want ErrNotFound, got %v", err)
}
}
func TestGetVersionMissingInterface(t *testing.T) {
s := newStore(t)
if _, err := s.GetVersion("ghost", 1); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("GetVersion missing iface: want ErrNotFound, got %v", err)
}
}
// -------------------------------------------------------------------------- //
// SetVersionTag //
// -------------------------------------------------------------------------- //
func TestSetVersionTagOnBackupAndClear(t *testing.T) {
s, id := mkVersioned(t)
// Tag a backup revision in place (no new revision created).
if err := s.SetVersionTag(id, 1, "milestone"); err != nil {
t.Fatalf("SetVersionTag(1): %v", err)
}
vs, _ := s.Versions(id)
if len(vs) != 3 {
t.Fatalf("tagging must not add a revision: got %d", len(vs))
}
var v1 *storage.VersionMeta
for i := range vs {
if vs[i].Version == 1 {
v1 = &vs[i]
}
}
if v1 == nil || v1.Tag != "milestone" {
t.Fatalf("v1 tag not set: %+v", v1)
}
// Clearing the tag.
if err := s.SetVersionTag(id, 1, ""); err != nil {
t.Fatalf("SetVersionTag clear: %v", err)
}
vs, _ = s.Versions(id)
for _, v := range vs {
if v.Version == 1 && v.Tag != "" {
t.Errorf("v1 tag should be cleared, got %q", v.Tag)
}
}
}
func TestSetVersionTagCurrent(t *testing.T) {
s, id := mkVersioned(t)
if err := s.SetVersionTag(id, 3, "live"); err != nil {
t.Fatalf("SetVersionTag(3): %v", err)
}
vs, _ := s.Versions(id)
if vs[0].Version != 3 || vs[0].Tag != "live" {
t.Errorf("current tag not applied: %+v", vs[0])
}
}
func TestSetVersionTagMissing(t *testing.T) {
s, id := mkVersioned(t)
if err := s.SetVersionTag(id, 99, "x"); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("SetVersionTag missing rev: want ErrNotFound, got %v", err)
}
if err := s.SetVersionTag("../bad", 1, "x"); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("SetVersionTag bad ID: want ErrNotFound, got %v", err)
}
}
// -------------------------------------------------------------------------- //
// Promote //
// -------------------------------------------------------------------------- //
func TestPromoteIsNonDestructive(t *testing.T) {
s, id := mkVersioned(t)
// Promote v1 — its content becomes the new current revision (v4), while
// every prior revision is preserved.
if err := s.Promote(id, 1); err != nil {
t.Fatalf("Promote(1): %v", err)
}
vs, err := s.Versions(id)
if err != nil {
t.Fatalf("Versions after promote: %v", err)
}
if len(vs) != 4 {
t.Fatalf("expected 4 versions after promote, got %d", len(vs))
}
if vs[0].Version != 4 || !vs[0].Current {
t.Errorf("new current should be v4: %+v", vs[0])
}
if vs[0].Name != "V1" {
t.Errorf("promoted content should be V1's, got name %q", vs[0].Name)
}
if vs[0].Tag != "restored from v1" {
t.Errorf("promote tag = %q", vs[0].Tag)
}
}
func TestPromoteMissing(t *testing.T) {
s, id := mkVersioned(t)
if err := s.Promote(id, 99); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Promote missing rev: want ErrNotFound, got %v", err)
}
}
// -------------------------------------------------------------------------- //
// Fork //
// -------------------------------------------------------------------------- //
func TestForkResetsVersionAndClearsTag(t *testing.T) {
s, id := mkVersioned(t)
if err := s.SetVersionTag(id, 2, "tagged"); err != nil {
t.Fatalf("SetVersionTag: %v", err)
}
newID, err := s.Fork(id, 2)
if err != nil {
t.Fatalf("Fork(2): %v", err)
}
if newID == id {
t.Fatal("Fork must produce a distinct ID")
}
data, err := s.Get(newID)
if err != nil {
t.Fatalf("Get(fork): %v", err)
}
str := string(data)
if !strings.Contains(str, `name="V2"`) {
t.Errorf("fork should carry V2 content: %s", str)
}
if !strings.Contains(str, `version="1"`) {
t.Errorf("fork version should reset to 1: %s", str)
}
if !strings.Contains(str, `id="`+newID+`"`) {
t.Errorf("fork id attr should be stamped: %s", str)
}
if strings.Contains(str, "tagged") {
t.Errorf("fork should clear the tag: %s", str)
}
// The fork is an independent, listable interface at v1.
vs, err := s.Versions(newID)
if err != nil {
t.Fatalf("Versions(fork): %v", err)
}
if len(vs) != 1 || vs[0].Version != 1 {
t.Errorf("fork should have a single v1 revision, got %+v", vs)
}
}
func TestForkMissing(t *testing.T) {
s := newStore(t)
if _, err := s.Fork("ghost", 1); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Fork missing: want ErrNotFound, got %v", err)
}
}
// -------------------------------------------------------------------------- //
// Create: slug derivation + attribute stamping //
// -------------------------------------------------------------------------- //
func TestCreateSlugifiesNameWhenNoID(t *testing.T) {
s := newStore(t)
id, err := s.Create([]byte(`<interface name="My Cool Panel!" version="1"/>`), "")
if err != nil {
t.Fatalf("Create: %v", err)
}
if id != "my-cool-panel" {
t.Errorf("slug id = %q, want %q", id, "my-cool-panel")
}
}
func TestCreateEmptyNameFallsBackToInterface(t *testing.T) {
s := newStore(t)
id, err := s.Create([]byte(`<interface name="" version="1"/>`), "")
if err != nil {
t.Fatalf("Create: %v", err)
}
if id != "interface" {
t.Errorf("fallback id = %q, want %q", id, "interface")
}
}
func TestCreateStampsMissingVersionAndID(t *testing.T) {
s := newStore(t)
// XML carries neither id nor version attributes — Create must insert both.
id, err := s.Create([]byte(`<interface name="Stamp Me"><widget/></interface>`), "label")
if err != nil {
t.Fatalf("Create: %v", err)
}
data, err := s.Get(id)
if err != nil {
t.Fatalf("Get: %v", err)
}
str := string(data)
if !strings.Contains(str, `version="1"`) {
t.Errorf("Create should stamp version=1: %s", str)
}
if !strings.Contains(str, `id="`+id+`"`) {
t.Errorf("Create should stamp id: %s", str)
}
if !strings.Contains(str, `tag="label"`) {
t.Errorf("Create should stamp tag: %s", str)
}
}
// -------------------------------------------------------------------------- //
// Update tagging + backup chain //
// -------------------------------------------------------------------------- //
func TestDeleteMovesVersionedBackups(t *testing.T) {
s, id := mkVersioned(t) // current + id.v1.xml + id.v2.xml
if err := s.Delete(id); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := s.Get(id); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Get after delete: want ErrNotFound, got %v", err)
}
if _, err := s.Versions(id); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Versions after delete: want ErrNotFound, got %v", err)
}
}
func TestListSkipsVersionedBackups(t *testing.T) {
s, _ := mkVersioned(t) // leaves id.v1.xml and id.v2.xml on disk
list, err := s.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 1 {
t.Fatalf("List should skip versioned backups, got %d entries: %+v", len(list), list)
}
}
func TestUpdateStampsIncrementingVersion(t *testing.T) {
s, id := mkVersioned(t)
data, err := s.Get(id)
if err != nil {
t.Fatalf("Get: %v", err)
}
if !strings.Contains(string(data), `version="3"`) {
t.Errorf("current should be version 3 after two updates: %s", data)
}
}