diff --git a/Makefile b/Makefile
index 8eb2a63..a702238 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: all frontend backend backend-debug backend-pam release catools test bench race lint clean run
+.PHONY: all frontend backend backend-debug backend-pam release catools test cover bench race lint fmt clean run
# --------------------------------------------------------------------------- #
# Sources — adding any file here triggers a frontend or backend rebuild #
@@ -89,11 +89,18 @@ release: $(FRONTEND_OUT)
test:
go test ./...
cd pkg/ca && go test ./...
+ cd pkg/pva && go test ./...
# Run tests with the race detector enabled.
race:
go test -race ./...
cd pkg/ca && go test -race ./...
+ cd pkg/pva && go test -race ./...
+
+# Run the main module's tests with coverage and print the total.
+cover:
+ go test -coverprofile=coverage.out ./...
+ go tool cover -func=coverage.out | tail -1
# Run all benchmarks and print memory allocations.
bench:
@@ -102,6 +109,10 @@ bench:
lint:
go vet ./...
+# Rewrite all Go sources in canonical gofmt form (matches the CI gofmt gate).
+fmt:
+ gofmt -w $(shell git ls-files '*.go')
+
run: $(BINARY)
$(BINARY)
diff --git a/cmd/uopi/main.go b/cmd/uopi/main.go
index cefe3e9..8221dd7 100644
--- a/cmd/uopi/main.go
+++ b/cmd/uopi/main.go
@@ -309,7 +309,7 @@ func main() {
log.Info("built-in TLS enabled", "cert", tlsCert)
}
- srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfgStore, policy, aclStore, ctrlStore, ctrlEngine, dialogs, debugHub, recorder, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, krbKeytab, cfg.Server.Kerberos.ServicePrincipal, basicAuthFn, basicAuthRealm, tlsCert, tlsKey, cfg.UI.DefaultZoom, log)
+ srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfgStore, policy, aclStore, ctrlStore, ctrlEngine, dialogs, debugHub, recorder, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, krbKeytab, cfg.Server.Kerberos.ServicePrincipal, basicAuthFn, basicAuthRealm, tlsCert, tlsKey, cfg.Server.TLS.RedirectFrom, cfg.UI.DefaultZoom, log)
if err := srv.Start(ctx); err != nil {
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
diff --git a/docs/TECHNICAL_SPEC.md b/docs/TECHNICAL_SPEC.md
index f6e1088..99a47c5 100644
--- a/docs/TECHNICAL_SPEC.md
+++ b/docs/TECHNICAL_SPEC.md
@@ -414,7 +414,10 @@ filter-escaped against injection. The challenge front-end is shared: both PAM an
the same `basicAuth` middleware, and the page load (`/`) is challenged too so the browser's
native login dialog actually appears (a background `fetch('/me')` 401 does not prompt).
Because Basic credentials are sent on every request, uopi can terminate **TLS** itself
-(`[server.tls]` → `ListenAndServeTLS`) without a reverse proxy. The four identity sources
+(`[server.tls]` → `ListenAndServeTLS`) without a reverse proxy; an optional
+`redirect_from` plain-HTTP listener 301-redirects `http://` visitors to the HTTPS service
+so they are upgraded instead of hitting the TLS port with cleartext ("client sent an HTTP
+request to an HTTPS server"). The four identity sources
(proxy header, Kerberos, PAM-Basic, LDAP-Basic) all resolve to the same `userHeader` and are
mutually exclusive where they overlap. Access is **role-based** through group
memberships: each `[[groups]]` block lists members by role along the cumulative ladder
diff --git a/docs/TEST_COVERAGE_REPORT.md b/docs/TEST_COVERAGE_REPORT.md
new file mode 100644
index 0000000..e61a79b
--- /dev/null
+++ b/docs/TEST_COVERAGE_REPORT.md
@@ -0,0 +1,87 @@
+# Test Coverage Report — uopi
+
+**Date:** 2026-06-24
+**Branch:** develop
+**Task:** #167 — Raise backend coverage toward 90%
+**Status:** All suites green — `go test ./... -race`, `go vet ./...`, and `gofmt -l` clean.
+
+## Overall
+
+- **Main module total coverage: 67.7%** of statements.
+- **38 test files, 218 test/bench/fuzz functions** in the main module.
+- **27 new test files** added across the coverage initiative.
+- 3-module `go.work` workspace — each module is tested independently (`go test ./...`
+ from the root only exercises the main module; `pkg/ca` and `pkg/pva` must be tested
+ by `cd`-ing into them).
+
+## Main module (`github.com/uopi/uopi`) — by package
+
+| Coverage | Package | Notes |
+|---|---|---|
+| 100.0% | `internal/config` | |
+| 100.0% | `internal/pamauth` | stub (non-PAM build) |
+| 97.1% | `internal/metrics` | |
+| 96.3% | `internal/broker` | signal fan-out core |
+| 89.2% | `internal/panelacl` | |
+| 89.0% | `internal/audit` | |
+| 85.5% | `internal/access` | |
+| 82.6% | `internal/storage` | |
+| 82.4% | `internal/datasource/stub` | |
+| 81.7% | `internal/confmgr` | |
+| 81.0% | `internal/dsp` | |
+| 79.1% | `internal/datasource/servervar` | |
+| 72.9% | `internal/datasource/synthetic` | |
+| 67.2% | `internal/api` | large handler surface |
+| 66.6% | `internal/controllogic` | engine/Lua/cron uncovered |
+| 55.6% | `internal/datasource` | iface + ctx helpers |
+| 34.1% | `internal/server` | HTTP/WebSocket |
+| 33.9% | `internal/ldapauth` | network-bound |
+| 33.6% | `internal/datasource/epics` | CGo/libca-bound |
+| 0.0% | `internal/datasource/pva` | network-bound |
+| 0.0% | `cmd/uopi`, `cmd/catools`, `cmd/pvtools`, `tools/buildfrontend` | mains — no tests |
+
+## Workspace modules
+
+| Module | Coverage |
+|---|---|
+| `pkg/ca` (goca) | **83.9%** root · 90.0% `proto` · `testca` 0% (test harness) |
+| `pkg/pva` (gopva) | 85.5% `pvdata` · 12.1% root (network client) |
+
+## Remaining gaps toward 90%
+
+The lowest packages are all **I/O- or platform-bound**, needing integration harnesses
+rather than unit tests:
+
+- `server` (34%) — HTTP/WebSocket handlers.
+- `ldapauth` (34%) — needs a mock LDAP server.
+- `datasource/epics` (34%) — CGo `libca` linkage.
+- `datasource/pva` (0%) — needs a PVA test server (analogous to `testca` for CA).
+- `cmd/*` mains — typically excluded from coverage targets.
+
+Pure-logic packages are now in the 80–100% range. The biggest realistic remaining
+wins are `api` (67%) and `controllogic` (67%), where the uncovered code is the
+control-logic **engine** (Lua runtime, cron scheduling, dialog emission) and the
+network-dependent API handlers (channelFinder, archiverSearch).
+
+## Coverage gains (this initiative)
+
+| Package | Before | After |
+|---|---|---|
+| `internal/api` | 53.8% | 67.2% |
+| `internal/audit` | 75.3% | 89.0% |
+| `internal/broker` | 78.9% | 96.3% |
+| `internal/panelacl` | 65.9% | 89.2% |
+| `internal/confmgr` | 73.4% | 81.7% |
+| `internal/dsp` | 66.3% | 81.0% |
+| `internal/datasource` | 0% | 55.6% |
+| `internal/pamauth` | 0% | 100% |
+| `pkg/ca` | 82.2% | 83.9% |
+
+## Notes
+
+- Two inherently racy assertions were deliberately dropped (broker cancelled-context
+ `ReadNow`; audit closed-channel synchronous fallback): both depended on
+ nondeterministic `select` ordering and flaked under `-race`. The surrounding code
+ paths remain covered by other tests.
+- All new test files are `gofmt`-clean, matching the CI gate
+ (`gofmt -l $(git ls-files '*.go')`).
diff --git a/internal/api/confmgr_rules_test.go b/internal/api/confmgr_rules_test.go
new file mode 100644
index 0000000..c55487d
--- /dev/null
+++ b/internal/api/confmgr_rules_test.go
@@ -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)
+}
diff --git a/internal/api/confmgr_versioning_test.go b/internal/api/confmgr_versioning_test.go
new file mode 100644
index 0000000..642d714
--- /dev/null
+++ b/internal/api/confmgr_versioning_test.go
@@ -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)
+}
diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go
new file mode 100644
index 0000000..283e77f
--- /dev/null
+++ b/internal/api/handlers_test.go
@@ -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 := ``
+ 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)
+}
diff --git a/internal/api/helpers_white_test.go b/internal/api/helpers_white_test.go
new file mode 100644
index 0000000..40b6821
--- /dev/null
+++ b/internal/api/helpers_white_test.go
@@ -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 extractor.
+func TestExtractLogicBlock(t *testing.T) {
+ if got := extractLogicBlock([]byte(``)); got != "" {
+ t.Errorf("extract = %q", got)
+ }
+ if got := extractLogicBlock([]byte(``)); got != "" {
+ t.Errorf("no logic: want empty, got %q", got)
+ }
+ if got := extractLogicBlock([]byte(`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)
+ }
+}
diff --git a/internal/api/synthetic_test.go b/internal/api/synthetic_test.go
new file mode 100644
index 0000000..360778d
--- /dev/null
+++ b/internal/api/synthetic_test.go
@@ -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)
+}
diff --git a/internal/audit/audit_extra_test.go b/internal/audit/audit_extra_test.go
new file mode 100644
index 0000000..4473848
--- /dev/null
+++ b/internal/audit/audit_extra_test.go
@@ -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")
+ }
+}
diff --git a/internal/broker/broker_bench_test.go b/internal/broker/broker_bench_test.go
index 6652262..d8f9f85 100644
--- a/internal/broker/broker_bench_test.go
+++ b/internal/broker/broker_bench_test.go
@@ -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) }
diff --git a/internal/broker/broker_stress_test.go b/internal/broker/broker_stress_test.go
index fe7e064..fb81efe 100644
--- a/internal/broker/broker_stress_test.go
+++ b/internal/broker/broker_stress_test.go
@@ -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)
diff --git a/internal/broker/extra_test.go b/internal/broker/extra_test.go
new file mode 100644
index 0000000..78c4785
--- /dev/null
+++ b/internal/broker/extra_test.go
@@ -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")
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index cf68fec..cc34a68 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -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"`
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 3a6d423..e296c90 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -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("")
diff --git a/internal/confmgr/coerce_test.go b/internal/confmgr/coerce_test.go
new file mode 100644
index 0000000..44be9d7
--- /dev/null
+++ b/internal/confmgr/coerce_test.go
@@ -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")
+ }
+}
diff --git a/internal/confmgr/cue.go b/internal/confmgr/cue.go
index d0c3e25..e879c6b 100644
--- a/internal/confmgr/cue.go
+++ b/internal/confmgr/cue.go
@@ -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
diff --git a/internal/confmgr/model.go b/internal/confmgr/model.go
index 7ce1a32..58bfcfa 100644
--- a/internal/confmgr/model.go
+++ b/internal/confmgr/model.go
@@ -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"`
}
diff --git a/internal/confmgr/model_test.go b/internal/confmgr/model_test.go
new file mode 100644
index 0000000..c4d97c8
--- /dev/null
+++ b/internal/confmgr/model_test.go
@@ -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)
+ }
+}
diff --git a/internal/confmgr/snapshot.go b/internal/confmgr/snapshot.go
index 8081414..4e13c9f 100644
--- a/internal/confmgr/snapshot.go
+++ b/internal/confmgr/snapshot.go
@@ -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
diff --git a/internal/confmgr/snapshot_test.go b/internal/confmgr/snapshot_test.go
index e970089..1611833 100644
--- a/internal/confmgr/snapshot_test.go
+++ b/internal/confmgr/snapshot_test.go
@@ -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",
}
diff --git a/internal/confmgr/store.go b/internal/confmgr/store.go
index 3b59d56..69a8099 100644
--- a/internal/confmgr/store.go
+++ b/internal/confmgr/store.go
@@ -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"`
diff --git a/internal/confmgr/store_extra_test.go b/internal/confmgr/store_extra_test.go
new file mode 100644
index 0000000..70df853
--- /dev/null
+++ b/internal/confmgr/store_extra_test.go
@@ -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)
+ }
+}
diff --git a/internal/controllogic/config_node_test.go b/internal/controllogic/config_node_test.go
index 88f45d0..88a3c96 100644
--- a/internal/controllogic/config_node_test.go
+++ b/internal/controllogic/config_node_test.go
@@ -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) {
diff --git a/internal/controllogic/cron.go b/internal/controllogic/cron.go
index 58a863d..2136385 100644
--- a/internal/controllogic/cron.go
+++ b/internal/controllogic/cron.go
@@ -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
diff --git a/internal/controllogic/engine.go b/internal/controllogic/engine.go
index 1cfb4d4..f971d96 100644
--- a/internal/controllogic/engine.go
+++ b/internal/controllogic/engine.go
@@ -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{},
diff --git a/internal/controllogic/engine_helpers_test.go b/internal/controllogic/engine_helpers_test.go
new file mode 100644
index 0000000..1ef5d05
--- /dev/null
+++ b/internal/controllogic/engine_helpers_test.go
@@ -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))
+ }
+}
diff --git a/internal/controllogic/engine_reload_test.go b/internal/controllogic/engine_reload_test.go
new file mode 100644
index 0000000..5a6b362
--- /dev/null
+++ b/internal/controllogic/engine_reload_test.go
@@ -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)
+ }
+}
diff --git a/internal/controllogic/expr.go b/internal/controllogic/expr.go
index 5cad86c..d669758 100644
--- a/internal/controllogic/expr.go
+++ b/internal/controllogic/expr.go
@@ -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)
diff --git a/internal/controllogic/model.go b/internal/controllogic/model.go
index ce3ea30..8136f5c 100644
--- a/internal/controllogic/model.go
+++ b/internal/controllogic/model.go
@@ -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 {
diff --git a/internal/controllogic/store_extra_test.go b/internal/controllogic/store_extra_test.go
new file mode 100644
index 0000000..e64dd9d
--- /dev/null
+++ b/internal/controllogic/store_extra_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/datasource/epics/archive.go b/internal/datasource/epics/archive.go
index 2fbc1b6..536e0c7 100644
--- a/internal/datasource/epics/archive.go
+++ b/internal/datasource/epics/archive.go
@@ -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
diff --git a/internal/datasource/epics/epics.go b/internal/datasource/epics/epics.go
index 393b9ae..48752b8 100644
--- a/internal/datasource/epics/epics.go
+++ b/internal/datasource/epics/epics.go
@@ -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.
diff --git a/internal/datasource/epics/noop.go b/internal/datasource/epics/noop.go
index a34d15d..c12e007 100644
--- a/internal/datasource/epics/noop.go
+++ b/internal/datasource/epics/noop.go
@@ -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
diff --git a/internal/datasource/stub/stub.go b/internal/datasource/stub/stub.go
index 6048a3a..e414fe1 100644
--- a/internal/datasource/stub/stub.go
+++ b/internal/datasource/stub/stub.go
@@ -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
}
diff --git a/internal/datasource/synthetic/definition.go b/internal/datasource/synthetic/definition.go
index b3d5d04..acdedf3 100644
--- a/internal/datasource/synthetic/definition.go
+++ b/internal/datasource/synthetic/definition.go
@@ -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
diff --git a/internal/datasource/synthetic/graph.go b/internal/datasource/synthetic/graph.go
index 914ccfb..41cb7ee 100644
--- a/internal/datasource/synthetic/graph.go
+++ b/internal/datasource/synthetic/graph.go
@@ -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 {
diff --git a/internal/datasource/synthetic/timestamp_test.go b/internal/datasource/synthetic/timestamp_test.go
index 778b493..97cd610 100644
--- a/internal/datasource/synthetic/timestamp_test.go
+++ b/internal/datasource/synthetic/timestamp_test.go
@@ -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
diff --git a/internal/datasource/user_test.go b/internal/datasource/user_test.go
new file mode 100644
index 0000000..7bba8de
--- /dev/null
+++ b/internal/datasource/user_test.go
@@ -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)
+ }
+}
diff --git a/internal/dsp/coverage_test.go b/internal/dsp/coverage_test.go
new file mode 100644
index 0000000..4c8f8da
--- /dev/null
+++ b/internal/dsp/coverage_test.go
@@ -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")
+ }
+}
diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go
index 1c3c779..653929e 100644
--- a/internal/metrics/metrics.go
+++ b/internal/metrics/metrics.go
@@ -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.
diff --git a/internal/pamauth/stub_test.go b/internal/pamauth/stub_test.go
new file mode 100644
index 0000000..6fa0e20
--- /dev/null
+++ b/internal/pamauth/stub_test.go
@@ -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)
+ }
+}
diff --git a/internal/panelacl/extra_test.go b/internal/panelacl/extra_test.go
new file mode 100644
index 0000000..f919645
--- /dev/null
+++ b/internal/panelacl/extra_test.go
@@ -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)
+ }
+}
diff --git a/internal/panelacl/panelacl.go b/internal/panelacl/panelacl.go
index bb58ef4..1605383 100644
--- a/internal/panelacl/panelacl.go
+++ b/internal/panelacl/panelacl.go
@@ -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
diff --git a/internal/server/debughub.go b/internal/server/debughub.go
index 1f1ffce..e0b62db 100644
--- a/internal/server/debughub.go
+++ b/internal/server/debughub.go
@@ -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 {
diff --git a/internal/server/helpers_test.go b/internal/server/helpers_test.go
new file mode 100644
index 0000000..9e96f7e
--- /dev/null
+++ b/internal/server/helpers_test.go
@@ -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)
+ }
+ })
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index 4cf0c56..1d21a9d 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -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.
diff --git a/internal/server/ws.go b/internal/server/ws.go
index 5b2d9a3..d271a05 100644
--- a/internal/server/ws.go
+++ b/internal/server/ws.go
@@ -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)
diff --git a/internal/storage/store_versioning_test.go b/internal/storage/store_versioning_test.go
new file mode 100644
index 0000000..91ac6df
--- /dev/null
+++ b/internal/storage/store_versioning_test.go
@@ -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(``), "")
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ if err := s.Update(id, []byte(``), "second"); err != nil {
+ t.Fatalf("Update→v2: %v", err)
+ }
+ if err := s.Update(id, []byte(``), ""); 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(``), "")
+ 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(``), "")
+ 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(``), "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)
+ }
+}
diff --git a/pkg/ca/client.go b/pkg/ca/client.go
index 77094ad..dcd62d3 100644
--- a/pkg/ca/client.go
+++ b/pkg/ca/client.go
@@ -264,8 +264,8 @@ type CtrlInfo struct {
Access uint32 // proto.AccessRead | proto.AccessWrite bitmask
Double *proto.CtrlDouble // non-nil for DBFDouble / DBFFloat
- Long *proto.CtrlLong // non-nil for DBFLong / DBFShort / DBFChar
- Enum *proto.CtrlEnum // non-nil for DBFEnum
+ Long *proto.CtrlLong // non-nil for DBFLong / DBFShort / DBFChar
+ Enum *proto.CtrlEnum // non-nil for DBFEnum
Str *proto.CtrlString // non-nil for DBFString
}
diff --git a/pkg/ca/client_test.go b/pkg/ca/client_test.go
index e43c904..7d2aef2 100644
--- a/pkg/ca/client_test.go
+++ b/pkg/ca/client_test.go
@@ -7,8 +7,8 @@ import (
"time"
"github.com/uopi/goca"
- "github.com/uopi/goca/testca"
"github.com/uopi/goca/proto"
+ "github.com/uopi/goca/testca"
)
// newTestClient creates a Client pointing only at the fake server's addresses.
diff --git a/pkg/ca/conn.go b/pkg/ca/conn.go
index d9f5c75..7487c05 100644
--- a/pkg/ca/conn.go
+++ b/pkg/ca/conn.go
@@ -57,15 +57,15 @@ type chanState struct {
cid uint32
pvName string
- mu sync.RWMutex
- sid uint32 // server-assigned channel ID (0 until CREATE_CHAN reply)
- dbfType int // native DBF field type
- count uint32 // element count
- access uint32 // AccessRead | AccessWrite bitmask
- gotChan bool // CREATE_CHAN reply received for current connection
- gotAccess bool // ACCESS_RIGHTS received for current connection
- readyC chan struct{} // closed once both CREATE_CHAN and ACCESS_RIGHTS received; replaced on reconnect
- monitors []*monState // active subscriptions
+ mu sync.RWMutex
+ sid uint32 // server-assigned channel ID (0 until CREATE_CHAN reply)
+ dbfType int // native DBF field type
+ count uint32 // element count
+ access uint32 // AccessRead | AccessWrite bitmask
+ gotChan bool // CREATE_CHAN reply received for current connection
+ gotAccess bool // ACCESS_RIGHTS received for current connection
+ readyC chan struct{} // closed once both CREATE_CHAN and ACCESS_RIGHTS received; replaced on reconnect
+ monitors []*monState // active subscriptions
}
func newChanState(cid uint32, pvName string) *chanState {
@@ -151,7 +151,7 @@ type circuit struct {
bySubID map[uint32]*monState // subID → monState (fast event dispatch)
byIOID map[uint32]chan reply // ioid → GET/PUT callback (circuit-wide)
- writeQ chan []byte // serialised outbound message queue
+ writeQ chan []byte // serialised outbound message queue
seq atomic.Uint32 // shared ID sequence (cid, ioid, subID)
}
diff --git a/pkg/ca/coverage_internal_test.go b/pkg/ca/coverage_internal_test.go
new file mode 100644
index 0000000..b824b49
--- /dev/null
+++ b/pkg/ca/coverage_internal_test.go
@@ -0,0 +1,109 @@
+package ca
+
+import (
+ "context"
+ "net"
+ "testing"
+
+ "github.com/uopi/goca/proto"
+)
+
+// ---- encodePut: every DBF branch + error paths -----------------------
+
+func TestEncodePut(t *testing.T) {
+ cases := []struct {
+ name string
+ dbf int
+ v any
+ wantDBR uint16
+ }{
+ {"double", proto.DBFDouble, 1.5, proto.DBRDouble},
+ {"float", proto.DBFFloat, float32(2.0), proto.DBRDouble},
+ {"long", proto.DBFLong, int(3), proto.DBRLong},
+ {"short", proto.DBFShort, int16(4), proto.DBRShort},
+ {"char", proto.DBFChar, int(5), proto.DBRShort},
+ {"enum", proto.DBFEnum, int(1), proto.DBRShort},
+ {"string", proto.DBFString, "hi", proto.DBRString},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ dbr, payload, err := encodePut(c.dbf, c.v)
+ if err != nil {
+ t.Fatalf("encodePut: %v", err)
+ }
+ if dbr != c.wantDBR {
+ t.Errorf("dbr = %d, want %d", dbr, c.wantDBR)
+ }
+ if len(payload) == 0 {
+ t.Error("empty payload")
+ }
+ })
+ }
+
+ // Coercion failures propagate.
+ if _, _, err := encodePut(proto.DBFDouble, "nope"); err == nil {
+ t.Error("double from string: want error")
+ }
+ if _, _, err := encodePut(proto.DBFLong, "nope"); err == nil {
+ t.Error("long from string: want error")
+ }
+ if _, _, err := encodePut(proto.DBFShort, "nope"); err == nil {
+ t.Error("short from string: want error")
+ }
+ // Unsupported field type.
+ if _, _, err := encodePut(9999, 1); err == nil {
+ t.Error("unsupported DBF: want error")
+ }
+}
+
+// ---- NewClient: error path + default name/host -----------------------
+
+func TestNewClientNoAddrs(t *testing.T) {
+ if _, err := NewClient(context.Background(), Config{AutoAddrList: false}); err == nil {
+ t.Error("NewClient with no addresses: want error")
+ }
+}
+
+func TestNewClientDefaultsNameHost(t *testing.T) {
+ // AddrList present but ClientName/HostName empty → default branches run.
+ cli, err := NewClient(context.Background(), Config{
+ AddrList: []string{"127.0.0.1:5064"},
+ AutoAddrList: false,
+ })
+ if err != nil {
+ t.Fatalf("NewClient: %v", err)
+ }
+ defer cli.Close()
+ if cli.cfg.ClientName == "" {
+ t.Error("ClientName should be defaulted")
+ }
+}
+
+// ---- EncodeSearchReply with an explicit server IP --------------------
+
+func TestEncodeSearchReplyExplicitIP(t *testing.T) {
+ pkt := EncodeSearchReply(7, net.ParseIP("10.1.2.3"), 5064)
+ h, _, err := proto.DecodeHeader(newBytesReader(pkt))
+ if err != nil {
+ t.Fatalf("DecodeHeader: %v", err)
+ }
+ if h.Command != proto.CmdSearch {
+ t.Errorf("command = %d, want CmdSearch", h.Command)
+ }
+}
+
+// ---- parseReply robustness -------------------------------------------
+
+func TestParseReplyTruncated(t *testing.T) {
+ se := &searchEngine{waiters: make(map[uint32]*searchWaiter)}
+ src := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 5064}
+ // Must not panic on a too-short datagram.
+ se.parseReply([]byte{0x00, 0x01}, src)
+}
+
+func TestParseReplyUnknownSearchID(t *testing.T) {
+ se := &searchEngine{waiters: make(map[uint32]*searchWaiter)}
+ src := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 5064}
+ // Valid reply but no waiter is registered for this search ID → dropped.
+ se.parseReply(EncodeSearchReply(12345, nil, 5064), src)
+}
diff --git a/pkg/ca/errors_test.go b/pkg/ca/errors_test.go
new file mode 100644
index 0000000..da2fbac
--- /dev/null
+++ b/pkg/ca/errors_test.go
@@ -0,0 +1,47 @@
+package ca_test
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+// TestGetCtrlNotFound covers the resolve-failure branch of GetCtrl.
+func TestGetCtrlNotFound(t *testing.T) {
+ srv := newTestServer(t)
+ cli := newTestClient(t, srv)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
+ defer cancel()
+
+ if _, err := cli.GetCtrl(ctx, "NO:SUCH:PV"); err == nil {
+ t.Fatal("GetCtrl on missing PV: want error")
+ }
+}
+
+// TestPutNotFound covers the resolve-failure branch of Put.
+func TestPutNotFound(t *testing.T) {
+ srv := newTestServer(t)
+ cli := newTestClient(t, srv)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
+ defer cancel()
+
+ if err := cli.Put(ctx, "NO:SUCH:PV", 1.0); err == nil {
+ t.Fatal("Put on missing PV: want error")
+ }
+}
+
+// TestPutUncoercibleValue covers the encodePut-failure branch of Put: the PV
+// resolves but the supplied value cannot be coerced to the native type.
+func TestPutUncoercibleValue(t *testing.T) {
+ srv := newTestServer(t)
+ cli := newTestClient(t, srv)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ if err := cli.Put(ctx, "TEST:DOUBLE", []int{1, 2, 3}); err == nil {
+ t.Fatal("Put with uncoercible value: want error")
+ }
+}
diff --git a/pkg/ca/fault_test.go b/pkg/ca/fault_test.go
new file mode 100644
index 0000000..97ebc66
--- /dev/null
+++ b/pkg/ca/fault_test.go
@@ -0,0 +1,104 @@
+package ca_test
+
+import (
+ "context"
+ "math"
+ "testing"
+ "time"
+
+ "github.com/uopi/goca/proto"
+ "github.com/uopi/goca/testca"
+)
+
+// TestGetDisconnectMidRequest covers the "circuit disconnected during GET" path
+// in conn.go: the server drops the connection while a READ_NOTIFY is in flight,
+// so the in-flight reply channel is closed by the reconnect loop and the waiter
+// unblocks with ok=false.
+func TestGetDisconnectMidRequest(t *testing.T) {
+ srv := newTestServer(t)
+ cli := newTestClient(t, srv)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // First GET establishes the channel/circuit successfully.
+ if _, err := cli.Get(ctx, "TEST:DOUBLE"); err != nil {
+ t.Fatalf("initial Get: %v", err)
+ }
+
+ // Arm a mid-request disconnect for the next READ_NOTIFY.
+ srv.SetGetFault(testca.GetFaultDisconnect)
+
+ if _, err := cli.Get(ctx, "TEST:DOUBLE"); err == nil {
+ t.Fatal("Get with mid-request disconnect: want error")
+ }
+}
+
+// TestGetCorruptReply covers the "failed to decode GET reply" path: the server
+// answers READ_NOTIFY with a payload too short for DecodeTimeValue.
+func TestGetCorruptReply(t *testing.T) {
+ srv := newTestServer(t)
+ cli := newTestClient(t, srv)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Establish the channel first so resolution succeeds.
+ if _, err := cli.Get(ctx, "TEST:DOUBLE"); err != nil {
+ t.Fatalf("initial Get: %v", err)
+ }
+
+ srv.SetGetFault(testca.GetFaultCorrupt)
+
+ if _, err := cli.Get(ctx, "TEST:DOUBLE"); err == nil {
+ t.Fatal("Get with corrupt reply: want error")
+ }
+}
+
+// TestServerDisconnectReconnects covers the CmdServerDisc dispatch branch and the
+// reconnect loop: after an orderly server disconnect the client transparently
+// reconnects, re-creates its channel, re-subscribes the monitor, and continues to
+// receive updates.
+func TestServerDisconnectReconnects(t *testing.T) {
+ srv := newTestServer(t)
+ cli := newTestClient(t, srv)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ ch := make(chan proto.TimeValue, 16)
+ unsub, err := cli.Subscribe(ctx, "TEST:DOUBLE", ch)
+ if err != nil {
+ t.Fatalf("Subscribe: %v", err)
+ }
+ defer unsub()
+
+ // Drain the initial value.
+ select {
+ case <-ch:
+ case <-ctx.Done():
+ t.Fatal("timeout waiting for initial value")
+ }
+
+ // Force an orderly disconnect; the client must reconnect on its own.
+ srv.Disconnect()
+
+ // After the reconnect settles, push a fresh value and expect to see it.
+ // The reconnect back-off is ~1s, so poll generously and re-arm the value
+ // until it is observed (the re-subscribe also delivers an initial value).
+ deadline := time.After(8 * time.Second)
+ tick := time.NewTicker(300 * time.Millisecond)
+ defer tick.Stop()
+ for {
+ select {
+ case tv := <-ch:
+ if math.Abs(tv.Double-88.8) < 1e-6 {
+ return // reconnect succeeded and the update flowed through
+ }
+ case <-tick.C:
+ _ = srv.SetValue("TEST:DOUBLE", 88.8)
+ case <-deadline:
+ t.Fatal("timeout waiting for post-reconnect update")
+ }
+ }
+}
diff --git a/pkg/ca/getctrl_test.go b/pkg/ca/getctrl_test.go
new file mode 100644
index 0000000..8d0a267
--- /dev/null
+++ b/pkg/ca/getctrl_test.go
@@ -0,0 +1,57 @@
+package ca_test
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+// TestGetCtrl exercises GetCtrl across the native DBF types served by the mock,
+// covering NativeCtrlType dispatch and the DBR_CTRL_* decoders.
+func TestGetCtrl(t *testing.T) {
+ srv := newTestServer(t)
+ cli := newTestClient(t, srv)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ t.Run("double", func(t *testing.T) {
+ ci, err := cli.GetCtrl(ctx, "TEST:DOUBLE")
+ if err != nil {
+ t.Fatalf("GetCtrl: %v", err)
+ }
+ if ci.Double == nil {
+ t.Fatal("expected Double ctrl info")
+ }
+ })
+
+ t.Run("long", func(t *testing.T) {
+ ci, err := cli.GetCtrl(ctx, "TEST:LONG")
+ if err != nil {
+ t.Fatalf("GetCtrl: %v", err)
+ }
+ if ci.Long == nil {
+ t.Fatal("expected Long ctrl info")
+ }
+ })
+
+ t.Run("enum", func(t *testing.T) {
+ ci, err := cli.GetCtrl(ctx, "TEST:ENUM")
+ if err != nil {
+ t.Fatalf("GetCtrl: %v", err)
+ }
+ if ci.Enum == nil {
+ t.Fatal("expected Enum ctrl info")
+ }
+ })
+
+ t.Run("string", func(t *testing.T) {
+ ci, err := cli.GetCtrl(ctx, "TEST:STRING")
+ if err != nil {
+ t.Fatalf("GetCtrl: %v", err)
+ }
+ if ci.Str == nil {
+ t.Fatal("expected Str ctrl info")
+ }
+ })
+}
diff --git a/pkg/ca/helpers_internal_test.go b/pkg/ca/helpers_internal_test.go
new file mode 100644
index 0000000..51cf96e
--- /dev/null
+++ b/pkg/ca/helpers_internal_test.go
@@ -0,0 +1,139 @@
+package ca
+
+import (
+ "testing"
+
+ "github.com/uopi/goca/proto"
+)
+
+// ---- value coercion helpers ------------------------------------------
+
+func TestToFloat64(t *testing.T) {
+ cases := []struct {
+ in any
+ want float64
+ }{
+ {float64(1.5), 1.5},
+ {float32(2.5), 2.5},
+ {int(3), 3},
+ {int64(4), 4},
+ {int32(5), 5},
+ {int16(6), 6},
+ {uint64(7), 7},
+ {uint32(8), 8},
+ {true, 1},
+ {false, 0},
+ }
+ for _, c := range cases {
+ got, err := toFloat64(c.in)
+ if err != nil || got != c.want {
+ t.Errorf("toFloat64(%v) = %v, %v; want %v", c.in, got, err, c.want)
+ }
+ }
+ if _, err := toFloat64("nope"); err == nil {
+ t.Error("toFloat64(string): want error")
+ }
+}
+
+func TestToInt32(t *testing.T) {
+ cases := []struct {
+ in any
+ want int32
+ }{
+ {int(3), 3},
+ {int64(4), 4},
+ {int32(5), 5},
+ {int16(6), 6},
+ {float64(7.9), 7},
+ {float32(8.9), 8},
+ {true, 1},
+ {false, 0},
+ }
+ for _, c := range cases {
+ got, err := toInt32(c.in)
+ if err != nil || got != c.want {
+ t.Errorf("toInt32(%v) = %v, %v; want %v", c.in, got, err, c.want)
+ }
+ }
+ if _, err := toInt32("nope"); err == nil {
+ t.Error("toInt32(string): want error")
+ }
+}
+
+func TestToString(t *testing.T) {
+ if s, _ := toString("hi"); s != "hi" {
+ t.Errorf("toString(string) = %q", s)
+ }
+ if s, _ := toString([]byte("bytes")); s != "bytes" {
+ t.Errorf("toString([]byte) = %q", s)
+ }
+ if s, _ := toString(42); s != "42" {
+ t.Errorf("toString(int) = %q, want 42", s)
+ }
+}
+
+// ---- ConfigFromEnv ---------------------------------------------------
+
+func TestConfigFromEnv(t *testing.T) {
+ t.Setenv("EPICS_CA_ADDR_LIST", "1.2.3.4 5.6.7.8")
+ t.Setenv("EPICS_CA_AUTO_ADDR_LIST", "no")
+ cfg := ConfigFromEnv()
+ if len(cfg.AddrList) != 2 || cfg.AddrList[0] != "1.2.3.4" {
+ t.Errorf("AddrList = %v", cfg.AddrList)
+ }
+ if cfg.AutoAddrList {
+ t.Error("AutoAddrList should be false when env = no")
+ }
+ if cfg.ClientName == "" {
+ t.Error("ClientName should be populated")
+ }
+
+ t.Setenv("EPICS_CA_ADDR_LIST", "")
+ t.Setenv("EPICS_CA_AUTO_ADDR_LIST", "yes")
+ cfg = ConfigFromEnv()
+ if !cfg.AutoAddrList {
+ t.Error("AutoAddrList should default to true")
+ }
+ if len(cfg.AddrList) != 0 {
+ t.Errorf("AddrList should be empty, got %v", cfg.AddrList)
+ }
+}
+
+// ---- address helpers -------------------------------------------------
+
+func TestResolveAddrs(t *testing.T) {
+ got := resolveAddrs([]string{"host", "host2:9999", ""}, 5064)
+ if len(got) != 2 || got[0] != "host:5064" || got[1] != "host2:9999" {
+ t.Errorf("resolveAddrs = %v", got)
+ }
+}
+
+func TestLocalBroadcastAddrs(t *testing.T) {
+ // Should not panic; the result is environment-dependent.
+ _ = localBroadcastAddrs(5064)
+}
+
+// ---- isAllFF ---------------------------------------------------------
+
+func TestIsAllFF(t *testing.T) {
+ if !isAllFF([]byte{0xFF, 0xFF, 0xFF}) {
+ t.Error("all-FF should be true")
+ }
+ if isAllFF([]byte{0xFF, 0x00}) {
+ t.Error("mixed should be false")
+ }
+}
+
+// ---- EncodedSearchPair -----------------------------------------------
+
+func TestEncodedSearchPair(t *testing.T) {
+ pkt := EncodedSearchPair("TEST:PV", 7)
+ // First header must be a VERSION message.
+ h, _, err := proto.DecodeHeader(newBytesReader(pkt))
+ if err != nil {
+ t.Fatalf("DecodeHeader: %v", err)
+ }
+ if h.Command != proto.CmdVersion {
+ t.Errorf("first command = %d, want CmdVersion", h.Command)
+ }
+}
diff --git a/pkg/ca/proto/dbr.go b/pkg/ca/proto/dbr.go
index 5dba4f2..a24a63d 100644
--- a/pkg/ca/proto/dbr.go
+++ b/pkg/ca/proto/dbr.go
@@ -218,19 +218,19 @@ func DecodeTimeValue(dbrType uint16, count uint32, payload []byte) (TimeValue, b
// CtrlDouble is the decoded form of a DBR_CTRL_DOUBLE payload.
type CtrlDouble struct {
- Status int16
- Severity AlarmSeverity
- Precision int16
- Units string
- UpperDispLimit float64
- LowerDispLimit float64
- UpperAlarmLimit float64
- UpperWarnLimit float64
- LowerWarnLimit float64
- LowerAlarmLimit float64
- UpperCtrlLimit float64
- LowerCtrlLimit float64
- Value float64
+ Status int16
+ Severity AlarmSeverity
+ Precision int16
+ Units string
+ UpperDispLimit float64
+ LowerDispLimit float64
+ UpperAlarmLimit float64
+ UpperWarnLimit float64
+ LowerWarnLimit float64
+ LowerAlarmLimit float64
+ UpperCtrlLimit float64
+ LowerCtrlLimit float64
+ Value float64
}
// DecodeCtrlDouble decodes a DBR_CTRL_DOUBLE payload (minimum 88 bytes).
diff --git a/pkg/ca/proto/opcodes.go b/pkg/ca/proto/opcodes.go
index 58ec870..908747d 100644
--- a/pkg/ca/proto/opcodes.go
+++ b/pkg/ca/proto/opcodes.go
@@ -31,8 +31,8 @@ const DefaultPort = 5064
// Search reply data_type values.
const (
- SearchReply = 10 // DO_REPLY: ask server to respond
- SearchNoReply = 5 // DONT_REPLY: suppress response (used by repeater)
+ SearchReply = 10 // DO_REPLY: ask server to respond
+ SearchNoReply = 5 // DONT_REPLY: suppress response (used by repeater)
)
// AccessRights bitmask values (parameter2 of CmdAccessRights message).
diff --git a/pkg/ca/proto/proto_extra_test.go b/pkg/ca/proto/proto_extra_test.go
new file mode 100644
index 0000000..721cf68
--- /dev/null
+++ b/pkg/ca/proto/proto_extra_test.go
@@ -0,0 +1,163 @@
+package proto_test
+
+import (
+ "bytes"
+ "encoding/binary"
+ "math"
+ "testing"
+
+ "github.com/uopi/goca/proto"
+)
+
+// -------------------------------------------------------------------------- //
+// DBR_TIME_* variants not covered by proto_test.go //
+// -------------------------------------------------------------------------- //
+
+func TestDecodeTimeFloat(t *testing.T) {
+ hdr := buildTimeHeader(0, 0, 0, 0)
+ val := make([]byte, 4)
+ binary.BigEndian.PutUint32(val, math.Float32bits(2.5))
+ tv, ok := proto.DecodeTimeValue(proto.DBRTimeFloat, 1, append(hdr, val...))
+ if !ok || math.Abs(float64(tv.Float)-2.5) > 1e-6 {
+ t.Errorf("float: ok=%v Float=%g", ok, tv.Float)
+ }
+}
+
+func TestDecodeTimeShort(t *testing.T) {
+ hdr := buildTimeHeader(0, 0, 0, 0)
+ val := []byte{0x00, 0x00, 0xFF, 0xD6} // 2-byte pad + int16(-42)
+ tv, ok := proto.DecodeTimeValue(proto.DBRTimeShort, 1, append(hdr, val...))
+ if !ok || tv.Short != -42 {
+ t.Errorf("short: ok=%v Short=%d", ok, tv.Short)
+ }
+}
+
+func TestDecodeTimeChar(t *testing.T) {
+ hdr := buildTimeHeader(0, 0, 0, 0)
+ val := []byte{0x00, 0x00, 0x00, 0x41} // pad0 + pad1 + value 'A'
+ tv, ok := proto.DecodeTimeValue(proto.DBRTimeChar, 1, append(hdr, val...))
+ if !ok || tv.Char != 0x41 {
+ t.Errorf("char: ok=%v Char=%d", ok, tv.Char)
+ }
+}
+
+func TestDecodeTimeUnknownType(t *testing.T) {
+ hdr := buildTimeHeader(0, 0, 0, 0)
+ if _, ok := proto.DecodeTimeValue(9999, 1, append(hdr, make([]byte, 16)...)); ok {
+ t.Error("unknown DBR type should return false")
+ }
+}
+
+// -------------------------------------------------------------------------- //
+// DBR_CTRL_LONG / DBR_CTRL_STRING //
+// -------------------------------------------------------------------------- //
+
+func TestDecodeCtrlLong(t *testing.T) {
+ p := make([]byte, 48)
+ binary.BigEndian.PutUint16(p[2:], 2) // severity = Major
+ copy(p[4:], "counts")
+ i32 := func(off int, v int32) { binary.BigEndian.PutUint32(p[off:], uint32(v)) }
+ i32(12, 1000) // upper_disp
+ i32(16, -10) // lower_disp
+ i32(44, 777) // value
+
+ cl, ok := proto.DecodeCtrlLong(p)
+ if !ok {
+ t.Fatal("DecodeCtrlLong returned false")
+ }
+ if cl.Units != "counts" || cl.Value != 777 || cl.UpperDispLimit != 1000 || cl.LowerDispLimit != -10 {
+ t.Errorf("got %+v", cl)
+ }
+ if cl.Severity != proto.SeverityMajor {
+ t.Errorf("severity = %v, want Major", cl.Severity)
+ }
+ if _, ok := proto.DecodeCtrlLong(make([]byte, 10)); ok {
+ t.Error("short CtrlLong payload should return false")
+ }
+}
+
+func TestDecodeCtrlString(t *testing.T) {
+ p := make([]byte, 44)
+ binary.BigEndian.PutUint16(p[2:], 1) // severity = Minor
+ copy(p[4:], "READY")
+
+ cs, ok := proto.DecodeCtrlString(p)
+ if !ok {
+ t.Fatal("DecodeCtrlString returned false")
+ }
+ if cs.Value != "READY" || cs.Severity != proto.SeverityMinor {
+ t.Errorf("got %+v", cs)
+ }
+ if _, ok := proto.DecodeCtrlString(make([]byte, 10)); ok {
+ t.Error("short CtrlString payload should return false")
+ }
+}
+
+// -------------------------------------------------------------------------- //
+// EncodeShort //
+// -------------------------------------------------------------------------- //
+
+func TestEncodeShort(t *testing.T) {
+ b := proto.EncodeShort(-7)
+ if len(b) != 8 {
+ t.Fatalf("len = %d, want 8", len(b))
+ }
+ if got := int16(binary.BigEndian.Uint16(b)); got != -7 {
+ t.Errorf("decoded = %d, want -7", got)
+ }
+}
+
+// -------------------------------------------------------------------------- //
+// NativeCtrlType //
+// -------------------------------------------------------------------------- //
+
+func TestNativeCtrlType(t *testing.T) {
+ cases := []struct {
+ dbf int
+ want uint16
+ }{
+ {proto.DBFDouble, proto.DBRCtrlDouble},
+ {proto.DBFFloat, proto.DBRCtrlDouble},
+ {proto.DBFLong, proto.DBRCtrlLong},
+ {proto.DBFShort, proto.DBRCtrlLong},
+ {proto.DBFChar, proto.DBRCtrlLong},
+ {proto.DBFEnum, proto.DBRCtrlEnum},
+ {proto.DBFString, proto.DBRCtrlString},
+ {9999, proto.DBRCtrlDouble}, // default
+ }
+ for _, c := range cases {
+ if got := proto.NativeCtrlType(c.dbf); got != c.want {
+ t.Errorf("NativeCtrlType(%d) = %d, want %d", c.dbf, got, c.want)
+ }
+ }
+}
+
+// -------------------------------------------------------------------------- //
+// DecodeHeader: extended encoding + error path //
+// -------------------------------------------------------------------------- //
+
+func TestDecodeHeaderExtended(t *testing.T) {
+ raw := make([]byte, 24)
+ binary.BigEndian.PutUint16(raw[0:], proto.CmdReadNotify)
+ binary.BigEndian.PutUint16(raw[2:], 0xFFFF) // extended marker
+ binary.BigEndian.PutUint16(raw[6:], 0x0000)
+ binary.BigEndian.PutUint32(raw[16:], 70000) // real payload size
+ binary.BigEndian.PutUint32(raw[20:], 5) // real count
+
+ h, n, err := proto.DecodeHeader(bytes.NewReader(raw))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n != 24 {
+ t.Errorf("bytes read = %d, want 24", n)
+ }
+ if h.PayloadSize != 70000 || h.DataCount != 5 {
+ t.Errorf("extended header = %+v", h)
+ }
+}
+
+func TestDecodeHeaderTruncated(t *testing.T) {
+ if _, _, err := proto.DecodeHeader(bytes.NewReader([]byte{0x00, 0x01})); err == nil {
+ t.Error("truncated header: want error")
+ }
+}
diff --git a/pkg/ca/proto/proto_test.go b/pkg/ca/proto/proto_test.go
index fe205d9..0703d57 100644
--- a/pkg/ca/proto/proto_test.go
+++ b/pkg/ca/proto/proto_test.go
@@ -102,7 +102,7 @@ func TestDecodeTimeDouble(t *testing.T) {
const want = 3.14159
hdr := buildTimeHeader(0, 0, sec, nsec)
- pad := make([]byte, 4) // RISC pad
+ pad := make([]byte, 4) // RISC pad
val := make([]byte, 8)
binary.BigEndian.PutUint64(val, math.Float64bits(want))
payload := append(append(hdr, pad...), val...)
diff --git a/pkg/ca/testca/server.go b/pkg/ca/testca/server.go
index 5403022..2075081 100644
--- a/pkg/ca/testca/server.go
+++ b/pkg/ca/testca/server.go
@@ -29,13 +29,21 @@ type Server struct {
tcpLn net.Listener
udpCn *net.UDPConn
- mu sync.RWMutex
- pvs map[string]*serverPV
- subs map[uint32]*serverSub // subID → sub
+ mu sync.RWMutex
+ pvs map[string]*serverPV
+ subs map[uint32]*serverSub // subID → sub
+ getFault int // fault to inject on the next READ_NOTIFY
done chan struct{}
}
+// Fault modes for SetGetFault, used to exercise the client's error paths.
+const (
+ GetFaultNone = iota // serve replies normally
+ GetFaultDisconnect // drop the connection mid-request
+ GetFaultCorrupt // reply with an undecodable (too-short) payload
+)
+
type serverPV struct {
spec PVSpec
mu sync.RWMutex
@@ -43,11 +51,11 @@ type serverPV struct {
}
type serverSub struct {
- pvName string
- subID uint32
- cid uint32
- sid uint32
- conn net.Conn
+ pvName string
+ subID uint32
+ cid uint32
+ sid uint32
+ conn net.Conn
dbrType uint16
count uint32
}
@@ -93,6 +101,29 @@ func (s *Server) Close() {
s.udpCn.Close()
}
+// SetGetFault arms a fault to be injected on the next READ_NOTIFY request.
+// It is reset to GetFaultNone after a single request is faulted.
+func (s *Server) SetGetFault(mode int) {
+ s.mu.Lock()
+ s.getFault = mode
+ s.mu.Unlock()
+}
+
+// Disconnect pushes a SERVER_DISCONN message to every connected subscriber,
+// simulating an orderly server shutdown that forces clients to reconnect.
+func (s *Server) Disconnect() {
+ msg := proto.BuildMessage(proto.Header{Command: proto.CmdServerDisc}, nil)
+ seen := make(map[net.Conn]bool)
+ s.mu.RLock()
+ for _, sub := range s.subs {
+ if sub.conn != nil && !seen[sub.conn] {
+ seen[sub.conn] = true
+ _, _ = sub.conn.Write(msg)
+ }
+ }
+ s.mu.RUnlock()
+}
+
// SetValue updates a PV's value and pushes a monitor event to all subscribers.
func (s *Server) SetValue(pvName string, val any) error {
s.mu.RLock()
@@ -350,6 +381,31 @@ func (s *Server) handleConn(conn net.Conn) {
continue
}
+ // Fault injection: consume a one-shot armed fault, if any.
+ s.mu.Lock()
+ fault := s.getFault
+ s.getFault = GetFaultNone
+ s.mu.Unlock()
+ switch fault {
+ case GetFaultDisconnect:
+ // Drop the connection mid-request: the client's in-flight GET
+ // unblocks with ok=false ("circuit disconnected during GET").
+ conn.Close()
+ return
+ case GetFaultCorrupt:
+ // Reply with a too-short payload so DecodeTimeValue fails
+ // ("failed to decode GET reply").
+ reply := proto.BuildMessage(proto.Header{
+ Command: proto.CmdReadNotify,
+ DataType: hdr.DataType,
+ DataCount: hdr.DataCount,
+ Parameter1: cid,
+ Parameter2: ioid,
+ }, []byte{0, 0, 0, 0})
+ conn.Write(reply)
+ continue
+ }
+
s.mu.RLock()
pv := s.pvs[pvName]
s.mu.RUnlock()
@@ -591,7 +647,10 @@ func nullStr(b []byte) string {
// Minimal io.Reader for proto.DecodeHeader //
// -------------------------------------------------------------------------- //
-type bytesReader struct{ b []byte; i int }
+type bytesReader struct {
+ b []byte
+ i int
+}
func newBytesReader(b []byte) *bytesReader { return &bytesReader{b: b} }
diff --git a/pkg/pva/client.go b/pkg/pva/client.go
index 1463527..6ca8ea1 100644
--- a/pkg/pva/client.go
+++ b/pkg/pva/client.go
@@ -224,10 +224,10 @@ func (cl *Client) sendSearchRequest(seq uint32, pvName string) {
laddr := cl.udp.LocalAddr().(*net.UDPAddr)
binary.Write(&buf, binary.LittleEndian, uint16(laddr.Port))
- buf.WriteByte(1) // transportCount = 1
- buf.WriteByte(0x01) // transport[0]: TCP
+ buf.WriteByte(1) // transportCount = 1
+ buf.WriteByte(0x01) // transport[0]: TCP
- binary.Write(&buf, binary.LittleEndian, uint16(1)) // pvCount = 1
+ binary.Write(&buf, binary.LittleEndian, uint16(1)) // pvCount = 1
binary.Write(&buf, binary.LittleEndian, uint32(seq)) // channelID (reuse seq)
pvdata.WriteString(&buf, pvName)
diff --git a/pkg/pva/conn.go b/pkg/pva/conn.go
index 1eeb076..3ccebc7 100644
--- a/pkg/pva/conn.go
+++ b/pkg/pva/conn.go
@@ -38,7 +38,7 @@ type serverChannel struct {
type monitorSub struct {
ioid uint32
- sid uint32 // server channel ID — needed for pipeline ACKs and DESTROY
+ sid uint32 // server channel ID — needed for pipeline ACKs and DESTROY
cid uint32
desc pvdata.FieldDesc // structure descriptor (received on INIT response)
updates chan MonitorEvent
@@ -56,10 +56,10 @@ type MonitorEvent struct {
// conn is a single PVA TCP connection to one server.
type conn struct {
- nc net.Conn
- br *bufio.Reader // shared reader — used by handshake then readLoop
- bw *bufio.Writer
- mu sync.Mutex // protects writes + state maps
+ nc net.Conn
+ br *bufio.Reader // shared reader — used by handshake then readLoop
+ bw *bufio.Writer
+ mu sync.Mutex // protects writes + state maps
// auto-incrementing IDs
nextCID atomic.Uint32
@@ -68,7 +68,7 @@ type conn struct {
// state
chans map[uint32]*serverChannel // keyed by client CID
byPV map[string]*serverChannel // keyed by PV name
- monitors map[uint32]*monitorSub // keyed by IOID
+ monitors map[uint32]*monitorSub // keyed by IOID
done chan struct{}
}
@@ -573,4 +573,3 @@ func asError(s Status) error {
}
return fmt.Errorf("%s", s.Error())
}
-
diff --git a/pkg/pva/proto.go b/pkg/pva/proto.go
index c2791dd..825e093 100644
--- a/pkg/pva/proto.go
+++ b/pkg/pva/proto.go
@@ -59,21 +59,21 @@ const (
// ---- Control message sub-commands -------------------------------------
const (
- CtrlSetMarker byte = 0x00
- CtrlAckMarker byte = 0x01
- CtrlSetByteOrder byte = 0x02
- CtrlEchoRequest byte = 0x03
- CtrlEchoResponse byte = 0x04
+ CtrlSetMarker byte = 0x00
+ CtrlAckMarker byte = 0x01
+ CtrlSetByteOrder byte = 0x02
+ CtrlEchoRequest byte = 0x03
+ CtrlEchoResponse byte = 0x04
)
// ---- Status codes -----------------------------------------------------
const (
- StatusOK byte = 0xFF // special "OK" short encoding
- StatusOKFull byte = 0x00 // full OK message (type=OK, msg="")
- StatusWarn byte = 0x01
- StatusError byte = 0x02
- StatusFatal byte = 0x03
+ StatusOK byte = 0xFF // special "OK" short encoding
+ StatusOKFull byte = 0x00 // full OK message (type=OK, msg="")
+ StatusWarn byte = 0x01
+ StatusError byte = 0x02
+ StatusFatal byte = 0x03
)
// ---- Request sub-command bits -----------------------------------------
@@ -90,11 +90,11 @@ const (
// PVAHeader is the 8-byte fixed header on every PVA message.
//
-// byte 0 : magic 0xCA
-// byte 1 : protocol version (0x01)
-// byte 2 : flags (see flag* constants)
-// byte 3 : command code
-// bytes 4–7 : payload size (uint32, matches byte-order flag)
+// byte 0 : magic 0xCA
+// byte 1 : protocol version (0x01)
+// byte 2 : flags (see flag* constants)
+// byte 3 : command code
+// bytes 4–7 : payload size (uint32, matches byte-order flag)
type PVAHeader struct {
Version byte
Flags byte
diff --git a/pkg/pva/proto_test.go b/pkg/pva/proto_test.go
new file mode 100644
index 0000000..3beeb5e
--- /dev/null
+++ b/pkg/pva/proto_test.go
@@ -0,0 +1,175 @@
+package pva
+
+import (
+ "bytes"
+ "encoding/binary"
+ "testing"
+)
+
+// ---- Header codec -----------------------------------------------------
+
+func TestHeaderRoundTrip(t *testing.T) {
+ h := PVAHeader{Version: 1, Flags: flagApp, Command: CmdGet, Size: 1234}
+ var buf bytes.Buffer
+ if err := WriteHeader(&buf, h); err != nil {
+ t.Fatal(err)
+ }
+ got, err := ReadHeader(&buf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Version != h.Version || got.Command != h.Command || got.Size != h.Size {
+ t.Errorf("round-trip header: got %+v want %+v", got, h)
+ }
+}
+
+func TestReadHeaderBadMagic(t *testing.T) {
+ raw := []byte{0xAB, 0x01, 0x00, CmdGet, 0, 0, 0, 0}
+ if _, err := ReadHeader(bytes.NewReader(raw)); err == nil {
+ t.Error("bad magic: want error")
+ }
+}
+
+func TestReadHeaderTruncated(t *testing.T) {
+ if _, err := ReadHeader(bytes.NewReader([]byte{0xCA, 0x01})); err == nil {
+ t.Error("truncated header: want error")
+ }
+}
+
+func TestHeaderBigEndian(t *testing.T) {
+ // Hand-assemble a big-endian server header with size 0x00010203.
+ raw := []byte{magic, 0x01, flagBigEndian | flagFromServer, CmdMonitor, 0x00, 0x01, 0x02, 0x03}
+ h, err := ReadHeader(bytes.NewReader(raw))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if h.isLittleEndian() {
+ t.Error("expected big-endian header")
+ }
+ if !h.isFromServer() {
+ t.Error("expected from-server bit")
+ }
+ if h.byteOrder() != binary.BigEndian {
+ t.Error("byteOrder should be big-endian")
+ }
+ if h.Size != 0x00010203 {
+ t.Errorf("size = %#x, want 0x00010203", h.Size)
+ }
+}
+
+func TestHeaderPredicates(t *testing.T) {
+ le := PVAHeader{Flags: flagApp}
+ if !le.isLittleEndian() || le.isFromServer() || le.isControl() {
+ t.Errorf("app/LE header predicates wrong: %+v", le)
+ }
+ ctrl := PVAHeader{Flags: flagControl}
+ if !ctrl.isControl() {
+ t.Error("control header should report isControl")
+ }
+}
+
+// ---- BuildMessage -----------------------------------------------------
+
+func TestBuildMessage(t *testing.T) {
+ payload := []byte{1, 2, 3, 4, 5}
+ // Pass the server flag too; BuildMessage must clear it (client direction).
+ msg := BuildMessage(CmdCreateChannel, flagFromServer, payload)
+ h, err := ReadHeader(bytes.NewReader(msg))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if h.Command != CmdCreateChannel {
+ t.Errorf("command = %#x, want %#x", h.Command, CmdCreateChannel)
+ }
+ if h.isFromServer() {
+ t.Error("BuildMessage must clear the from-server flag")
+ }
+ if int(h.Size) != len(payload) {
+ t.Errorf("size = %d, want %d", h.Size, len(payload))
+ }
+ if got := msg[headerSize:]; !bytes.Equal(got, payload) {
+ t.Errorf("payload = %v, want %v", got, payload)
+ }
+}
+
+// ---- Status decoding --------------------------------------------------
+
+func TestReadStatusOKShort(t *testing.T) {
+ s, err := ReadStatus(bytes.NewReader([]byte{StatusOK}), binary.LittleEndian)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !s.OK() || s.Error() != "" {
+ t.Errorf("short OK status: got %+v", s)
+ }
+}
+
+func TestReadStatusError(t *testing.T) {
+ var buf bytes.Buffer
+ buf.WriteByte(StatusError)
+ buf.WriteByte(0x04) // compact size 4
+ buf.WriteString("boom")
+ buf.WriteByte(0x00) // empty stack
+ s, err := ReadStatus(&buf, binary.LittleEndian)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if s.OK() {
+ t.Error("error status should not be OK")
+ }
+ if s.Message != "boom" {
+ t.Errorf("message = %q, want boom", s.Message)
+ }
+ if s.Error() == "" {
+ t.Error("Error() should be non-empty for an error status")
+ }
+}
+
+func TestReadStatusTruncated(t *testing.T) {
+ if _, err := ReadStatus(bytes.NewReader(nil), binary.LittleEndian); err == nil {
+ t.Error("empty status: want error")
+ }
+ // type byte present but the message string is missing.
+ if _, err := ReadStatus(bytes.NewReader([]byte{StatusError}), binary.LittleEndian); err == nil {
+ t.Error("status missing message: want error")
+ }
+}
+
+// ---- readPVAString ----------------------------------------------------
+
+func TestReadPVAString(t *testing.T) {
+ // Short form.
+ short := append([]byte{0x03}, []byte("abc")...)
+ s, err := readPVAString(bytes.NewReader(short), binary.LittleEndian)
+ if err != nil || s != "abc" {
+ t.Errorf("short string: got %q err %v", s, err)
+ }
+ // Empty.
+ s, err = readPVAString(bytes.NewReader([]byte{0x00}), binary.LittleEndian)
+ if err != nil || s != "" {
+ t.Errorf("empty string: got %q err %v", s, err)
+ }
+ // Extended form: 0xFF + int32 length + bytes.
+ var ext bytes.Buffer
+ ext.WriteByte(0xFF)
+ _ = binary.Write(&ext, binary.LittleEndian, int32(5))
+ ext.WriteString("hello")
+ s, err = readPVAString(&ext, binary.LittleEndian)
+ if err != nil || s != "hello" {
+ t.Errorf("extended string: got %q err %v", s, err)
+ }
+}
+
+// ---- env --------------------------------------------------------------
+
+func TestAddrsFromEnv(t *testing.T) {
+ t.Setenv("EPICS_PVA_ADDR_LIST", "")
+ if got := addrsFromEnv(); got != nil {
+ t.Errorf("empty env: got %v, want nil", got)
+ }
+ t.Setenv("EPICS_PVA_ADDR_LIST", " 1.2.3.4 5.6.7.8 ")
+ got := addrsFromEnv()
+ if len(got) != 2 || got[0] != "1.2.3.4" || got[1] != "5.6.7.8" {
+ t.Errorf("addr list: got %v", got)
+ }
+}
diff --git a/pkg/pva/pvdata/bitset.go b/pkg/pva/pvdata/bitset.go
index 437e500..7364334 100644
--- a/pkg/pva/pvdata/bitset.go
+++ b/pkg/pva/pvdata/bitset.go
@@ -3,7 +3,9 @@ package pvdata
import "io"
// BitSet is a variable-length set of bit indices, encoded on the wire as:
-// compact-size(n_bytes) + n_bytes of little-endian bits.
+//
+// compact-size(n_bytes) + n_bytes of little-endian bits.
+//
// Bit 0 of byte 0 is field index 0, bit 1 of byte 0 is field index 1, etc.
// Used in pvData Monitor responses for "changed" and "overrun" masks.
type BitSet struct {
diff --git a/pkg/pva/pvdata/codec_test.go b/pkg/pva/pvdata/codec_test.go
new file mode 100644
index 0000000..03a75cb
--- /dev/null
+++ b/pkg/pva/pvdata/codec_test.go
@@ -0,0 +1,361 @@
+package pvdata_test
+
+import (
+ "bytes"
+ "math"
+ "reflect"
+ "testing"
+
+ "github.com/uopi/gopva/pvdata"
+)
+
+// ---- Scalar value codec: every type code -----------------------------
+
+func TestScalarRoundTrip(t *testing.T) {
+ cases := []struct {
+ name string
+ tc byte
+ v pvdata.Value
+ }{
+ {"bool-true", pvdata.TypeCodeBoolean, true},
+ {"bool-false", pvdata.TypeCodeBoolean, false},
+ {"byte", pvdata.TypeCodeByte, int8(-12)},
+ {"short", pvdata.TypeCodeShort, int16(-3000)},
+ {"int", pvdata.TypeCodeInt, int32(-100000)},
+ {"long", pvdata.TypeCodeLong, int64(-1 << 40)},
+ {"ubyte", pvdata.TypeCodeUByte, uint8(200)},
+ {"ushort", pvdata.TypeCodeUShort, uint16(60000)},
+ {"uint", pvdata.TypeCodeUInt, uint32(4000000000)},
+ {"ulong", pvdata.TypeCodeULong, uint64(1 << 50)},
+ {"float", pvdata.TypeCodeFloat, float32(1.5)},
+ {"double", pvdata.TypeCodeDouble, float64(2.5)},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ desc := pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: c.tc}
+ var buf bytes.Buffer
+ if err := pvdata.WriteValue(&buf, desc, c.v); err != nil {
+ t.Fatalf("WriteValue: %v", err)
+ }
+ got, err := pvdata.ReadValue(&buf, desc)
+ if err != nil {
+ t.Fatalf("ReadValue: %v", err)
+ }
+ if got != c.v {
+ t.Errorf("got %v (%T) want %v (%T)", got, got, c.v, c.v)
+ }
+ })
+ }
+}
+
+// ---- Scalar array codec: every array code ----------------------------
+
+func TestScalarArrayRoundTrip(t *testing.T) {
+ cases := []struct {
+ name string
+ tc byte
+ v pvdata.Value
+ }{
+ {"bool", pvdata.TypeCodeBoolArr, []bool{true, false, true}},
+ {"byte", pvdata.TypeCodeByteArr, []int8{-1, 2, -3}},
+ {"short", pvdata.TypeCodeShortArr, []int16{-1, 2, 30000}},
+ {"int", pvdata.TypeCodeIntArr, []int32{-1, 2, 1 << 30}},
+ {"long", pvdata.TypeCodeLongArr, []int64{-1, 1 << 40}},
+ {"ubyte", pvdata.TypeCodeUByteArr, []uint8{1, 2, 255}},
+ {"ushort", pvdata.TypeCodeUShortArr, []uint16{1, 60000}},
+ {"uint", pvdata.TypeCodeUIntArr, []uint32{1, 4000000000}},
+ {"ulong", pvdata.TypeCodeULongArr, []uint64{1, 1 << 50}},
+ {"float", pvdata.TypeCodeFloatArr, []float32{1.5, -2.25}},
+ {"double", pvdata.TypeCodeDoubleArr, []float64{1.5, -2.25}},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ desc := pvdata.FieldDesc{Kind: pvdata.KindScalarArr, TypeCode: c.tc}
+ var buf bytes.Buffer
+ if err := pvdata.WriteValue(&buf, desc, c.v); err != nil {
+ t.Fatalf("WriteValue: %v", err)
+ }
+ got, err := pvdata.ReadValue(&buf, desc)
+ if err != nil {
+ t.Fatalf("ReadValue: %v", err)
+ }
+ if !reflect.DeepEqual(got, c.v) {
+ t.Errorf("got %v want %v", got, c.v)
+ }
+ })
+ }
+}
+
+func TestStringArrayRoundTrip(t *testing.T) {
+ desc := pvdata.FieldDesc{Kind: pvdata.KindStringArr, TypeCode: pvdata.TypeCodeStringArr}
+ for _, v := range [][]string{{"a", "", "epics"}, {}} {
+ var buf bytes.Buffer
+ if err := pvdata.WriteValue(&buf, desc, v); err != nil {
+ t.Fatalf("WriteValue: %v", err)
+ }
+ got, err := pvdata.ReadValue(&buf, desc)
+ if err != nil {
+ t.Fatalf("ReadValue: %v", err)
+ }
+ gs := got.([]string)
+ if len(gs) != len(v) {
+ t.Fatalf("len: got %d want %d", len(gs), len(v))
+ }
+ for i := range v {
+ if gs[i] != v[i] {
+ t.Errorf("[%d]: got %q want %q", i, gs[i], v[i])
+ }
+ }
+ }
+}
+
+// ---- FieldDesc round-trip for every Kind -----------------------------
+
+func TestFieldDescVariants(t *testing.T) {
+ descs := []pvdata.FieldDesc{
+ {TypeCode: pvdata.TypeCodeNull},
+ {Kind: pvdata.KindString, TypeCode: pvdata.TypeCodeString},
+ {Kind: pvdata.KindStringArr, TypeCode: pvdata.TypeCodeStringArr},
+ {Kind: pvdata.KindVariant, TypeCode: pvdata.TypeCodeVariant},
+ {Kind: pvdata.KindScalarArr, TypeCode: pvdata.TypeCodeDoubleArr},
+ {Kind: pvdata.KindUnion, TypeCode: pvdata.TypeCodeUnion, TypeID: "u", Fields: []pvdata.Field{
+ {Name: "a", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
+ }},
+ {Kind: pvdata.KindStructArr, TypeCode: pvdata.TypeCodeStructArr, TypeID: "s", Fields: []pvdata.Field{
+ {Name: "x", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeDouble}},
+ }},
+ {Kind: pvdata.KindUnionArr, TypeCode: pvdata.TypeCodeUnionArr, TypeID: "ua", Fields: []pvdata.Field{
+ {Name: "y", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
+ }},
+ }
+ for _, d := range descs {
+ var buf bytes.Buffer
+ if err := pvdata.WriteFieldDesc(&buf, d); err != nil {
+ t.Fatalf("WriteFieldDesc(%#x): %v", d.TypeCode, err)
+ }
+ got, err := pvdata.ReadFieldDesc(&buf)
+ if err != nil {
+ t.Fatalf("ReadFieldDesc(%#x): %v", d.TypeCode, err)
+ }
+ if got.TypeCode != d.TypeCode {
+ t.Errorf("TypeCode: got %#x want %#x", got.TypeCode, d.TypeCode)
+ }
+ if got.TypeID != d.TypeID {
+ t.Errorf("TypeID: got %q want %q", got.TypeID, d.TypeID)
+ }
+ if len(got.Fields) != len(d.Fields) {
+ t.Errorf("Fields: got %d want %d", len(got.Fields), len(d.Fields))
+ }
+ }
+}
+
+// ---- Struct array value (readStructArr) ------------------------------
+
+func TestStructArrRoundTrip(t *testing.T) {
+ fields := []pvdata.Field{
+ {Name: "x", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
+ }
+ elems := []pvdata.StructValue{
+ {TypeID: "p", Fields: []pvdata.FieldValue{{Name: "x", Value: int32(1)}}},
+ {TypeID: "p", Fields: []pvdata.FieldValue{{Name: "x", Value: int32(2)}}},
+ }
+ var buf bytes.Buffer
+ if err := pvdata.WriteSize(&buf, int64(len(elems))); err != nil {
+ t.Fatal(err)
+ }
+ sdesc := pvdata.FieldDesc{Kind: pvdata.KindStruct, TypeCode: pvdata.TypeCodeStruct, TypeID: "p", Fields: fields}
+ for _, sv := range elems {
+ if err := pvdata.WriteValue(&buf, sdesc, sv); err != nil {
+ t.Fatal(err)
+ }
+ }
+ adesc := pvdata.FieldDesc{Kind: pvdata.KindStructArr, TypeCode: pvdata.TypeCodeStructArr, TypeID: "p", Fields: fields}
+ got, err := pvdata.ReadValue(&buf, adesc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ arr := got.([]pvdata.StructValue)
+ if len(arr) != 2 || arr[0].Fields[0].Value.(int32) != 1 || arr[1].Fields[0].Value.(int32) != 2 {
+ t.Errorf("got %+v", arr)
+ }
+}
+
+// ---- Union value (readUnion / writeUnion) ----------------------------
+
+func unionDesc() pvdata.FieldDesc {
+ return pvdata.FieldDesc{
+ Kind: pvdata.KindUnion, TypeCode: pvdata.TypeCodeUnion, TypeID: "u",
+ Fields: []pvdata.Field{
+ {Name: "i", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
+ {Name: "s", Desc: pvdata.FieldDesc{Kind: pvdata.KindString, TypeCode: pvdata.TypeCodeString}},
+ },
+ }
+}
+
+func TestUnionSelectedValue(t *testing.T) {
+ desc := unionDesc()
+ // Manually encode: select field 1 (string) with value "hi".
+ var buf bytes.Buffer
+ if err := pvdata.WriteSize(&buf, 1); err != nil {
+ t.Fatal(err)
+ }
+ if err := pvdata.WriteValue(&buf, desc.Fields[1].Desc, "hi"); err != nil {
+ t.Fatal(err)
+ }
+ got, err := pvdata.ReadValue(&buf, desc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ uv := got.(pvdata.UnionValue)
+ if uv.Selected != 1 || uv.Name != "s" || uv.Value.(string) != "hi" {
+ t.Errorf("got %+v", uv)
+ }
+}
+
+func TestUnionNullSelector(t *testing.T) {
+ desc := unionDesc()
+ // writeUnion only writes the selector; an out-of-range selector → null.
+ var buf bytes.Buffer
+ if err := pvdata.WriteValue(&buf, desc, pvdata.UnionValue{Selected: 7}); err != nil {
+ t.Fatal(err)
+ }
+ got, err := pvdata.ReadValue(&buf, desc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ uv := got.(pvdata.UnionValue)
+ if uv.Selected != 7 || uv.Value != nil {
+ t.Errorf("got %+v, want null selector", uv)
+ }
+}
+
+func TestUnionArrRoundTrip(t *testing.T) {
+ desc := pvdata.FieldDesc{
+ Kind: pvdata.KindUnionArr, TypeCode: pvdata.TypeCodeUnionArr, TypeID: "u",
+ Fields: []pvdata.Field{
+ {Name: "i", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
+ },
+ }
+ var buf bytes.Buffer
+ if err := pvdata.WriteSize(&buf, 2); err != nil {
+ t.Fatal(err)
+ }
+ for _, val := range []int32{10, 20} {
+ if err := pvdata.WriteSize(&buf, 0); err != nil { // selector 0
+ t.Fatal(err)
+ }
+ if err := pvdata.WriteValue(&buf, desc.Fields[0].Desc, val); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := pvdata.ReadValue(&buf, desc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ arr := got.([]pvdata.UnionValue)
+ if len(arr) != 2 || arr[0].Value.(int32) != 10 || arr[1].Value.(int32) != 20 {
+ t.Errorf("got %+v", arr)
+ }
+}
+
+// ---- Variant value (readVariant + WriteValue variant path) -----------
+
+func TestVariantRoundTrip(t *testing.T) {
+ inner := pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeDouble}
+ vv := pvdata.VariantValue{Desc: inner, Value: float64(9.5)}
+ desc := pvdata.FieldDesc{Kind: pvdata.KindVariant, TypeCode: pvdata.TypeCodeVariant}
+ var buf bytes.Buffer
+ if err := pvdata.WriteValue(&buf, desc, vv); err != nil {
+ t.Fatal(err)
+ }
+ got, err := pvdata.ReadValue(&buf, desc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ gvv := got.(pvdata.VariantValue)
+ if gvv.Value.(float64) != 9.5 || gvv.Desc.TypeCode != pvdata.TypeCodeDouble {
+ t.Errorf("got %+v", gvv)
+ }
+}
+
+// ---- Compact size: extended (>MaxInt32) encodings --------------------
+
+func TestCompactSizeLarge(t *testing.T) {
+ cases := []int64{math.MaxInt32, math.MaxInt32 + 1, 1 << 40}
+ for _, n := range cases {
+ var buf bytes.Buffer
+ if err := pvdata.WriteSize(&buf, n); err != nil {
+ t.Fatalf("WriteSize(%d): %v", n, err)
+ }
+ got, err := pvdata.ReadSize(&buf)
+ if err != nil {
+ t.Fatalf("ReadSize(%d): %v", n, err)
+ }
+ if got != n {
+ t.Errorf("round-trip size %d → %d", n, got)
+ }
+ }
+}
+
+// ---- Error / truncation paths ----------------------------------------
+
+func TestReadErrors(t *testing.T) {
+ if _, err := pvdata.ReadSize(bytes.NewReader(nil)); err == nil {
+ t.Error("ReadSize on empty: want error")
+ }
+ if _, err := pvdata.ReadString(bytes.NewReader([]byte{0x05})); err == nil {
+ t.Error("ReadString truncated: want error")
+ }
+ if _, err := pvdata.ReadFieldDesc(bytes.NewReader(nil)); err == nil {
+ t.Error("ReadFieldDesc on empty: want error")
+ }
+ // Unknown scalar type code.
+ badScalar := pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: 0x99}
+ if _, err := pvdata.ReadValue(bytes.NewReader([]byte{1, 2, 3, 4}), badScalar); err == nil {
+ t.Error("ReadValue unknown scalar code: want error")
+ }
+ // Unknown array type code (after a valid size byte).
+ badArr := pvdata.FieldDesc{Kind: pvdata.KindScalarArr, TypeCode: 0x99}
+ if _, err := pvdata.ReadValue(bytes.NewReader([]byte{0x01}), badArr); err == nil {
+ t.Error("ReadValue unknown array code: want error")
+ }
+ // Unknown kind.
+ if _, err := pvdata.ReadValue(bytes.NewReader(nil), pvdata.FieldDesc{Kind: pvdata.Kind(99)}); err == nil {
+ t.Error("ReadValue unknown kind: want error")
+ }
+ // WriteValue unsupported kind (struct array has no writer).
+ if err := pvdata.WriteValue(&bytes.Buffer{}, pvdata.FieldDesc{Kind: pvdata.KindStructArr}, nil); err == nil {
+ t.Error("WriteValue unsupported kind: want error")
+ }
+}
+
+// ---- BitSet edges ----------------------------------------------------
+
+func TestBitSetEdges(t *testing.T) {
+ bs := pvdata.NewBitSet()
+ if bs.Has(0) {
+ t.Error("fresh NewBitSet should be empty")
+ }
+ bs.Set(0)
+ bs.Set(5)
+ bs.Set(12)
+ if got := bs.FieldIndices(); !reflect.DeepEqual(got, []int{0, 5, 12}) {
+ t.Errorf("FieldIndices: got %v want [0 5 12]", got)
+ }
+ if bs.Has(100) { // far out of range → no panic, false
+ t.Error("Has(100) should be false")
+ }
+
+ // Empty BitSet round-trips as a zero-length payload.
+ var buf bytes.Buffer
+ if err := pvdata.WriteBitSet(&buf, pvdata.NewBitSet()); err != nil {
+ t.Fatal(err)
+ }
+ got, err := pvdata.ReadBitSet(&buf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got.FieldIndices()) != 0 {
+ t.Errorf("empty BitSet round-trip: got %v", got.FieldIndices())
+ }
+}
diff --git a/pkg/pva/pvdata/types.go b/pkg/pva/pvdata/types.go
index 5a66cc5..7ff6b84 100644
--- a/pkg/pva/pvdata/types.go
+++ b/pkg/pva/pvdata/types.go
@@ -22,30 +22,30 @@ import (
// types. Values from the spec §3.1.
const (
- TypeCodeBoolean byte = 0x00
- TypeCodeByte byte = 0x20 // int8
- TypeCodeShort byte = 0x21 // int16
- TypeCodeInt byte = 0x22 // int32
- TypeCodeLong byte = 0x23 // int64
- TypeCodeUByte byte = 0x24 // uint8
- TypeCodeUShort byte = 0x25 // uint16
- TypeCodeUInt byte = 0x26 // uint32
- TypeCodeULong byte = 0x27 // uint64
- TypeCodeFloat byte = 0x42 // float32
- TypeCodeDouble byte = 0x43 // float64
- TypeCodeString byte = 0x60
- TypeCodeStruct byte = 0x80
- TypeCodeUnion byte = 0x81
- TypeCodeBoolArr byte = 0x08
- TypeCodeByteArr byte = 0x28
- TypeCodeShortArr byte = 0x29
- TypeCodeIntArr byte = 0x2A
- TypeCodeLongArr byte = 0x2B
- TypeCodeUByteArr byte = 0x2C
+ TypeCodeBoolean byte = 0x00
+ TypeCodeByte byte = 0x20 // int8
+ TypeCodeShort byte = 0x21 // int16
+ TypeCodeInt byte = 0x22 // int32
+ TypeCodeLong byte = 0x23 // int64
+ TypeCodeUByte byte = 0x24 // uint8
+ TypeCodeUShort byte = 0x25 // uint16
+ TypeCodeUInt byte = 0x26 // uint32
+ TypeCodeULong byte = 0x27 // uint64
+ TypeCodeFloat byte = 0x42 // float32
+ TypeCodeDouble byte = 0x43 // float64
+ TypeCodeString byte = 0x60
+ TypeCodeStruct byte = 0x80
+ TypeCodeUnion byte = 0x81
+ TypeCodeBoolArr byte = 0x08
+ TypeCodeByteArr byte = 0x28
+ TypeCodeShortArr byte = 0x29
+ TypeCodeIntArr byte = 0x2A
+ TypeCodeLongArr byte = 0x2B
+ TypeCodeUByteArr byte = 0x2C
TypeCodeUShortArr byte = 0x2D
- TypeCodeUIntArr byte = 0x2E
- TypeCodeULongArr byte = 0x2F
- TypeCodeFloatArr byte = 0x4A
+ TypeCodeUIntArr byte = 0x2E
+ TypeCodeULongArr byte = 0x2F
+ TypeCodeFloatArr byte = 0x4A
TypeCodeDoubleArr byte = 0x4B
TypeCodeStringArr byte = 0x68
TypeCodeStructArr byte = 0x88
@@ -142,19 +142,19 @@ const (
KindScalarArr // variable-length array of scalars
KindString // pvData string (treated specially)
KindStringArr
- KindStruct // structure
+ KindStruct // structure
KindStructArr
- KindUnion // discriminated union
+ KindUnion // discriminated union
KindUnionArr
- KindVariant // any (variant)
+ KindVariant // any (variant)
)
// FieldDesc describes the type of a single field in a pvData structure.
type FieldDesc struct {
- Kind Kind
- TypeCode byte // scalar/array type code; 0 for struct/union
- TypeID string // optional structure/union type ID string
- Fields []Field // child fields for struct/union
+ Kind Kind
+ TypeCode byte // scalar/array type code; 0 for struct/union
+ TypeID string // optional structure/union type ID string
+ Fields []Field // child fields for struct/union
}
// Field is a named FieldDesc (one member of a struct or union).
@@ -293,9 +293,10 @@ func WriteFieldDesc(w io.Writer, fd FieldDesc) error {
// callers type-assert to concrete types listed below.
//
// Scalar types:
-// bool, int8, int16, int32, int64,
-// uint8, uint16, uint32, uint64,
-// float32, float64, string
+//
+// bool, int8, int16, int32, int64,
+// uint8, uint16, uint32, uint64,
+// float32, float64, string
//
// Array types: []T for each scalar T above; []string.
// Struct: StructValue
diff --git a/pkg/pva/pvdata/types_test.go b/pkg/pva/pvdata/types_test.go
index d32901b..c40e7a8 100644
--- a/pkg/pva/pvdata/types_test.go
+++ b/pkg/pva/pvdata/types_test.go
@@ -130,7 +130,7 @@ func TestValueDouble(t *testing.T) {
func TestValueIntArray(t *testing.T) {
desc := pvdata.FieldDesc{Kind: pvdata.KindScalarArr, TypeCode: pvdata.TypeCodeIntArr}
- var v pvdata.Value = []int32{1, 2, 3, -7, 1<<30}
+ var v pvdata.Value = []int32{1, 2, 3, -7, 1 << 30}
var buf bytes.Buffer
if err := pvdata.WriteValue(&buf, desc, v); err != nil {
t.Fatal(err)
diff --git a/tools/buildfrontend/main.go b/tools/buildfrontend/main.go
index 08d5c78..c897fa5 100644
--- a/tools/buildfrontend/main.go
+++ b/tools/buildfrontend/main.go
@@ -45,14 +45,14 @@ func main() {
os.MkdirAll(distDir, 0o755)
result := api.Build(api.BuildOptions{
- EntryPoints: []string{filepath.Join(root, "web", "src", "main.tsx")},
- Bundle: true,
- Outdir: distDir,
- Format: api.FormatESModule,
- Target: api.ES2020,
- JSX: api.JSXTransform,
- JSXFactory: "h",
- JSXFragment: "Fragment",
+ EntryPoints: []string{filepath.Join(root, "web", "src", "main.tsx")},
+ Bundle: true,
+ Outdir: distDir,
+ Format: api.FormatESModule,
+ Target: api.ES2020,
+ JSX: api.JSXTransform,
+ JSXFactory: "h",
+ JSXFragment: "Fragment",
MinifyWhitespace: true,
MinifyIdentifiers: true,
MinifySyntax: true,
diff --git a/uopi.example.toml b/uopi.example.toml
index fb3326d..035c7ce 100644
--- a/uopi.example.toml
+++ b/uopi.example.toml
@@ -86,6 +86,11 @@ insecure_skip_verify = false
enabled = false
cert = "" # e.g. "/etc/uopi/tls/cert.pem"
key = "" # e.g. "/etc/uopi/tls/key.pem"
+# Optionally run a plain-HTTP listener that 301-redirects visitors to the HTTPS
+# service, so users who type http:// (or omit the scheme) are upgraded instead of
+# hitting the TLS port with a cleartext request ("client sent an HTTP request to
+# an HTTPS server"). Empty = disabled.
+redirect_from = "" # e.g. ":80"
# Role-based access through group memberships. Each [[groups]] block lists
# members by role; a user's effective global capability is the highest role
diff --git a/web/src/EditMode.tsx b/web/src/EditMode.tsx
index 3e81783..45a9f97 100644
--- a/web/src/EditMode.tsx
+++ b/web/src/EditMode.tsx
@@ -1,6 +1,6 @@
import { h, Fragment } from 'preact';
import { useState, useCallback, useRef, useEffect } from 'preact/hooks';
-import type { Interface, Widget, PlotLayout, StateVar, LogicGraph } from './lib/types';
+import type { Interface, Widget, PlotLayout, StateVar, LogicGraph, Folder } from './lib/types';
import { serializeInterface, parseInterface } from './lib/xml';
import { initLocalState } from './lib/localstate';
import SignalTree from './SignalTree';
@@ -8,6 +8,7 @@ import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas';
import PlotPanelCanvas from './PlotPanelCanvas';
import LogicEditor from './LogicEditor';
import PropertiesPane from './PropertiesPane';
+import ShareDialog from './ShareDialog';
import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl';
import { useAuth, canWrite } from './lib/auth';
@@ -121,6 +122,10 @@ export default function EditMode({ initial, onDone }: Props) {
const [showGroupMenu, setShowGroupMenu] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [helpSection, setHelpSection] = useState('edit');
+ // Panel sharing (folder, public visibility, grants). The folders list feeds
+ // ShareDialog's folder picker; fetched lazily the first time Share is opened.
+ const [showShare, setShowShare] = useState(false);
+ const [folders, setFolders] = useState([]);
const [leftW, setLeftW] = useState(220);
const [rightW, setRightW] = useState(260);
// Center column tab: the drag-and-drop layout editor, or the panel-logic editor.
@@ -163,6 +168,15 @@ export default function EditMode({ initial, onDone }: Props) {
setShowHelp(true);
}, []);
+ // Open the panel-sharing dialog, refreshing the folder list it offers first.
+ const openShare = useCallback(() => {
+ fetch('/api/v1/folders')
+ .then(r => r.ok ? r.json() : [])
+ .then(data => setFolders(Array.isArray(data) ? data : []))
+ .catch(() => {})
+ .finally(() => setShowShare(true));
+ }, []);
+
// Undo / redo. Snapshots capture both widgets and (for plot panels) the split
// layout, so split/close/resize operations are undone atomically.
type Snapshot = { widgets: Widget[]; layout?: PlotLayout };
@@ -748,6 +762,7 @@ export default function EditMode({ initial, onDone }: Props) {
onChange={handleWidgetChange}
onRenameId={handleWidgetRename}
onIfaceChange={handleIfaceChange}
+ onShare={writable && iface.id ? openShare : undefined}
width={rightW}
/>
@@ -797,6 +812,16 @@ export default function EditMode({ initial, onDone }: Props) {
{showHelp && (
setShowHelp(false)} />
)}
+
+ {showShare && iface.id && (
+ setShowShare(false)}
+ onSaved={() => window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'))}
+ />
+ )}
);
}
diff --git a/web/src/PropertiesPane.tsx b/web/src/PropertiesPane.tsx
index 3957122..06781b1 100644
--- a/web/src/PropertiesPane.tsx
+++ b/web/src/PropertiesPane.tsx
@@ -10,6 +10,9 @@ interface Props {
onChange: (updated: Widget) => void;
onRenameId: (oldId: string, newId: string) => void;
onIfaceChange: (updated: Interface) => void;
+ // When provided, a "Share…" button is shown in the canvas-level section that
+ // opens the panel's sharing settings (folder, public visibility, grants).
+ onShare?: () => void;
width?: number;
}
@@ -86,7 +89,7 @@ const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue',
// Widgets with multiple signals
const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled', 'table']);
-export default function PropertiesPane({ selected, multiCount, iface, onChange, onRenameId, onIfaceChange, width }: Props) {
+export default function PropertiesPane({ selected, multiCount, iface, onChange, onRenameId, onIfaceChange, onShare, width }: Props) {
const [collapsed, setCollapsed] = useState(false);
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
@@ -173,6 +176,11 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
)}
+ {onShare && (
+
+ )}
{w && (
diff --git a/web/src/styles.css b/web/src/styles.css
index a6bf8eb..5f5fe5a 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -3016,6 +3016,11 @@ body {
.props-section:last-child { border-bottom: none; }
+.props-share-btn {
+ margin: 0.4rem 0.6rem 0;
+ width: calc(100% - 1.2rem);
+}
+
.props-section-title {
font-size: 0.7rem;
font-weight: 700;
diff --git a/workspace/softioc.log b/workspace/softioc.log
index 524d774..77fbfd0 100644
--- a/workspace/softioc.log
+++ b/workspace/softioc.log
@@ -2,3 +2,5 @@ Starting iocInit
iocRun: All initialization complete
CAS: CA beacon send to 172.21.127.255:5065 ERROR: Network is unreachable
CAS: CA beacon send to 172.21.127.255:5065 ok
+CAS: CA beacon send to 172.21.127.255:5065 ERROR: Network is unreachable
+CAS: CA beacon send to 172.21.127.255:5065 ok