From c0f7e662be17a7a19439e5c9d3d79d925eaf380c Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Wed, 24 Jun 2026 01:39:15 +0200 Subject: [PATCH] Testing --- Makefile | 13 +- cmd/uopi/main.go | 2 +- docs/TECHNICAL_SPEC.md | 5 +- docs/TEST_COVERAGE_REPORT.md | 87 ++++ internal/api/confmgr_rules_test.go | 146 ++++++ internal/api/confmgr_versioning_test.go | 158 +++++++ internal/api/handlers_test.go | 438 ++++++++++++++++++ internal/api/helpers_white_test.go | 112 +++++ internal/api/synthetic_test.go | 175 +++++++ internal/audit/audit_extra_test.go | 91 ++++ internal/broker/broker_bench_test.go | 2 +- internal/broker/broker_stress_test.go | 4 +- internal/broker/extra_test.go | 48 ++ internal/config/config.go | 2 +- internal/config/config_test.go | 82 ++++ internal/confmgr/coerce_test.go | 75 +++ internal/confmgr/cue.go | 8 +- internal/confmgr/model.go | 18 +- internal/confmgr/model_test.go | 134 ++++++ internal/confmgr/snapshot.go | 8 +- internal/confmgr/snapshot_test.go | 6 +- internal/confmgr/store.go | 6 +- internal/confmgr/store_extra_test.go | 118 +++++ internal/controllogic/config_node_test.go | 5 +- internal/controllogic/cron.go | 12 +- internal/controllogic/engine.go | 8 +- internal/controllogic/engine_helpers_test.go | 93 ++++ internal/controllogic/engine_reload_test.go | 180 +++++++ internal/controllogic/expr.go | 6 +- internal/controllogic/model.go | 22 +- internal/controllogic/store_extra_test.go | 77 +++ internal/datasource/epics/archive.go | 10 +- internal/datasource/epics/epics.go | 9 +- internal/datasource/epics/noop.go | 6 +- internal/datasource/stub/stub.go | 2 +- internal/datasource/synthetic/definition.go | 14 +- internal/datasource/synthetic/graph.go | 8 +- .../datasource/synthetic/timestamp_test.go | 4 +- internal/datasource/user_test.go | 28 ++ internal/dsp/coverage_test.go | 145 ++++++ internal/metrics/metrics.go | 10 +- internal/pamauth/stub_test.go | 19 + internal/panelacl/extra_test.go | 80 ++++ internal/panelacl/panelacl.go | 2 +- internal/server/debughub.go | 6 +- internal/server/helpers_test.go | 223 +++++++++ internal/server/server.go | 10 +- internal/server/ws.go | 10 +- internal/storage/store_versioning_test.go | 374 +++++++++++++++ pkg/ca/client.go | 4 +- pkg/ca/client_test.go | 2 +- pkg/ca/conn.go | 20 +- pkg/ca/coverage_internal_test.go | 109 +++++ pkg/ca/errors_test.go | 47 ++ pkg/ca/fault_test.go | 104 +++++ pkg/ca/getctrl_test.go | 57 +++ pkg/ca/helpers_internal_test.go | 139 ++++++ pkg/ca/proto/dbr.go | 26 +- pkg/ca/proto/opcodes.go | 4 +- pkg/ca/proto/proto_extra_test.go | 163 +++++++ pkg/ca/proto/proto_test.go | 2 +- pkg/ca/testca/server.go | 77 ++- pkg/pva/client.go | 6 +- pkg/pva/conn.go | 13 +- pkg/pva/proto.go | 30 +- pkg/pva/proto_test.go | 175 +++++++ pkg/pva/pvdata/bitset.go | 4 +- pkg/pva/pvdata/codec_test.go | 361 +++++++++++++++ pkg/pva/pvdata/types.go | 67 +-- pkg/pva/pvdata/types_test.go | 2 +- tools/buildfrontend/main.go | 16 +- uopi.example.toml | 5 + web/src/EditMode.tsx | 27 +- web/src/PropertiesPane.tsx | 10 +- web/src/styles.css | 5 + workspace/softioc.log | 2 + 76 files changed, 4368 insertions(+), 210 deletions(-) create mode 100644 docs/TEST_COVERAGE_REPORT.md create mode 100644 internal/api/confmgr_rules_test.go create mode 100644 internal/api/confmgr_versioning_test.go create mode 100644 internal/api/handlers_test.go create mode 100644 internal/api/helpers_white_test.go create mode 100644 internal/api/synthetic_test.go create mode 100644 internal/audit/audit_extra_test.go create mode 100644 internal/broker/extra_test.go create mode 100644 internal/confmgr/coerce_test.go create mode 100644 internal/confmgr/model_test.go create mode 100644 internal/confmgr/store_extra_test.go create mode 100644 internal/controllogic/engine_helpers_test.go create mode 100644 internal/controllogic/engine_reload_test.go create mode 100644 internal/controllogic/store_extra_test.go create mode 100644 internal/datasource/user_test.go create mode 100644 internal/dsp/coverage_test.go create mode 100644 internal/pamauth/stub_test.go create mode 100644 internal/panelacl/extra_test.go create mode 100644 internal/server/helpers_test.go create mode 100644 internal/storage/store_versioning_test.go create mode 100644 pkg/ca/coverage_internal_test.go create mode 100644 pkg/ca/errors_test.go create mode 100644 pkg/ca/fault_test.go create mode 100644 pkg/ca/getctrl_test.go create mode 100644 pkg/ca/helpers_internal_test.go create mode 100644 pkg/ca/proto/proto_extra_test.go create mode 100644 pkg/pva/proto_test.go create mode 100644 pkg/pva/pvdata/codec_test.go 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