This commit is contained in:
Martino Ferrari
2026-06-24 01:39:15 +02:00
parent 11120bedca
commit c0f7e662be
76 changed files with 4368 additions and 210 deletions
+12 -1
View File
@@ -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 # # Sources — adding any file here triggers a frontend or backend rebuild #
@@ -89,11 +89,18 @@ release: $(FRONTEND_OUT)
test: test:
go test ./... go test ./...
cd pkg/ca && go test ./... cd pkg/ca && go test ./...
cd pkg/pva && go test ./...
# Run tests with the race detector enabled. # Run tests with the race detector enabled.
race: race:
go test -race ./... go test -race ./...
cd pkg/ca && 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. # Run all benchmarks and print memory allocations.
bench: bench:
@@ -102,6 +109,10 @@ bench:
lint: lint:
go vet ./... 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) run: $(BINARY)
$(BINARY) $(BINARY)
+1 -1
View File
@@ -309,7 +309,7 @@ func main() {
log.Info("built-in TLS enabled", "cert", tlsCert) 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 { if err := srv.Start(ctx); err != nil {
fmt.Fprintf(os.Stderr, "server error: %v\n", err) fmt.Fprintf(os.Stderr, "server error: %v\n", err)
+4 -1
View File
@@ -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 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). 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 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 (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 mutually exclusive where they overlap. Access is **role-based** through group
memberships: each `[[groups]]` block lists members by role along the cumulative ladder memberships: each `[[groups]]` block lists members by role along the cumulative ladder
+87
View File
@@ -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 80100% 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')`).
+146
View File
@@ -0,0 +1,146 @@
package api_test
import (
"net/http"
"testing"
)
// TestConfigRuleCRUD exercises the full config-rule REST surface: list, create,
// get, update, versioning (list/get/promote/fork), check, preview, and delete,
// plus the principal error paths.
func TestConfigRuleCRUD(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// A set the rule (and preview) can bind to.
resp := postJSON(t, srv, "/api/v1/config/sets", map[string]any{
"name": "S",
"parameters": []map[string]any{
{"key": "voltage", "ds": "stub", "signal": "setpoint", "type": "float64", "default": 12.0},
},
})
assertStatus(t, resp, http.StatusCreated)
var set struct {
ID string `json:"id"`
}
readJSON(t, resp, &set)
// Empty list initially.
resp = get(t, srv, "/api/v1/config/rules")
assertStatus(t, resp, http.StatusOK)
var list []map[string]any
readJSON(t, resp, &list)
if len(list) != 0 {
t.Fatalf("expected no rules, got %d", len(list))
}
// Create.
resp = postJSON(t, srv, "/api/v1/config/rules", map[string]any{
"name": "cap",
"setId": set.ID,
"source": "voltage: <=24",
})
assertStatus(t, resp, http.StatusCreated)
var rule struct {
ID string `json:"id"`
Version int `json:"version"`
}
readJSON(t, resp, &rule)
if rule.ID == "" {
t.Fatal("rule missing id")
}
// Create with invalid CUE → 400.
resp = postJSON(t, srv, "/api/v1/config/rules", map[string]any{
"name": "bad",
"setId": set.ID,
"source": "voltage: <=",
})
assertStatus(t, resp, http.StatusBadRequest)
// Create with malformed JSON → 400.
resp = postRaw(t, srv, "/api/v1/config/rules", "application/json", []byte(`{not json`))
assertStatus(t, resp, http.StatusBadRequest)
// Get.
resp = get(t, srv, "/api/v1/config/rules/"+rule.ID)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Get missing → 404.
resp = get(t, srv, "/api/v1/config/rules/nope")
assertStatus(t, resp, http.StatusNotFound)
// Update (bumps version, creates backup).
resp = putRaw(t, srv, "/api/v1/config/rules/"+rule.ID, "application/json",
[]byte(`{"name":"cap2","setId":"`+set.ID+`","source":"voltage: <=30"}`))
assertStatus(t, resp, http.StatusOK)
// List versions.
resp = get(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 rule versions, got %d", len(versions))
}
// Get v1.
resp = get(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Bad version path → 400.
resp = get(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions/xyz")
assertStatus(t, resp, http.StatusBadRequest)
// Promote v1.
resp = postRaw(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusOK)
// Fork v1 → new id.
resp = postRaw(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions/1/fork", "application/json", nil)
assertStatus(t, resp, http.StatusCreated)
var fork struct {
ID string `json:"id"`
}
readJSON(t, resp, &fork)
if fork.ID == "" || fork.ID == rule.ID {
t.Errorf("fork id = %q, want fresh id", fork.ID)
}
// Check (live validation of an unsaved source against sample values).
resp = postJSON(t, srv, "/api/v1/config/rules/check", map[string]any{
"source": "voltage: <=24",
"values": map[string]any{"voltage": 20.0},
})
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Check with malformed JSON → 400.
resp = postRaw(t, srv, "/api/v1/config/rules/check", "application/json", []byte(`{`))
assertStatus(t, resp, http.StatusBadRequest)
// Preview against the bound set's live signals.
resp = postJSON(t, srv, "/api/v1/config/rules/preview", map[string]any{
"setId": set.ID,
"source": "voltage: <=24",
})
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Preview with an unknown set → 404.
resp = postJSON(t, srv, "/api/v1/config/rules/preview", map[string]any{
"setId": "does-not-exist",
"source": "voltage: <=24",
})
assertStatus(t, resp, http.StatusNotFound)
// Delete.
resp = deleteReq(t, srv, "/api/v1/config/rules/"+rule.ID)
assertStatus(t, resp, http.StatusNoContent)
// Delete missing → 404.
resp = deleteReq(t, srv, "/api/v1/config/rules/"+rule.ID)
assertStatus(t, resp, http.StatusNotFound)
}
+158
View File
@@ -0,0 +1,158 @@
package api_test
import (
"net/http"
"testing"
)
// TestConfigSetVersioning exercises the set update/version/promote/fork/diff and
// snapshot endpoints that the base end-to-end test does not reach.
func TestConfigSetVersioning(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
mk := func(name string) string {
resp := postJSON(t, srv, "/api/v1/config/sets", map[string]any{
"name": name,
"parameters": []map[string]any{
{"key": "sp", "ds": "stub", "signal": "setpoint", "type": "float64", "default": 1.0},
},
})
assertStatus(t, resp, http.StatusCreated)
var s struct {
ID string `json:"id"`
}
readJSON(t, resp, &s)
return s.ID
}
id := mk("Set A")
// Update → version bump + backup.
resp := putRaw(t, srv, "/api/v1/config/sets/"+id, "application/json",
[]byte(`{"name":"Set A v2","parameters":[{"key":"sp","ds":"stub","signal":"setpoint","type":"float64","default":2.0}]}`))
assertStatus(t, resp, http.StatusOK)
// List versions.
resp = get(t, srv, "/api/v1/config/sets/"+id+"/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 set versions, got %d", len(versions))
}
// Get a specific version.
resp = get(t, srv, "/api/v1/config/sets/"+id+"/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Promote v1.
resp = postRaw(t, srv, "/api/v1/config/sets/"+id+"/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusOK)
// Fork v1 → new set id.
resp = postRaw(t, srv, "/api/v1/config/sets/"+id+"/versions/1/fork", "application/json", nil)
assertStatus(t, resp, http.StatusCreated)
var fork struct {
ID string `json:"id"`
}
readJSON(t, resp, &fork)
if fork.ID == "" || fork.ID == id {
t.Errorf("fork id = %q, want fresh id", fork.ID)
}
// Diff the original against the fork.
resp = get(t, srv, "/api/v1/config/sets/diff?a="+id+"&b="+fork.ID)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Diff with a missing left id → 400.
resp = get(t, srv, "/api/v1/config/sets/diff?a=&b="+fork.ID)
assertStatus(t, resp, http.StatusBadRequest)
// Snapshot the set into a new instance.
resp = postJSON(t, srv, "/api/v1/config/sets/"+id+"/snapshot", map[string]any{"name": "snap-1"})
assertStatus(t, resp, http.StatusCreated)
resp.Body.Close()
}
// TestConfigInstanceVersioning exercises the instance update/version/promote/
// fork/validate and live-diff endpoints.
func TestConfigInstanceVersioning(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// A set to bind instances to.
resp := postJSON(t, srv, "/api/v1/config/sets", map[string]any{
"name": "Set B",
"parameters": []map[string]any{
{"key": "sp", "ds": "stub", "signal": "setpoint", "type": "float64", "default": 1.0},
},
})
assertStatus(t, resp, http.StatusCreated)
var set struct {
ID string `json:"id"`
}
readJSON(t, resp, &set)
// Create an instance.
resp = postJSON(t, srv, "/api/v1/config/instances", map[string]any{
"name": "inst", "setId": set.ID, "values": map[string]any{"sp": 5.0},
})
assertStatus(t, resp, http.StatusCreated)
var inst struct {
ID string `json:"id"`
}
readJSON(t, resp, &inst)
// Get it.
resp = get(t, srv, "/api/v1/config/instances/"+inst.ID)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// List instances.
resp = get(t, srv, "/api/v1/config/instances")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Update → version bump.
resp = putRaw(t, srv, "/api/v1/config/instances/"+inst.ID, "application/json",
[]byte(`{"name":"inst v2","setId":"`+set.ID+`","values":{"sp":7.0}}`))
assertStatus(t, resp, http.StatusOK)
// List versions.
resp = get(t, srv, "/api/v1/config/instances/"+inst.ID+"/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 instance versions, got %d", len(versions))
}
// Get a version.
resp = get(t, srv, "/api/v1/config/instances/"+inst.ID+"/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Promote + fork.
resp = postRaw(t, srv, "/api/v1/config/instances/"+inst.ID+"/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusOK)
resp = postRaw(t, srv, "/api/v1/config/instances/"+inst.ID+"/versions/1/fork", "application/json", nil)
assertStatus(t, resp, http.StatusCreated)
resp.Body.Close()
// Validate against the (empty) rule set → 200.
resp = postJSON(t, srv, "/api/v1/config/instances/"+inst.ID+"/validate", nil)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Live diff against current signal values → 200.
resp = get(t, srv, "/api/v1/config/instances/"+inst.ID+"/livediff")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Delete the instance.
resp = deleteReq(t, srv, "/api/v1/config/instances/"+inst.ID)
assertStatus(t, resp, http.StatusNoContent)
}
+438
View File
@@ -0,0 +1,438 @@
package api_test
import (
"encoding/json"
"io"
"net/http"
"testing"
)
// These tests exercise the many handlers reachable through the default setup()
// harness, whose policy is unconfigured (anonymous caller → write/admin), so
// permission gates default to "allow" and the focus is handler behaviour.
// ── /me ───────────────────────────────────────────────────────────────────────
func TestGetMe(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := get(t, srv, "/api/v1/me")
assertStatus(t, resp, http.StatusOK)
var me map[string]any
readJSON(t, resp, &me)
for _, k := range []string{"user", "level", "groups", "canEditLogic", "canViewAudit", "canAdmin", "defaultZoom"} {
if _, ok := me[k]; !ok {
t.Errorf("/me missing key %q", k)
}
}
// Unconfigured policy → anonymous caller has write level.
if me["level"] != "write" {
t.Errorf("level = %v, want write", me["level"])
}
}
// ── /groups (signal group tree) ───────────────────────────────────────────────
func TestGroupsRoundTrip(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// Initially valid JSON.
resp := get(t, srv, "/api/v1/groups")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Replace with a JSON array → 204.
resp = putRaw(t, srv, "/api/v1/groups", "application/json", []byte(`[{"name":"A"},{"name":"B"}]`))
assertStatus(t, resp, http.StatusNoContent)
// Read back what we wrote.
resp = get(t, srv, "/api/v1/groups")
assertStatus(t, resp, http.StatusOK)
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var tree []map[string]any
if err := json.Unmarshal(body, &tree); err != nil {
t.Fatalf("groups body not a JSON array: %v (%s)", err, body)
}
if len(tree) != 2 {
t.Fatalf("expected 2 groups, got %d", len(tree))
}
// A non-array body is rejected.
resp = putRaw(t, srv, "/api/v1/groups", "application/json", []byte(`{"not":"array"}`))
assertStatus(t, resp, http.StatusBadRequest)
}
// ── /usergroups ───────────────────────────────────────────────────────────────
func TestListUserGroups(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := get(t, srv, "/api/v1/usergroups")
assertStatus(t, resp, http.StatusOK)
var names []string
readJSON(t, resp, &names)
// Unconfigured policy has no named groups; the handler must still return [].
if names == nil {
t.Error("expected non-nil (possibly empty) group list")
}
}
// ── /folders ──────────────────────────────────────────────────────────────────
func TestFolderCRUD(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// List — initially empty.
resp := get(t, srv, "/api/v1/folders")
assertStatus(t, resp, http.StatusOK)
var list []any
readJSON(t, resp, &list)
if len(list) != 0 {
t.Fatalf("expected no folders, got %d", len(list))
}
// Missing name → 400.
resp = postJSON(t, srv, "/api/v1/folders", map[string]any{"name": ""})
assertStatus(t, resp, http.StatusBadRequest)
// Create.
resp = postJSON(t, srv, "/api/v1/folders", map[string]any{"name": "Reactor"})
assertStatus(t, resp, http.StatusCreated)
var created struct {
ID string `json:"id"`
}
readJSON(t, resp, &created)
if created.ID == "" {
t.Fatal("expected folder id")
}
// Update.
resp = putRaw(t, srv, "/api/v1/folders/"+created.ID, "application/json",
[]byte(`{"name":"Reactor Hall"}`))
assertStatus(t, resp, http.StatusNoContent)
// Update with empty name → 400.
resp = putRaw(t, srv, "/api/v1/folders/"+created.ID, "application/json", []byte(`{"name":""}`))
assertStatus(t, resp, http.StatusBadRequest)
// Update unknown folder → 404.
resp = putRaw(t, srv, "/api/v1/folders/fld-nope", "application/json", []byte(`{"name":"x"}`))
assertStatus(t, resp, http.StatusNotFound)
// It now appears in the list.
resp = get(t, srv, "/api/v1/folders")
assertStatus(t, resp, http.StatusOK)
readJSON(t, resp, &list)
if len(list) != 1 {
t.Fatalf("expected 1 folder, got %d", len(list))
}
// Delete.
resp = deleteReq(t, srv, "/api/v1/folders/"+created.ID)
assertStatus(t, resp, http.StatusNoContent)
// Delete unknown → 404.
resp = deleteReq(t, srv, "/api/v1/folders/fld-nope")
assertStatus(t, resp, http.StatusNotFound)
}
// ── /interfaces/{id}/acl ──────────────────────────────────────────────────────
func TestPanelACL(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
assertStatus(t, resp, http.StatusCreated)
var created struct {
ID string `json:"id"`
}
readJSON(t, resp, &created)
// GET ACL of a fresh panel.
resp = get(t, srv, "/api/v1/interfaces/"+created.ID+"/acl")
assertStatus(t, resp, http.StatusOK)
var acl map[string]any
readJSON(t, resp, &acl)
if _, ok := acl["perm"]; !ok {
t.Error("ACL response missing perm")
}
// PUT ACL — set a public read level.
resp = putRaw(t, srv, "/api/v1/interfaces/"+created.ID+"/acl", "application/json",
[]byte(`{"public":"read","grants":[]}`))
assertStatus(t, resp, http.StatusNoContent)
// PUT ACL on a non-existent panel → 404.
resp = putRaw(t, srv, "/api/v1/interfaces/nope/acl", "application/json",
[]byte(`{"public":"read"}`))
assertStatus(t, resp, http.StatusNotFound)
// PUT ACL referencing an unknown folder → 400.
resp = putRaw(t, srv, "/api/v1/interfaces/"+created.ID+"/acl", "application/json",
[]byte(`{"folder":"fld-nope"}`))
assertStatus(t, resp, http.StatusBadRequest)
}
// ── /interfaces/reorder ───────────────────────────────────────────────────────
func TestReorderInterfaces(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
mk := func() string {
resp := postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
assertStatus(t, resp, http.StatusCreated)
var c struct {
ID string `json:"id"`
}
readJSON(t, resp, &c)
return c.ID
}
a, b := mk(), mk()
// Reorder at root (no folder).
resp := postJSON(t, srv, "/api/v1/interfaces/reorder", map[string]any{"ids": []string{b, a}})
assertStatus(t, resp, http.StatusNoContent)
// Reorder into a non-existent folder → 404.
resp = postJSON(t, srv, "/api/v1/interfaces/reorder",
map[string]any{"folder": "fld-nope", "ids": []string{a}})
assertStatus(t, resp, http.StatusNotFound)
// Malformed body → 400.
resp = postRaw(t, srv, "/api/v1/interfaces/reorder", "application/json", []byte(`{not json`))
assertStatus(t, resp, http.StatusBadRequest)
}
// ── /interfaces/{id}/versions ─────────────────────────────────────────────────
func TestInterfaceVersioning(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
assertStatus(t, resp, http.StatusCreated)
var created struct {
ID string `json:"id"`
}
readJSON(t, resp, &created)
id := created.ID
// Update once to create a backup (v1) and bump current to v2.
upd := `<interface id="" name="Test Panel" version="2" w="800" h="600"></interface>`
resp = putRaw(t, srv, "/api/v1/interfaces/"+id, "application/xml", []byte(upd))
assertStatus(t, resp, http.StatusNoContent)
// List versions — at least the backup.
resp = get(t, srv, "/api/v1/interfaces/"+id+"/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 versions, got %d", len(versions))
}
// Get the v1 backup.
resp = get(t, srv, "/api/v1/interfaces/"+id+"/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Bad version path → 400.
resp = get(t, srv, "/api/v1/interfaces/"+id+"/versions/abc")
assertStatus(t, resp, http.StatusBadRequest)
// Tag v1.
resp = putRaw(t, srv, "/api/v1/interfaces/"+id+"/versions/1/tag?tag=golden", "application/json", nil)
assertStatus(t, resp, http.StatusNoContent)
// Promote v1 → becomes new current.
resp = postRaw(t, srv, "/api/v1/interfaces/"+id+"/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusNoContent)
// Fork v1 → brand-new panel.
resp = postRaw(t, srv, "/api/v1/interfaces/"+id+"/versions/1/fork", "application/json", nil)
assertStatus(t, resp, http.StatusCreated)
var fork struct {
ID string `json:"id"`
}
readJSON(t, resp, &fork)
if fork.ID == "" || fork.ID == id {
t.Errorf("fork id = %q, want fresh id", fork.ID)
}
// Version ops on a missing panel → 404.
resp = get(t, srv, "/api/v1/interfaces/missing/versions")
assertStatus(t, resp, http.StatusNotFound)
}
// ── /controllogic ─────────────────────────────────────────────────────────────
func TestControlLogicCRUD(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// Empty list initially.
resp := get(t, srv, "/api/v1/controllogic")
assertStatus(t, resp, http.StatusOK)
var list []any
readJSON(t, resp, &list)
if len(list) != 0 {
t.Fatalf("expected no control logic, got %d", len(list))
}
// Create.
resp = postJSON(t, srv, "/api/v1/controllogic", map[string]any{"name": "Watchdog", "enabled": true})
assertStatus(t, resp, http.StatusCreated)
var g struct {
ID string `json:"id"`
Version int `json:"version"`
}
readJSON(t, resp, &g)
if g.ID == "" {
t.Fatal("expected control logic id")
}
// Get.
resp = get(t, srv, "/api/v1/controllogic/"+g.ID)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Get missing → 404.
resp = get(t, srv, "/api/v1/controllogic/nope")
assertStatus(t, resp, http.StatusNotFound)
// Update (bumps version, creates a backup).
resp = putRaw(t, srv, "/api/v1/controllogic/"+g.ID, "application/json",
[]byte(`{"name":"Watchdog v2","enabled":false}`))
assertStatus(t, resp, http.StatusOK)
// List versions.
resp = get(t, srv, "/api/v1/controllogic/"+g.ID+"/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 control-logic versions, got %d", len(versions))
}
// Promote v1.
resp = postRaw(t, srv, "/api/v1/controllogic/"+g.ID+"/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusOK)
// Fork v1 → new graph id.
resp = postRaw(t, srv, "/api/v1/controllogic/"+g.ID+"/versions/1/fork", "application/json", nil)
assertStatus(t, resp, http.StatusCreated)
var fork struct {
ID string `json:"id"`
}
readJSON(t, resp, &fork)
if fork.ID == "" || fork.ID == g.ID {
t.Errorf("fork id = %q, want fresh id", fork.ID)
}
// Delete.
resp = deleteReq(t, srv, "/api/v1/controllogic/"+g.ID)
assertStatus(t, resp, http.StatusNoContent)
// Delete missing → 404.
resp = deleteReq(t, srv, "/api/v1/controllogic/"+g.ID)
assertStatus(t, resp, http.StatusNotFound)
}
// ── /audit (Nop audit log, anonymous can view on an open policy) ───────────────
func TestGetAuditOpen(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := get(t, srv, "/api/v1/audit")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Bad start time → 400.
resp = get(t, srv, "/api/v1/audit?start=not-a-time")
assertStatus(t, resp, http.StatusBadRequest)
}
// ── /synthetic disabled guards ────────────────────────────────────────────────
// Synthetic is nil in the default harness, so every route must report 503
// (service unavailable) rather than panicking.
func TestSyntheticDisabledRoutes(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
cases := []struct {
method, path string
body []byte
}{
{http.MethodGet, "/api/v1/synthetic/x", nil},
{http.MethodPut, "/api/v1/synthetic/x", []byte(`{}`)},
{http.MethodDelete, "/api/v1/synthetic/x", nil},
{http.MethodPost, "/api/v1/synthetic/trace", []byte(`{}`)},
{http.MethodGet, "/api/v1/synthetic/x/versions", nil},
{http.MethodGet, "/api/v1/synthetic/x/versions/1", nil},
{http.MethodPost, "/api/v1/synthetic/x/versions/1/promote", nil},
{http.MethodPost, "/api/v1/synthetic/x/versions/1/fork", nil},
}
for _, c := range cases {
var resp *http.Response
switch c.method {
case http.MethodGet:
resp = get(t, srv, c.path)
case http.MethodPut:
resp = putRaw(t, srv, c.path, "application/json", c.body)
case http.MethodDelete:
resp = deleteReq(t, srv, c.path)
case http.MethodPost:
resp = postRaw(t, srv, c.path, "application/json", c.body)
}
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("%s %s: status %d, want 503", c.method, c.path, resp.StatusCode)
}
resp.Body.Close()
}
}
// ── /controllogic/{id}/versions/{version} ─────────────────────────────────────
func TestControlLogicGetVersion(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := postJSON(t, srv, "/api/v1/controllogic", map[string]any{"name": "G"})
assertStatus(t, resp, http.StatusCreated)
var g struct {
ID string `json:"id"`
}
readJSON(t, resp, &g)
// Bump so a v1 backup exists.
resp = putRaw(t, srv, "/api/v1/controllogic/"+g.ID, "application/json", []byte(`{"name":"G2"}`))
assertStatus(t, resp, http.StatusOK)
resp = get(t, srv, "/api/v1/controllogic/"+g.ID+"/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Bad version number → 400.
resp = get(t, srv, "/api/v1/controllogic/"+g.ID+"/versions/xyz")
assertStatus(t, resp, http.StatusBadRequest)
}
// ── /config/instances/diff ────────────────────────────────────────────────────
func TestDiffConfigInstancesMissing(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// Missing left id → 400.
resp := get(t, srv, "/api/v1/config/instances/diff?a=&b=")
assertStatus(t, resp, http.StatusBadRequest)
}
+112
View File
@@ -0,0 +1,112 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/panelacl"
)
// TestExtractLogicBlock covers the three branches of the <logic> extractor.
func TestExtractLogicBlock(t *testing.T) {
if got := extractLogicBlock([]byte(`<i><logic><n/></logic></i>`)); got != "<logic><n/></logic>" {
t.Errorf("extract = %q", got)
}
if got := extractLogicBlock([]byte(`<i></i>`)); got != "" {
t.Errorf("no logic: want empty, got %q", got)
}
if got := extractLogicBlock([]byte(`<i><logic>unterminated`)); got != "" {
t.Errorf("unterminated: want empty, got %q", got)
}
}
// TestDataTypeName covers every DataType→token mapping plus the default.
func TestDataTypeName(t *testing.T) {
cases := map[datasource.DataType]string{
datasource.TypeFloat64: "float64",
datasource.TypeFloat64Array: "float64[]",
datasource.TypeString: "string",
datasource.TypeInt64: "int64",
datasource.TypeBool: "bool",
datasource.TypeEnum: "enum",
datasource.DataType(255): "unknown",
}
for typ, want := range cases {
if got := dataTypeName(typ); got != want {
t.Errorf("dataTypeName(%v) = %q, want %q", typ, got, want)
}
}
}
// TestSynVisible covers each visibility branch of synVisible.
func TestSynVisible(t *testing.T) {
cases := []struct {
name string
def synthetic.SignalDef
user string
panel string
groups []string
want bool
}{
{"user own", synthetic.SignalDef{Visibility: "user", Owner: "alice"}, "alice", "", nil, true},
{"user other", synthetic.SignalDef{Visibility: "user", Owner: "alice"}, "bob", "", nil, false},
{"panel match", synthetic.SignalDef{Visibility: "panel", Panel: "p1"}, "bob", "p1", nil, true},
{"panel mismatch", synthetic.SignalDef{Visibility: "panel", Panel: "p1"}, "bob", "p2", nil, false},
{"global", synthetic.SignalDef{Visibility: "global"}, "", "", nil, true},
{"legacy empty", synthetic.SignalDef{}, "", "", nil, true},
{"group member", synthetic.SignalDef{Visibility: "group", Owner: "alice", Groups: []string{"ops"}}, "bob", "", []string{"ops"}, true},
{"group outsider", synthetic.SignalDef{Visibility: "group", Owner: "alice", Groups: []string{"ops"}}, "bob", "", []string{"hr"}, false},
}
for _, tc := range cases {
if got := synVisible(tc.def, tc.user, tc.panel, tc.groups); got != tc.want {
t.Errorf("%s: synVisible = %v, want %v", tc.name, got, tc.want)
}
}
}
// TestPanelScope covers the nil/public→global, group, and private branches.
func TestPanelScope(t *testing.T) {
if sc, _ := panelScope(nil); sc != access.ScopeGlobal {
t.Errorf("nil acl: scope = %q, want global", sc)
}
if sc, _ := panelScope(&panelacl.PanelACL{Public: "read"}); sc != access.ScopeGlobal {
t.Errorf("public acl: scope = %q, want global", sc)
}
sc, groups := panelScope(&panelacl.PanelACL{
Grants: []panelacl.Grant{{Kind: "group", Name: "ops"}, {Kind: "user", Name: "x"}},
})
if sc != access.ScopeGroup || len(groups) != 1 || groups[0] != "ops" {
t.Errorf("group acl: scope=%q groups=%v", sc, groups)
}
if sc, _ := panelScope(&panelacl.PanelACL{Owner: "alice"}); sc != access.ScopePrivate {
t.Errorf("private acl: scope = %q, want private", sc)
}
}
// TestClientIP covers the XFF, X-Real-IP, host:port, and bare-RemoteAddr cases.
func TestClientIP(t *testing.T) {
mk := func(set func(*http.Request)) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/", nil)
set(r)
return r
}
if got := clientIP(mk(func(r *http.Request) { r.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8") })); got != "1.2.3.4" {
t.Errorf("XFF list: got %q", got)
}
if got := clientIP(mk(func(r *http.Request) { r.Header.Set("X-Forwarded-For", "9.9.9.9") })); got != "9.9.9.9" {
t.Errorf("XFF single: got %q", got)
}
if got := clientIP(mk(func(r *http.Request) { r.Header.Set("X-Real-IP", "8.8.8.8") })); got != "8.8.8.8" {
t.Errorf("X-Real-IP: got %q", got)
}
if got := clientIP(mk(func(r *http.Request) { r.RemoteAddr = "10.0.0.1:5555" })); got != "10.0.0.1" {
t.Errorf("host:port: got %q", got)
}
if got := clientIP(mk(func(r *http.Request) { r.RemoteAddr = "bare-addr" })); got != "bare-addr" {
t.Errorf("bare addr: got %q", got)
}
}
+175
View File
@@ -0,0 +1,175 @@
package api_test
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/storage"
)
// setupSynthetic builds an API server whose synthetic data source is enabled, so
// the synthetic CRUD/versioning/trace handlers run their real bodies.
func setupSynthetic(t *testing.T) (*httptest.Server, func()) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
log := slog.New(slog.NewTextHandler(io.Discard, nil))
brk := broker.New(ctx, log)
ds := stub.New()
if err := ds.Connect(ctx); err != nil {
t.Fatal("stub connect:", err)
}
brk.Register(ds)
dir := t.TempDir()
store, _ := storage.New(dir)
acl, _ := panelacl.New(dir)
clStore, _ := controllogic.NewStore(dir)
cfgStore, _ := confmgr.New(dir)
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
synthDS := synthetic.New(dir, brk, log)
if err := synthDS.Connect(ctx); err != nil {
t.Fatal("synthetic connect:", err)
}
brk.Register(synthDS)
mux := http.NewServeMux()
api.New(brk, synthDS, store, cfgStore, access.New("", nil), acl, clStore, clEngine, audit.Nop(), "", "", 0, log).Register(mux, "/api/v1")
srv := httptest.NewServer(mux)
return srv, func() { srv.Close(); cancel() }
}
// putJSON marshals body and issues a PUT, mirroring postJSON.
func putJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response {
t.Helper()
b, err := json.Marshal(body)
if err != nil {
t.Fatal("json.Marshal:", err)
}
return putRaw(t, srv, path, "application/json", b)
}
// syntheticBody returns a minimal valid graph signal sourced from the stub's
// "sine" signal through a gain op.
func syntheticBody(name string) map[string]any {
return map[string]any{
"name": name,
"visibility": "global",
"graph": map[string]any{
"output": "out",
"nodes": []map[string]any{
{"id": "a", "kind": "source", "ds": "stub", "signal": "sine"},
{"id": "g", "kind": "op", "op": "gain", "inputs": []string{"a"},
"params": map[string]any{"k": 2.0}},
{"id": "out", "kind": "output", "inputs": []string{"g"}},
},
},
}
}
func TestSyntheticCRUDEnabled(t *testing.T) {
srv, teardown := setupSynthetic(t)
defer teardown()
// List — initially empty.
resp := get(t, srv, "/api/v1/synthetic")
assertStatus(t, resp, http.StatusOK)
var list []any
readJSON(t, resp, &list)
if len(list) != 0 {
t.Fatalf("expected no synthetic signals, got %d", len(list))
}
// Create.
resp = postJSON(t, srv, "/api/v1/synthetic", syntheticBody("doubled"))
assertStatus(t, resp, http.StatusCreated)
resp.Body.Close()
// Duplicate create → 400 (AddSignal rejects an existing name).
resp = postJSON(t, srv, "/api/v1/synthetic", syntheticBody("doubled"))
assertStatus(t, resp, http.StatusBadRequest)
// Invalid JSON → 400.
resp = postRaw(t, srv, "/api/v1/synthetic", "application/json", []byte(`{bad`))
assertStatus(t, resp, http.StatusBadRequest)
// Get.
resp = get(t, srv, "/api/v1/synthetic/doubled")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Get missing → 404.
resp = get(t, srv, "/api/v1/synthetic/nope")
assertStatus(t, resp, http.StatusNotFound)
// Update (changes gain factor).
upd := syntheticBody("doubled")
upd["graph"].(map[string]any)["nodes"].([]map[string]any)[1]["params"] = map[string]any{"k": 3.0}
resp = putJSON(t, srv, "/api/v1/synthetic/doubled", upd)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Update missing → 404.
resp = putJSON(t, srv, "/api/v1/synthetic/nope", syntheticBody("nope"))
assertStatus(t, resp, http.StatusNotFound)
// Versions list (the update created a backup).
resp = get(t, srv, "/api/v1/synthetic/doubled/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 synthetic versions, got %d", len(versions))
}
// Get v1.
resp = get(t, srv, "/api/v1/synthetic/doubled/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Bad version → 400.
resp = get(t, srv, "/api/v1/synthetic/doubled/versions/xyz")
assertStatus(t, resp, http.StatusBadRequest)
// Promote v1.
resp = postRaw(t, srv, "/api/v1/synthetic/doubled/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Fork v1 → new signal.
resp = postRaw(t, srv, "/api/v1/synthetic/doubled/versions/1/fork", "application/json", nil)
assertStatus(t, resp, http.StatusCreated)
resp.Body.Close()
// Trace an unsaved graph.
resp = postJSON(t, srv, "/api/v1/synthetic/trace", syntheticBody("scratch"))
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Trace with invalid JSON → 400.
resp = postRaw(t, srv, "/api/v1/synthetic/trace", "application/json", []byte(`{bad`))
assertStatus(t, resp, http.StatusBadRequest)
// Delete.
resp = deleteReq(t, srv, "/api/v1/synthetic/doubled")
assertStatus(t, resp, http.StatusNoContent)
// Delete missing → 404.
resp = deleteReq(t, srv, "/api/v1/synthetic/doubled")
assertStatus(t, resp, http.StatusNotFound)
}
+91
View File
@@ -0,0 +1,91 @@
package audit
import (
"log/slog"
"path/filepath"
"testing"
"time"
)
// TestNopRecorder covers the disabled-auditing no-op implementation.
func TestNopRecorder(t *testing.T) {
rec := Nop()
rec.Record(Event{Action: "x"}) // must not panic
got, err := rec.Query(Filter{})
if err != nil {
t.Fatalf("Nop Query: %v", err)
}
if len(got) != 0 {
t.Errorf("Nop Query returned %d events, want 0", len(got))
}
if err := rec.Close(); err != nil {
t.Errorf("Nop Close: %v", err)
}
}
// TestQueryFilters covers the End, DS, and Signal (LIKE) filter branches plus
// the default-outcome and zero-time stamping in Record.
func TestQueryFilters(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.db")
rec, err := NewSQLite(path, slog.Default())
if err != nil {
t.Fatal("NewSQLite:", err)
}
defer rec.Close()
// Anchor base in the past so the zero-Time 'c' record (stamped with now)
// sorts after a/b and stays out of the End window below.
base := time.Now().Add(-time.Hour)
rec.Record(Event{Time: base, Actor: "a", ActorType: ActorUser, Action: "signal.write", DS: "epics", Signal: "PV:TEMP"})
rec.Record(Event{Time: base.Add(time.Second), Actor: "b", ActorType: ActorUser, Action: "signal.write", DS: "synthetic", Signal: "PV:FLOW"})
// Zero Time and empty Outcome exercise the defaulting branches in Record.
rec.Record(Event{Actor: "c", ActorType: ActorUser, Action: "interface.update"})
if err := rec.Close(); err != nil {
t.Fatal("Close:", err)
}
rec, err = NewSQLite(path, slog.Default())
if err != nil {
t.Fatal("reopen:", err)
}
defer rec.Close()
// End filter: only events at or before base.
upTo, err := rec.Query(Filter{End: base.Add(500 * time.Millisecond)})
if err != nil {
t.Fatal("Query end:", err)
}
if len(upTo) != 1 || upTo[0].Actor != "a" {
t.Errorf("end filter = %+v, want one 'a' event", upTo)
}
// DS filter.
byDS, err := rec.Query(Filter{DS: "synthetic"})
if err != nil {
t.Fatal("Query ds:", err)
}
if len(byDS) != 1 || byDS[0].Signal != "PV:FLOW" {
t.Errorf("ds filter = %+v, want one PV:FLOW event", byDS)
}
// Signal LIKE filter (substring match).
bySignal, err := rec.Query(Filter{Signal: "TEMP"})
if err != nil {
t.Fatal("Query signal:", err)
}
if len(bySignal) != 1 || bySignal[0].DS != "epics" {
t.Errorf("signal filter = %+v, want one epics event", bySignal)
}
// The zero-time/empty-outcome record was persisted with a default outcome.
def, err := rec.Query(Filter{Actor: "c"})
if err != nil {
t.Fatal("Query actor c:", err)
}
if len(def) != 1 || def[0].Outcome != OutcomeOK {
t.Errorf("defaulted record = %+v, want one event with outcome ok", def)
}
if def[0].Time.IsZero() {
t.Error("zero Time should have been stamped with now")
}
}
+48
View File
@@ -0,0 +1,48 @@
package broker_test
import (
"context"
"testing"
"time"
"github.com/uopi/uopi/internal/broker"
)
// TestDataSourcesAndSource covers the registry accessors.
func TestDataSourcesAndSource(t *testing.T) {
b, cancel := newBroker(t)
defer cancel()
all := b.DataSources()
if len(all) != 1 || all[0].Name() != "stub" {
t.Fatalf("DataSources = %v, want one 'stub'", all)
}
if ds, ok := b.Source("stub"); !ok || ds.Name() != "stub" {
t.Errorf("Source(stub) = %v,%v want stub,true", ds, ok)
}
if _, ok := b.Source("nope"); ok {
t.Error("Source(nope): want ok=false")
}
}
// TestReadNow covers the one-shot read happy path plus the unknown-DS and
// context-timeout error branches.
func TestReadNow(t *testing.T) {
b, cancel := newBroker(t)
defer cancel()
ctx, c := context.WithTimeout(context.Background(), time.Second)
defer c()
v, err := b.ReadNow(ctx, broker.SignalRef{DS: "stub", Name: "sine_1hz"})
if err != nil {
t.Fatalf("ReadNow: %v", err)
}
if v.Timestamp.IsZero() {
t.Error("ReadNow returned a zero-timestamp value")
}
// Unknown data source.
if _, err := b.ReadNow(ctx, broker.SignalRef{DS: "ghost", Name: "x"}); err == nil {
t.Error("ReadNow(unknown ds): want error")
}
}
+82
View File
@@ -112,6 +112,88 @@ func TestEnvOverrides(t *testing.T) {
} }
} }
func TestEnvOverridesAuthTLSAndMisc(t *testing.T) {
t.Setenv("UOPI_SERVER_MAX_UPDATE_RATE_HZ", "25.5")
t.Setenv("UOPI_SERVER_TRUSTED_USER_HEADER", "X-Forwarded-User")
t.Setenv("UOPI_SERVER_DEFAULT_USER", "svc")
t.Setenv("UOPI_SERVER_KERBEROS_ENABLED", "true")
t.Setenv("UOPI_SERVER_KERBEROS_KEYTAB", "/etc/krb.keytab")
t.Setenv("UOPI_SERVER_KERBEROS_SERVICE_PRINCIPAL", "HTTP/host")
t.Setenv("UOPI_SERVER_BASIC_AUTH_ENABLED", "YES")
t.Setenv("UOPI_SERVER_BASIC_AUTH_PAM_SERVICE", "login")
t.Setenv("UOPI_SERVER_TLS_ENABLED", "true")
t.Setenv("UOPI_SERVER_TLS_CERT", "/c.pem")
t.Setenv("UOPI_SERVER_TLS_KEY", "/k.pem")
t.Setenv("UOPI_SERVER_LDAP_ENABLED", "true")
t.Setenv("UOPI_SERVER_LDAP_URI", "ldaps://a ldaps://b")
t.Setenv("UOPI_SERVER_LDAP_SEARCH_BASE", "dc=x,dc=y")
t.Setenv("UOPI_SERVER_LDAP_BIND_DN", "cn=svc")
t.Setenv("UOPI_SERVER_LDAP_BIND_PASSWORD", "secret")
t.Setenv("UOPI_AUDIT_ENABLED", "true")
t.Setenv("UOPI_AUDIT_DB_PATH", "/audit.db")
t.Setenv("UOPI_EPICS_AUTO_SYNC_FILTER", "area=SR")
t.Setenv("UOPI_EPICS_AUTO_SYNC_FROM_ARCHIVER", "true")
t.Setenv("EPICS_PVA_ADDR_LIST", "10.1.1.1 10.1.1.2")
t.Setenv("UOPI_UI_DEFAULT_ZOOM", "1.5")
cfg, err := config.Load("")
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.MaxUpdateRateHz != 25.5 {
t.Errorf("MaxUpdateRateHz = %v, want 25.5", cfg.Server.MaxUpdateRateHz)
}
if cfg.Server.TrustedUserHeader != "X-Forwarded-User" {
t.Errorf("TrustedUserHeader = %q", cfg.Server.TrustedUserHeader)
}
if cfg.Server.DefaultUser != "svc" {
t.Errorf("DefaultUser = %q", cfg.Server.DefaultUser)
}
if !cfg.Server.Kerberos.Enabled || cfg.Server.Kerberos.Keytab != "/etc/krb.keytab" || cfg.Server.Kerberos.ServicePrincipal != "HTTP/host" {
t.Errorf("Kerberos = %+v", cfg.Server.Kerberos)
}
if !cfg.Server.BasicAuth.Enabled || cfg.Server.BasicAuth.PAMService != "login" {
t.Errorf("BasicAuth = %+v", cfg.Server.BasicAuth)
}
if !cfg.Server.TLS.Enabled || cfg.Server.TLS.Cert != "/c.pem" || cfg.Server.TLS.Key != "/k.pem" {
t.Errorf("TLS = %+v", cfg.Server.TLS)
}
if !cfg.Server.LDAP.Enabled || len(cfg.Server.LDAP.URIs) != 2 ||
cfg.Server.LDAP.SearchBase != "dc=x,dc=y" || cfg.Server.LDAP.BindDN != "cn=svc" ||
cfg.Server.LDAP.BindPassword != "secret" {
t.Errorf("LDAP = %+v", cfg.Server.LDAP)
}
if !cfg.Audit.Enabled || cfg.Audit.DBPath != "/audit.db" {
t.Errorf("Audit = %+v", cfg.Audit)
}
if cfg.Datasource.EPICS.AutoSyncFilter != "area=SR" || !cfg.Datasource.EPICS.AutoSyncFromArchiver {
t.Errorf("EPICS sync = %+v", cfg.Datasource.EPICS)
}
if len(cfg.Datasource.PVA.AddrList) != 2 {
t.Errorf("PVA AddrList = %+v", cfg.Datasource.PVA.AddrList)
}
if cfg.UI.DefaultZoom != 1.5 {
t.Errorf("UI.DefaultZoom = %v, want 1.5", cfg.UI.DefaultZoom)
}
}
func TestEnvInvalidNumbersIgnored(t *testing.T) {
t.Setenv("UOPI_SERVER_MAX_UPDATE_RATE_HZ", "not-a-number")
t.Setenv("UOPI_UI_DEFAULT_ZOOM", "xyz")
cfg, err := config.Load("")
if err != nil {
t.Fatalf("Load: %v", err)
}
// Invalid floats are ignored, leaving the defaults intact.
if cfg.Server.MaxUpdateRateHz != config.Default().Server.MaxUpdateRateHz {
t.Errorf("invalid MaxUpdateRateHz should be ignored, got %v", cfg.Server.MaxUpdateRateHz)
}
if cfg.UI.DefaultZoom != config.Default().UI.DefaultZoom {
t.Errorf("invalid DefaultZoom should be ignored, got %v", cfg.UI.DefaultZoom)
}
}
func TestEnvTrimsWhitespace(t *testing.T) { func TestEnvTrimsWhitespace(t *testing.T) {
t.Setenv("UOPI_SERVER_LISTEN", " :8888 ") t.Setenv("UOPI_SERVER_LISTEN", " :8888 ")
cfg, err := config.Load("") cfg, err := config.Load("")
+75
View File
@@ -0,0 +1,75 @@
package confmgr
import (
"reflect"
"testing"
)
// TestCoerceSnapshot covers the alternate and error branches of coerceSnapshot
// that the happy-path Snapshot tests do not reach.
func TestCoerceSnapshot(t *testing.T) {
enum := Parameter{Type: TypeEnum, EnumValues: []string{"off", "low", "high"}}
cases := []struct {
name string
p Parameter
raw any
want any
wantErr bool
}{
{"float err", Parameter{Type: TypeFloat}, struct{}{}, nil, true},
{"int round", Parameter{Type: TypeInt}, 2.6, int64(3), false},
{"int err", Parameter{Type: TypeInt}, struct{}{}, nil, true},
{"bool from string", Parameter{Type: TypeBool}, "true", true, false},
{"bool bad string", Parameter{Type: TypeBool}, "maybe", nil, true},
{"bool from numeric", Parameter{Type: TypeBool}, 0.0, false, false},
{"bool unconvertible", Parameter{Type: TypeBool}, struct{}{}, nil, true},
{"string passthrough", Parameter{Type: TypeString}, "x", "x", false},
{"string from numeric", Parameter{Type: TypeString}, 42.0, "42", false},
{"enum string in range", enum, "low", "low", false},
{"enum string out of range", enum, "nope", nil, true},
{"enum index", enum, int64(2), "high", false},
{"enum index out of range", enum, 9.0, nil, true},
{"enum non-numeric", enum, struct{}{}, nil, true},
{"array native", Parameter{Type: TypeFloatArray}, []float64{1, 2}, []float64{1, 2}, false},
{"array err", Parameter{Type: TypeFloatArray}, 1.0, nil, true},
{"default passthrough", Parameter{Type: ParamType("weird")}, "asis", "asis", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := tc.p.coerceSnapshot(tc.raw)
if tc.wantErr {
if err == nil {
t.Errorf("coerceSnapshot(%v): want error", tc.raw)
}
return
}
if err != nil {
t.Fatalf("coerceSnapshot(%v): %v", tc.raw, err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("coerceSnapshot(%v) = %v (%T), want %v (%T)", tc.raw, got, got, tc.want, tc.want)
}
})
}
}
// TestSnapshotCoerceFailureRecorded ensures a coercion failure (vs read failure)
// is counted in res.Failed and kept out of Values.
func TestSnapshotCoerceFailureRecorded(t *testing.T) {
set := ConfigSet{
ID: "s",
Name: "s",
Parameters: []Parameter{
{Key: "n", DS: "d", Signal: "N", Type: TypeInt},
},
}
res := Snapshot(set, func(_, _ string) (any, error) {
return "not-a-number", nil // reads fine, fails coercion
})
if res.Captured != 0 || res.Failed != 1 {
t.Fatalf("summary: captured=%d failed=%d", res.Captured, res.Failed)
}
if _, ok := res.Values["n"]; ok {
t.Error("coercion-failed parameter must not appear in values")
}
}
+134
View File
@@ -0,0 +1,134 @@
package confmgr
import (
"testing"
)
// TestParamTypeValid covers the valid/invalid branches of ParamType.valid.
func TestParamTypeValid(t *testing.T) {
for _, ok := range []ParamType{TypeFloat, TypeInt, TypeBool, TypeString, TypeEnum, TypeFloatArray} {
if !ok.valid() {
t.Errorf("%q should be valid", ok)
}
}
if ParamType("bogus").valid() {
t.Error("bogus type should be invalid")
}
}
// TestCheckValue exercises every type branch of Parameter.checkValue, including
// the type-mismatch and range-violation error paths.
func TestCheckValue(t *testing.T) {
cases := []struct {
name string
p Parameter
v any
wantErr bool
}{
{"float ok", Parameter{Type: TypeFloat}, 1.5, false},
{"float from string", Parameter{Type: TypeFloat}, "2.5", false},
{"float not numeric", Parameter{Type: TypeFloat}, true, true},
{"float below min", Parameter{Type: TypeFloat, Min: fptr(0)}, -1.0, true},
{"float above max", Parameter{Type: TypeFloat, Max: fptr(10)}, 11.0, true},
{"int ok", Parameter{Type: TypeInt}, 4.0, false},
{"int non-integer", Parameter{Type: TypeInt}, 4.5, true},
{"bool ok", Parameter{Type: TypeBool}, true, false},
{"bool wrong type", Parameter{Type: TypeBool}, 1.0, true},
{"string ok", Parameter{Type: TypeString}, "hi", false},
{"string wrong type", Parameter{Type: TypeString}, 1.0, true},
{"enum ok", Parameter{Type: TypeEnum, EnumValues: []string{"a", "b"}}, "b", false},
{"enum not string", Parameter{Type: TypeEnum, EnumValues: []string{"a"}}, 1.0, true},
{"enum not allowed", Parameter{Type: TypeEnum, EnumValues: []string{"a"}}, "z", true},
{"array ok", Parameter{Type: TypeFloatArray}, []any{1.0, 2.0}, false},
{"array native", Parameter{Type: TypeFloatArray}, []float64{1, 2}, false},
{"array not array", Parameter{Type: TypeFloatArray}, 1.0, true},
{"array elem below min", Parameter{Type: TypeFloatArray, Min: fptr(0)}, []any{-1.0}, true},
{"array elem above max", Parameter{Type: TypeFloatArray, Max: fptr(5)}, []any{9.0}, true},
{"array bad elem", Parameter{Type: TypeFloatArray}, []any{"nope"}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.p.checkValue(tc.v)
if tc.wantErr && err == nil {
t.Errorf("checkValue(%v): want error", tc.v)
}
if !tc.wantErr && err != nil {
t.Errorf("checkValue(%v): unexpected error %v", tc.v, err)
}
})
}
}
// TestValidateInvalidType covers the invalid-type branch of ConfigSet.Validate.
func TestValidateInvalidType(t *testing.T) {
set := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "s", Type: ParamType("weird")},
}}
if err := set.Validate(); err == nil {
t.Error("invalid parameter type: want error")
}
}
// TestValidateEnumRequiresValues covers the enum-without-values branch.
func TestValidateEnumRequiresValues(t *testing.T) {
set := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "s", Type: TypeEnum},
}}
if err := set.Validate(); err == nil {
t.Error("enum without values: want error")
}
}
// TestValidateMinGreaterThanMax covers the min>max branch.
func TestValidateMinGreaterThanMax(t *testing.T) {
set := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "s", Type: TypeFloat, Min: fptr(10), Max: fptr(1)},
}}
if err := set.Validate(); err == nil {
t.Error("min>max: want error")
}
}
// TestValidateAgainstMandatoryNoDefault covers the mandatory-without-default
// branch of ValidateAgainst.
func TestValidateAgainstMandatoryNoDefault(t *testing.T) {
set := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "req", DS: "d", Signal: "s", Type: TypeFloat, Mandatory: true},
}}
inst := ConfigInstance{SetID: "x", Values: map[string]any{}}
if err := inst.ValidateAgainst(set); err == nil {
t.Error("missing mandatory value: want error")
}
// Providing the value clears the error.
inst.Values["req"] = 1.0
if err := inst.ValidateAgainst(set); err != nil {
t.Errorf("with value: unexpected error %v", err)
}
}
// TestResolveAndNormalize covers Resolve fallbacks and array normalization.
func TestResolveAndNormalize(t *testing.T) {
p := Parameter{Key: "v", Type: TypeFloat, Default: 7.0}
inst := ConfigInstance{Values: map[string]any{}}
if got, ok := inst.Resolve(p); !ok || got != 7.0 {
t.Errorf("Resolve default: got %v,%v want 7,true", got, ok)
}
inst.Values["v"] = 3.0
if got, ok := inst.Resolve(p); !ok || got != 3.0 {
t.Errorf("Resolve value: got %v,%v want 3,true", got, ok)
}
// Optional param without default → no value.
if _, ok := inst.Resolve(Parameter{Key: "none", Type: TypeFloat}); ok {
t.Error("Resolve no-default: want ok=false")
}
arr := Parameter{Type: TypeFloatArray}
out := arr.normalize([]any{1.0, 2.0, 3.0})
if got, ok := out.([]float64); !ok || len(got) != 3 {
t.Errorf("normalize array: got %T %v", out, out)
}
// Non-array passthrough.
if got := arr.normalize("scalar"); got != "scalar" {
t.Errorf("normalize passthrough: got %v", got)
}
}
+118
View File
@@ -0,0 +1,118 @@
package confmgr
import (
"testing"
)
// TestValidateIDRejectsBadChars covers the invalid-character branch of
// validateID, surfaced through the typed getters as ErrNotFound.
func TestValidateIDRejectsBadChars(t *testing.T) {
s, _ := New(t.TempDir())
for _, bad := range []string{"", "bad/slash", "has space", "dot.dot"} {
if _, err := s.GetSet(bad); err == nil {
t.Errorf("GetSet(%q): want error", bad)
}
}
}
// TestNotFoundPaths covers the ErrNotFound branches across the revision API.
func TestNotFoundPaths(t *testing.T) {
s, _ := New(t.TempDir())
if _, err := s.GetSet("missing"); err != ErrNotFound {
t.Errorf("GetSet missing: want ErrNotFound, got %v", err)
}
if _, err := s.GetSetVersion("missing", 1); err != ErrNotFound {
t.Errorf("GetSetVersion missing: want ErrNotFound, got %v", err)
}
if _, err := s.Versions(KindSet, "missing"); err != ErrNotFound {
t.Errorf("Versions missing: want ErrNotFound, got %v", err)
}
if err := s.Promote(KindSet, "missing", 1); err != ErrNotFound {
t.Errorf("Promote missing: want ErrNotFound, got %v", err)
}
if _, err := s.Fork(KindSet, "missing", 1); err != ErrNotFound {
t.Errorf("Fork missing: want ErrNotFound, got %v", err)
}
if _, err := s.UpdateSet("missing", sampleSet(), ""); err != ErrNotFound {
t.Errorf("UpdateSet missing: want ErrNotFound, got %v", err)
}
// A valid id that exists but a version that was never written.
created, _ := s.CreateSet(sampleSet(), "")
if _, err := s.GetSetVersion(created.ID, 99); err != ErrNotFound {
t.Errorf("GetSetVersion bogus version: want ErrNotFound, got %v", err)
}
}
// TestInstancePinnedSetVersion covers the SetVersion>0 branch of setForInstance:
// the instance is validated against a specific (pinned) revision of its set.
func TestInstancePinnedSetVersion(t *testing.T) {
s, _ := New(t.TempDir())
set, _ := s.CreateSet(sampleSet(), "")
// Bump the set to v2 so a distinct earlier revision exists.
if _, err := s.UpdateSet(set.ID, sampleSet(), "v2"); err != nil {
t.Fatal(err)
}
inst := ConfigInstance{
Name: "pinned",
SetID: set.ID,
SetVersion: 1, // pin to the original schema revision
Values: map[string]any{"voltage": 24.0},
}
out, err := s.CreateInstance(inst, "")
if err != nil {
t.Fatalf("CreateInstance pinned: %v", err)
}
if out.SetVersion != 1 {
t.Errorf("SetVersion: want 1, got %d", out.SetVersion)
}
// Updating the pinned instance also exercises the pinned UpdateInstance path.
out.Values["voltage"] = 30.0
v2, err := s.UpdateInstance(out.ID, out, "bump")
if err != nil {
t.Fatalf("UpdateInstance pinned: %v", err)
}
if v2.Version != 2 {
t.Errorf("instance version: want 2, got %d", v2.Version)
}
}
// TestUpdateInstanceMissingSet covers the load-set error branch of
// UpdateInstance (set referenced by the instance does not exist).
func TestUpdateInstanceMissingSet(t *testing.T) {
s, _ := New(t.TempDir())
inst := ConfigInstance{Name: "x", SetID: "nope", Values: map[string]any{}}
if _, err := s.UpdateInstance("anything", inst, ""); err == nil {
t.Error("UpdateInstance with missing set: want error")
}
}
// TestListSkipsVersionedFiles checks List returns only current revisions and
// reflects metadata after updates.
func TestListSkipsVersionedFiles(t *testing.T) {
s, _ := New(t.TempDir())
a, _ := s.CreateSet(sampleSet(), "")
b, _ := s.CreateSet(sampleSet(), "")
// Create backups for a so the dir holds versioned files too.
_, _ = s.UpdateSet(a.ID, sampleSet(), "")
_, _ = s.UpdateSet(a.ID, sampleSet(), "")
metas, err := s.List(KindSet)
if err != nil {
t.Fatal(err)
}
if len(metas) != 2 {
t.Fatalf("List: want 2 current sets, got %d", len(metas))
}
ids := map[string]bool{}
for _, m := range metas {
ids[m.ID] = true
}
if !ids[a.ID] || !ids[b.ID] {
t.Errorf("List missing expected ids: %v", ids)
}
}
@@ -31,6 +31,7 @@ func (s *writableSource) ListSignals(context.Context) ([]datasource.Metadata, er
func (s *writableSource) GetMetadata(_ context.Context, sig string) (datasource.Metadata, error) { func (s *writableSource) GetMetadata(_ context.Context, sig string) (datasource.Metadata, error) {
return datasource.Metadata{Name: sig, Writable: true}, nil return datasource.Metadata{Name: sig, Writable: true}, nil
} }
// Subscribe delivers the signal's last-written value once (if any), so a // Subscribe delivers the signal's last-written value once (if any), so a
// one-shot ReadNow (used by config snapshot) resolves immediately. // one-shot ReadNow (used by config snapshot) resolves immediately.
func (s *writableSource) Subscribe(_ context.Context, sig string, ch chan<- datasource.Value) (datasource.CancelFunc, error) { func (s *writableSource) Subscribe(_ context.Context, sig string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
+1 -1
View File
@@ -3,7 +3,7 @@
// Fields, in order: minute hour day-of-month month day-of-week. // Fields, in order: minute hour day-of-month month day-of-week.
// Each field supports: // Each field supports:
// //
// * any value // - any value
// */n every n (step over the whole range) // */n every n (step over the whole range)
// a-b inclusive range // a-b inclusive range
// a-b/n range with step // a-b/n range with step
@@ -0,0 +1,93 @@
package controllogic
import (
"math"
"testing"
"github.com/uopi/uopi/internal/confmgr"
)
func TestFormatAny(t *testing.T) {
cases := []struct {
in any
want string
}{
{3.5, "3.5"},
{float64(42), "42"},
{"hello", "hello"},
{true, "true"},
{int64(7), "7"},
}
for _, c := range cases {
if got := formatAny(c.in); got != c.want {
t.Errorf("formatAny(%v) = %q, want %q", c.in, got, c.want)
}
}
}
func TestTestThreshold(t *testing.T) {
cases := []struct {
val float64
op string
cmp float64
want bool
}{
{1, "<", 2, true},
{3, "<", 2, false},
{2, ">=", 2, true},
{1, ">=", 2, false},
{2, "<=", 2, true},
{3, "<=", 2, false},
{2, "==", 2, true},
{2, "!=", 3, true},
{5, ">", 2, true}, // default branch
{1, "", 0, true}, // empty op → ">"
{math.NaN(), "<", 1, false}, // NaN never satisfies
}
for _, c := range cases {
if got := testThreshold(c.val, c.op, c.cmp); got != c.want {
t.Errorf("testThreshold(%v,%q,%v) = %v, want %v", c.val, c.op, c.cmp, got, c.want)
}
}
}
func TestParseFloat(t *testing.T) {
cases := []struct {
in string
want float64
}{
{"3.14", 3.14},
{" 10 ", 10},
{"", 0},
{"not-a-number", 0},
}
for _, c := range cases {
if got := parseFloat(c.in); got != c.want {
t.Errorf("parseFloat(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func TestCoerceParamValue(t *testing.T) {
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeInt}, 3.9); v != int64(3) {
t.Errorf("int coerce = %v, want 3", v)
}
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeBool}, 0); v != false {
t.Errorf("bool 0 = %v, want false", v)
}
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeBool}, 1); v != true {
t.Errorf("bool 1 = %v, want true", v)
}
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeString}, 2.5); v != "2.5" {
t.Errorf("string coerce = %v, want \"2.5\"", v)
}
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeFloat}, 1.25); v != 1.25 {
t.Errorf("float coerce = %v, want 1.25", v)
}
}
func TestSign(t *testing.T) {
if sign(5) != 1 || sign(-5) != -1 || sign(0) != 0 {
t.Errorf("sign mismatch: %d %d %d", sign(5), sign(-5), sign(0))
}
}
+180
View File
@@ -0,0 +1,180 @@
package controllogic
import (
"context"
"io"
"log/slog"
"sync"
"testing"
"time"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
)
// pushSource is a DataSource whose Subscribe captures the broker's delivery
// channel per signal, letting a test push successive values to drive level/edge
// triggers. Writes are recorded so action.write effects can be asserted.
type pushSource struct {
mu sync.Mutex
chans map[string]chan<- datasource.Value
written map[string]any
}
func newPushSource() *pushSource {
return &pushSource{chans: map[string]chan<- datasource.Value{}, written: map[string]any{}}
}
func (s *pushSource) Name() string { return "tgt" }
func (s *pushSource) Connect(context.Context) error { return nil }
func (s *pushSource) ListSignals(context.Context) ([]datasource.Metadata, error) {
return nil, nil
}
func (s *pushSource) GetMetadata(_ context.Context, sig string) (datasource.Metadata, error) {
return datasource.Metadata{Name: sig, Writable: true}, nil
}
func (s *pushSource) Subscribe(_ context.Context, sig string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
s.mu.Lock()
s.chans[sig] = ch
s.mu.Unlock()
return func() {}, nil
}
func (s *pushSource) Write(_ context.Context, signal string, value any) error {
s.mu.Lock()
s.written[signal] = value
s.mu.Unlock()
return nil
}
func (s *pushSource) History(context.Context, string, time.Time, time.Time, int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
func (s *pushSource) chanFor(sig string) (chan<- datasource.Value, bool) {
s.mu.Lock()
defer s.mu.Unlock()
ch, ok := s.chans[sig]
return ch, ok
}
func (s *pushSource) get(sig string) (any, bool) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.written[sig]
return v, ok
}
// TestEngineReloadThresholdTrigger drives a real Reload generation: a
// trigger.threshold on tgt:IN (>5) wired to an action.write of 42 to tgt:OUT.
// Pushing IN below then above the threshold must fire exactly the rising edge.
func TestEngineReloadThresholdTrigger(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx := t.Context()
src := newPushSource()
brk := broker.New(ctx, log)
brk.Register(src)
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatal("NewStore:", err)
}
g := Graph{
Name: "watchdog",
Enabled: true,
Nodes: []Node{
{ID: "t1", Kind: "trigger.threshold", Params: map[string]string{
"signal": "tgt:IN", "op": ">", "value": "5",
}},
{ID: "a1", Kind: "action.write", Params: map[string]string{
"target": "tgt:OUT", "expr": "42",
}},
},
Wires: []Wire{{From: "t1", To: "a1"}},
}
if err := store.Save(g); err != nil {
t.Fatal("Save:", err)
}
e := NewEngine(ctx, brk, store, nil, audit.Nop(), log)
e.Reload()
// t.Context() is cancelled at test cleanup, tearing the generation down.
// Wait until the engine has subscribed to tgt:IN.
var ch chan<- datasource.Value
waitFor(t, time.Second, func() bool {
c, ok := src.chanFor("IN")
if ok {
ch = c
}
return ok
})
// Below threshold: no fire (prev state seeds to false).
ch <- datasource.Value{Data: 0.0, Timestamp: time.Now()}
// Rising edge above threshold: fires the action.
ch <- datasource.Value{Data: 10.0, Timestamp: time.Now()}
waitFor(t, 2*time.Second, func() bool {
v, ok := src.get("OUT")
return ok && toNum(v) == 42
})
if v, ok := src.get("OUT"); !ok || toNum(v) != 42 {
t.Fatalf("OUT = %v (ok=%v), want 42 after rising-edge trigger", v, ok)
}
}
// TestEngineReloadTimerIfWrite covers the timer trigger, startTriggers, and a
// flow.if then-branch driving an action.write.
func TestEngineReloadTimerIfWrite(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx := t.Context()
src := newPushSource()
brk := broker.New(ctx, log)
brk.Register(src)
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatal("NewStore:", err)
}
g := Graph{
Name: "ticker",
Enabled: true,
Nodes: []Node{
{ID: "t1", Kind: "trigger.timer", Params: map[string]string{"interval": "50"}},
{ID: "if1", Kind: "flow.if", Params: map[string]string{"cond": "2 > 1"}},
{ID: "a1", Kind: "action.write", Params: map[string]string{"target": "tgt:OUT2", "expr": "7"}},
},
Wires: []Wire{
{From: "t1", To: "if1"},
{From: "if1", FromPort: "then", To: "a1"},
},
}
if err := store.Save(g); err != nil {
t.Fatal("Save:", err)
}
e := NewEngine(ctx, brk, store, nil, audit.Nop(), log)
e.Reload()
waitFor(t, 2*time.Second, func() bool {
v, ok := src.get("OUT2")
return ok && toNum(v) == 7
})
}
// waitFor polls cond until it returns true or the deadline elapses.
func waitFor(t *testing.T, d time.Duration, cond func() bool) {
t.Helper()
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
if !cond() {
t.Fatalf("condition not met within %s", d)
}
}
+77
View File
@@ -0,0 +1,77 @@
package controllogic
import (
"os"
"path/filepath"
"reflect"
"testing"
)
// TestStoreDeleteAndReload covers Delete (with trash backup), the ErrNotFound
// branches, List, and load() re-reading a persisted store from disk.
func TestStoreDeleteAndReload(t *testing.T) {
dir := t.TempDir()
s, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
g := Graph{ID: "g1", Name: "one", Enabled: true,
Nodes: []Node{{ID: "n1", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}}}}
if err := s.Save(g); err != nil {
t.Fatalf("Save: %v", err)
}
if err := s.Save(Graph{ID: "g2", Name: "two"}); err != nil {
t.Fatalf("Save g2: %v", err)
}
if got := s.List(); len(got) != 2 {
t.Fatalf("List: want 2, got %d", len(got))
}
// Reload from disk: a fresh Store over the same dir must see both graphs.
s2, err := NewStore(dir)
if err != nil {
t.Fatalf("reopen: %v", err)
}
if got, err := s2.Get("g1"); err != nil || got.Name != "one" {
t.Errorf("reloaded g1 = %+v, %v", got, err)
}
// Delete writes a trash backup then removes the item.
if err := s.Delete("g1"); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := s.Get("g1"); err != ErrNotFound {
t.Errorf("Get after delete: want ErrNotFound, got %v", err)
}
if err := s.Delete("g1"); err != ErrNotFound {
t.Errorf("Delete missing: want ErrNotFound, got %v", err)
}
// A trash file should now exist for g1.
trashDir := filepath.Join(dir, "trash", "controllogic")
entries, err := os.ReadDir(trashDir)
if err != nil || len(entries) == 0 {
t.Errorf("trash dir = %v entries, err %v; want >=1", len(entries), err)
}
}
// TestSplitCSV covers the comma-filter parser, including trimming and the
// empty-input (nil) case.
func TestSplitCSV(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"", nil},
{" ", nil},
{"a", []string{"a"}},
{" a , b ,, c ", []string{"a", "b", "c"}},
}
for _, tc := range cases {
if got := splitCSV(tc.in); !reflect.DeepEqual(got, tc.want) {
t.Errorf("splitCSV(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
+1 -2
View File
@@ -73,8 +73,7 @@ type EPICS struct {
autoSyncFilter string autoSyncFilter string
autoSyncFromArchiver bool autoSyncFromArchiver bool
// caCtx is the CA context created in Connect(). // caCtx is the CA context created in Connect(). Stored as unsafe.Pointer
Stored as unsafe.Pointer
// because the C type (ca_client_context *) is opaque. Every goroutine // because the C type (ca_client_context *) is opaque. Every goroutine
// that calls CA functions must call caAttachContext(caCtx) first, because // that calls CA functions must call caAttachContext(caCtx) first, because
// Go goroutines can run on any OS thread and CA contexts are thread-local. // Go goroutines can run on any OS thread and CA contexts are thread-local.
+28
View File
@@ -0,0 +1,28 @@
package datasource
import (
"context"
"testing"
)
// TestWithUserAndUserFrom covers the context identity round-trip, including the
// empty-user passthrough and the missing-identity fallback.
func TestWithUserAndUserFrom(t *testing.T) {
base := context.Background()
// No identity present.
if u, ok := UserFrom(base); ok || u != "" {
t.Errorf("UserFrom(empty) = %q,%v want \"\",false", u, ok)
}
// Empty user must not attach a value.
if ctx := WithUser(base, ""); ctx != base {
t.Error("WithUser with empty user should return the original context")
}
// Real identity round-trips.
ctx := WithUser(base, "alice")
if u, ok := UserFrom(ctx); !ok || u != "alice" {
t.Errorf("UserFrom = %q,%v want alice,true", u, ok)
}
}
+145
View File
@@ -0,0 +1,145 @@
package dsp
import (
"math"
"testing"
)
// TestExprFunctions exercises every built-in function branch of parseCall plus
// the two-argument forms and the right-associative power operator.
func TestExprFunctions(t *testing.T) {
st := map[string]any{}
cases := []struct {
expr string
inputs []float64
want float64
}{
{"exp(a)", []float64{1}, math.E},
{"log(a)", []float64{math.E}, 1},
{"ln(a)", []float64{math.E}, 1},
{"log2(a)", []float64{8}, 3},
{"log10(a)", []float64{1000}, 3},
{"sqrt(a)", []float64{9}, 3},
{"abs(a)", []float64{-4}, 4},
{"sin(a)", []float64{0}, 0},
{"cos(a)", []float64{0}, 1},
{"tan(a)", []float64{0}, 0},
{"asin(a)", []float64{1}, math.Pi / 2},
{"acos(a)", []float64{1}, 0},
{"atan(a)", []float64{1}, math.Pi / 4},
{"atan2(a, b)", []float64{1, 1}, math.Pi / 4},
{"pow(a, b)", []float64{2, 10}, 1024},
{"floor(a)", []float64{2.9}, 2},
{"ceil(a)", []float64{2.1}, 3},
{"round(a)", []float64{2.5}, 3},
{"min(a, b)", []float64{3, 7}, 3},
{"max(a, b)", []float64{3, 7}, 7},
{"a ^ b", []float64{2, 3}, 8},
{"2 ^ 3 ^ 2", []float64{}, 512}, // right-associative: 2^(3^2)
{"-a ^ 2", []float64{3}, -9}, // unary minus binds outside power
}
for _, tc := range cases {
t.Run(tc.expr, func(t *testing.T) {
n := &ExprNode{Expr: tc.expr}
got, err := n.Process(tc.inputs, st)
if err != nil {
t.Fatalf("Process(%q): %v", tc.expr, err)
}
if math.Abs(got-tc.want) > 1e-9 {
t.Errorf("Process(%q) = %v, want %v", tc.expr, got, tc.want)
}
})
}
}
// TestExprFunctionErrors covers the error branches of parseCall/parseFactor.
func TestExprFunctionErrors(t *testing.T) {
st := map[string]any{}
cases := []string{
"bogus(a)", // unknown function
"sqrt(a", // missing ')'
"sqrt(", // empty / unexpected end inside call
"@", // unexpected character
"(a + 1", // missing closing parenthesis
"1.2.3", // invalid number
}
for _, expr := range cases {
t.Run(expr, func(t *testing.T) {
n := &ExprNode{Expr: expr}
if _, err := n.Process([]float64{1}, st); err == nil {
t.Errorf("Process(%q): want error", expr)
}
})
}
}
// TestArrayNodeScalarAdapters covers the legacy scalar Node interface
// (Type + Process) on the array nodes, which the array-path tests skip.
func TestArrayNodeScalarAdapters(t *testing.T) {
st := map[string]any{}
// Reductions: Process treats its float64 inputs as a single-element array,
// so each reduction over one value returns that value.
reductions := []struct {
node ArrayNode
typ string
}{
{&SumNode{}, "sum"},
{&MeanNode{}, "mean"},
{&MinNode{}, "min"},
{&MaxNode{}, "max"},
{&IndexNode{I: 0}, "index"},
}
for _, r := range reductions {
t.Run(r.typ, func(t *testing.T) {
if r.node.Type() != r.typ {
t.Errorf("Type() = %q, want %q", r.node.Type(), r.typ)
}
got, err := r.node.Process([]float64{42}, st)
if err != nil {
t.Fatalf("Process: %v", err)
}
if got != 42 {
t.Errorf("Process = %v, want 42", got)
}
})
}
// LengthNode over a single scalar input → length 1.
ln := &LengthNode{}
if ln.Type() != "length" {
t.Errorf("LengthNode.Type() = %q", ln.Type())
}
if got, err := ln.Process([]float64{7}, st); err != nil || got != 1 {
t.Errorf("LengthNode.Process = %v, %v; want 1", got, err)
}
// SliceNode.Process returns the first element of the resulting slice.
sn := &SliceNode{Start: 0, End: 0}
if sn.Type() != "slice" {
t.Errorf("SliceNode.Type() = %q", sn.Type())
}
if got, err := sn.Process([]float64{5}, st); err != nil || got != 5 {
t.Errorf("SliceNode.Process = %v, %v; want 5", got, err)
}
// FFTNode.Process returns the first magnitude bin (DC term = the value).
fn := &FFTNode{}
if fn.Type() != "fft" {
t.Errorf("FFTNode.Type() = %q", fn.Type())
}
if got, err := fn.Process([]float64{3}, st); err != nil || math.Abs(got-3) > 1e-9 {
t.Errorf("FFTNode.Process = %v, %v; want 3", got, err)
}
}
// TestArrayNodeProcessErrors covers the error propagation through the scalar
// Process adapters.
func TestArrayNodeProcessErrors(t *testing.T) {
st := map[string]any{}
// IndexNode with an out-of-range index propagates the reduction error.
n := &IndexNode{I: 5}
if _, err := n.Process([]float64{1}, st); err == nil {
t.Error("IndexNode.Process out of range: want error")
}
}
+19
View File
@@ -0,0 +1,19 @@
//go:build !pam
package pamauth
import (
"errors"
"testing"
)
// TestStubUnavailable verifies the default (non-PAM) build reports PAM as
// unavailable and Authenticate always fails with ErrUnavailable.
func TestStubUnavailable(t *testing.T) {
if Available {
t.Error("Available should be false in the non-PAM build")
}
if err := Authenticate("login", "alice", "secret"); !errors.Is(err, ErrUnavailable) {
t.Errorf("Authenticate = %v, want ErrUnavailable", err)
}
}
+80
View File
@@ -0,0 +1,80 @@
package panelacl
import "testing"
// TestPermString covers the Perm→token rendering.
func TestPermString(t *testing.T) {
cases := map[Perm]string{PermNone: "none", PermRead: "read", PermWrite: "write"}
for p, want := range cases {
if got := p.String(); got != want {
t.Errorf("Perm(%d).String() = %q, want %q", p, got, want)
}
}
}
// TestPlaceAndDeletePanel covers PlacePanel (organizational-only record that
// stays open) and DeletePanel (present + absent).
func TestPlaceAndDeletePanel(t *testing.T) {
s := newStore(t)
if _, err := s.CreateFolder("f1", "Folder 1", "", "alice"); err != nil {
t.Fatal(err)
}
// Placing a legacy panel into a folder must not lock it (no owner record).
if err := s.PlacePanel("legacy", "f1", 2.5); err != nil {
t.Fatalf("PlacePanel: %v", err)
}
if got := s.PanelPerm("legacy", "bob", nil); got != PermWrite {
t.Errorf("placed legacy panel perm = %v, want write", got)
}
// DeletePanel on a missing record is a no-op success.
if err := s.DeletePanel("never"); err != nil {
t.Errorf("DeletePanel missing: %v", err)
}
// DeletePanel on the placed record removes it.
if err := s.DeletePanel("legacy"); err != nil {
t.Fatalf("DeletePanel: %v", err)
}
}
// TestFoldersAndGetFolder covers the folder accessors, FolderPerm on an owner
// vs a stranger, and reload from the persisted index.
func TestFoldersAndGetFolder(t *testing.T) {
dir := t.TempDir()
s, err := New(dir)
if err != nil {
t.Fatal("New:", err)
}
if _, err := s.CreateFolder("root", "Root", "", "alice"); err != nil {
t.Fatal(err)
}
all := s.Folders()
if len(all) != 1 {
t.Fatalf("Folders: want 1, got %d", len(all))
}
if f, err := s.GetFolder("root"); err != nil || f.Name != "Root" {
t.Errorf("GetFolder = %+v, %v", f, err)
}
if _, err := s.GetFolder("ghost"); err != ErrNotFound {
t.Errorf("GetFolder missing: want ErrNotFound, got %v", err)
}
// Owner has write on the folder; an unrelated user has none.
if got := s.FolderPerm("root", "alice", nil); got != PermWrite {
t.Errorf("owner FolderPerm = %v, want write", got)
}
if got := s.FolderPerm("root", "bob", nil); got != PermNone {
t.Errorf("stranger FolderPerm = %v, want none", got)
}
// Reload: a fresh Store over the same dir still sees the folder.
s2, err := New(dir)
if err != nil {
t.Fatalf("reopen: %v", err)
}
if _, err := s2.GetFolder("root"); err != nil {
t.Errorf("reloaded GetFolder: %v", err)
}
}
+223
View File
@@ -0,0 +1,223 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/uopi/uopi/internal/access"
)
// -------------------------------------------------------------------------- //
// accessMiddleware //
// -------------------------------------------------------------------------- //
// okHandler records that it ran and returns 200.
func okHandler(ran *bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
*ran = true
w.WriteHeader(http.StatusOK)
})
}
func TestAccessMiddlewareWriteUserPassesMutation(t *testing.T) {
// A configured policy with an operator (write) user.
policy := access.New("", []access.GroupSpec{
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
})
var ran bool
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
req := httptest.NewRequest(http.MethodPost, "/api/v1/interfaces", nil)
req.Header.Set(testUserHeader, "alice")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !ran {
t.Errorf("write user POST: code=%d ran=%v, want 200/true", rec.Code, ran)
}
}
func TestAccessMiddlewareReadUserBlockedFromMutation(t *testing.T) {
// Configured policy → an unlisted user is a read-only viewer.
policy := access.New("", []access.GroupSpec{
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
})
var ran bool
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
req := httptest.NewRequest(http.MethodPost, "/api/v1/interfaces", nil)
req.Header.Set(testUserHeader, "bob") // viewer
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("read-only POST: code=%d, want 403", rec.Code)
}
if ran {
t.Error("handler must not run for a forbidden mutation")
}
}
func TestAccessMiddlewareReadUserAllowedToGet(t *testing.T) {
policy := access.New("", []access.GroupSpec{
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
})
var ran bool
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
req := httptest.NewRequest(http.MethodGet, "/api/v1/interfaces", nil)
req.Header.Set(testUserHeader, "bob") // viewer
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !ran {
t.Errorf("read-only GET: code=%d ran=%v, want 200/true", rec.Code, ran)
}
}
func TestAccessMiddlewareMeAlwaysReachable(t *testing.T) {
policy := access.New("", []access.GroupSpec{
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
})
var ran bool
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
// Even a mutating method on /me is allowed through (identity discovery).
req := httptest.NewRequest(http.MethodPost, "/api/v1/me", nil)
req.Header.Set(testUserHeader, "bob")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !ran {
t.Errorf("/me must always be reachable: code=%d ran=%v", rec.Code, ran)
}
}
func TestAccessMiddlewareStoresUserOnContext(t *testing.T) {
policy := access.New("", nil) // unconfigured → everyone write
var got string
h := accessMiddleware(policy, testUserHeader, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = access.UserFrom(r.Context())
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/foo", nil)
req.Header.Set(testUserHeader, "carol")
h.ServeHTTP(httptest.NewRecorder(), req)
if got != "carol" {
t.Errorf("context user = %q, want carol", got)
}
}
// -------------------------------------------------------------------------- //
// httpsRedirectHandler //
// -------------------------------------------------------------------------- //
func TestHTTPSRedirectHandler(t *testing.T) {
cases := []struct {
name string
tlsAddr string
host string
target string
want string
}{
{"default port omitted", ":443", "example.com", "/panels?x=1", "https://example.com/panels?x=1"},
{"custom port preserved", ":8443", "host.local:80", "/a", "https://host.local:8443/a"},
{"root path", "0.0.0.0:443", "h", "/", "https://h/"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
h := httpsRedirectHandler(c.tlsAddr)
req := httptest.NewRequest(http.MethodGet, c.target, nil)
req.Host = c.host
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusMovedPermanently {
t.Fatalf("code = %d, want 301", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != c.want {
t.Errorf("Location = %q, want %q", loc, c.want)
}
})
}
}
// -------------------------------------------------------------------------- //
// parseDialogTarget //
// -------------------------------------------------------------------------- //
func TestParseDialogTarget(t *testing.T) {
cases := []struct {
in string
ds, name string
ok bool
}{
{"epics:SR:CURRENT", "epics", "SR:CURRENT", true}, // splits on first ':' only
{"bareName", "srv", "bareName", true}, // defaults to srv
{" spaced ", "srv", "spaced", true},
{"", "", "", false},
{" ", "", "", false},
}
for _, c := range cases {
ds, name, ok := parseDialogTarget(c.in)
if ds != c.ds || name != c.name || ok != c.ok {
t.Errorf("parseDialogTarget(%q) = (%q,%q,%v), want (%q,%q,%v)",
c.in, ds, name, ok, c.ds, c.name, c.ok)
}
}
}
// -------------------------------------------------------------------------- //
// formatAuditValue //
// -------------------------------------------------------------------------- //
func TestFormatAuditValue(t *testing.T) {
cases := []struct {
in any
want string
}{
{"hello", "hello"},
{float64(3.5), "3.5"},
{float64(42), "42"},
{true, "true"},
{false, "false"},
{nil, ""},
{[]any{1.0, 2.0}, "[1,2]"}, // default → JSON
{map[string]any{"a": 1.0}, `{"a":1}`},
}
for _, c := range cases {
if got := formatAuditValue(c.in); got != c.want {
t.Errorf("formatAuditValue(%v) = %q, want %q", c.in, got, c.want)
}
}
}
// -------------------------------------------------------------------------- //
// clientIP //
// -------------------------------------------------------------------------- //
func TestClientIP(t *testing.T) {
t.Run("X-Forwarded-For first hop", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Forwarded-For", "203.0.113.7, 10.0.0.1")
if got := clientIP(req); got != "203.0.113.7" {
t.Errorf("got %q, want 203.0.113.7", got)
}
})
t.Run("X-Real-IP", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Real-IP", "198.51.100.4")
if got := clientIP(req); got != "198.51.100.4" {
t.Errorf("got %q, want 198.51.100.4", got)
}
})
t.Run("RemoteAddr fallback", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.9:54321"
if got := clientIP(req); got != "192.0.2.9" {
t.Errorf("got %q, want 192.0.2.9", got)
}
})
}
+374
View File
@@ -0,0 +1,374 @@
package storage_test
import (
"errors"
"strings"
"testing"
"github.com/uopi/uopi/internal/storage"
)
// mkVersioned creates an interface and pushes it to version 3 by updating
// twice, returning the id. After this the store holds: current (v3) plus
// backups id.v1.xml and id.v2.xml.
func mkVersioned(t *testing.T) (*storage.Store, string) {
t.Helper()
s := newStore(t)
id, err := s.Create([]byte(`<interface id="vers" name="V1" version="1"><widget/></interface>`), "")
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := s.Update(id, []byte(`<interface id="vers" name="V2" version="1"><widget/></interface>`), "second"); err != nil {
t.Fatalf("Update→v2: %v", err)
}
if err := s.Update(id, []byte(`<interface id="vers" name="V3" version="1"><widget/></interface>`), ""); err != nil {
t.Fatalf("Update→v3: %v", err)
}
return s, id
}
// -------------------------------------------------------------------------- //
// Groups //
// -------------------------------------------------------------------------- //
func TestReadGroupsDefaultsEmptyArray(t *testing.T) {
s := newStore(t)
data, err := s.ReadGroups()
if err != nil {
t.Fatalf("ReadGroups: %v", err)
}
if string(data) != "[]" {
t.Errorf("ReadGroups default = %q, want %q", data, "[]")
}
}
func TestWriteThenReadGroups(t *testing.T) {
s := newStore(t)
want := `[{"id":"a","name":"Area A"}]`
if err := s.WriteGroups([]byte(want)); err != nil {
t.Fatalf("WriteGroups: %v", err)
}
got, err := s.ReadGroups()
if err != nil {
t.Fatalf("ReadGroups: %v", err)
}
if string(got) != want {
t.Errorf("ReadGroups = %q, want %q", got, want)
}
}
// -------------------------------------------------------------------------- //
// Versions //
// -------------------------------------------------------------------------- //
func TestVersionsNewestFirstWithCurrentFlag(t *testing.T) {
s, id := mkVersioned(t)
vs, err := s.Versions(id)
if err != nil {
t.Fatalf("Versions: %v", err)
}
if len(vs) != 3 {
t.Fatalf("expected 3 versions, got %d", len(vs))
}
if vs[0].Version != 3 || vs[1].Version != 2 || vs[2].Version != 1 {
t.Errorf("not newest-first: %d, %d, %d", vs[0].Version, vs[1].Version, vs[2].Version)
}
if !vs[0].Current {
t.Error("v3 should be flagged Current")
}
if vs[1].Current || vs[2].Current {
t.Error("backup revisions must not be flagged Current")
}
// The "second" tag was stamped on the revision that became backup v2.
if vs[1].Tag != "second" {
t.Errorf("v2 tag = %q, want %q", vs[1].Tag, "second")
}
}
func TestVersionsNotFound(t *testing.T) {
s := newStore(t)
if _, err := s.Versions("ghost"); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Versions missing: want ErrNotFound, got %v", err)
}
}
func TestVersionsInvalidID(t *testing.T) {
s := newStore(t)
if _, err := s.Versions("../x"); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Versions bad ID: want ErrNotFound, got %v", err)
}
}
// -------------------------------------------------------------------------- //
// GetVersion //
// -------------------------------------------------------------------------- //
func TestGetVersionCurrentAndBackup(t *testing.T) {
s, id := mkVersioned(t)
cur, err := s.GetVersion(id, 3)
if err != nil {
t.Fatalf("GetVersion(3): %v", err)
}
if !strings.Contains(string(cur), `name="V3"`) {
t.Errorf("GetVersion(3) wrong content: %s", cur)
}
old, err := s.GetVersion(id, 1)
if err != nil {
t.Fatalf("GetVersion(1): %v", err)
}
if !strings.Contains(string(old), `name="V1"`) {
t.Errorf("GetVersion(1) wrong content: %s", old)
}
}
func TestGetVersionMissingRevision(t *testing.T) {
s, id := mkVersioned(t)
if _, err := s.GetVersion(id, 99); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("GetVersion(99): want ErrNotFound, got %v", err)
}
}
func TestGetVersionMissingInterface(t *testing.T) {
s := newStore(t)
if _, err := s.GetVersion("ghost", 1); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("GetVersion missing iface: want ErrNotFound, got %v", err)
}
}
// -------------------------------------------------------------------------- //
// SetVersionTag //
// -------------------------------------------------------------------------- //
func TestSetVersionTagOnBackupAndClear(t *testing.T) {
s, id := mkVersioned(t)
// Tag a backup revision in place (no new revision created).
if err := s.SetVersionTag(id, 1, "milestone"); err != nil {
t.Fatalf("SetVersionTag(1): %v", err)
}
vs, _ := s.Versions(id)
if len(vs) != 3 {
t.Fatalf("tagging must not add a revision: got %d", len(vs))
}
var v1 *storage.VersionMeta
for i := range vs {
if vs[i].Version == 1 {
v1 = &vs[i]
}
}
if v1 == nil || v1.Tag != "milestone" {
t.Fatalf("v1 tag not set: %+v", v1)
}
// Clearing the tag.
if err := s.SetVersionTag(id, 1, ""); err != nil {
t.Fatalf("SetVersionTag clear: %v", err)
}
vs, _ = s.Versions(id)
for _, v := range vs {
if v.Version == 1 && v.Tag != "" {
t.Errorf("v1 tag should be cleared, got %q", v.Tag)
}
}
}
func TestSetVersionTagCurrent(t *testing.T) {
s, id := mkVersioned(t)
if err := s.SetVersionTag(id, 3, "live"); err != nil {
t.Fatalf("SetVersionTag(3): %v", err)
}
vs, _ := s.Versions(id)
if vs[0].Version != 3 || vs[0].Tag != "live" {
t.Errorf("current tag not applied: %+v", vs[0])
}
}
func TestSetVersionTagMissing(t *testing.T) {
s, id := mkVersioned(t)
if err := s.SetVersionTag(id, 99, "x"); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("SetVersionTag missing rev: want ErrNotFound, got %v", err)
}
if err := s.SetVersionTag("../bad", 1, "x"); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("SetVersionTag bad ID: want ErrNotFound, got %v", err)
}
}
// -------------------------------------------------------------------------- //
// Promote //
// -------------------------------------------------------------------------- //
func TestPromoteIsNonDestructive(t *testing.T) {
s, id := mkVersioned(t)
// Promote v1 — its content becomes the new current revision (v4), while
// every prior revision is preserved.
if err := s.Promote(id, 1); err != nil {
t.Fatalf("Promote(1): %v", err)
}
vs, err := s.Versions(id)
if err != nil {
t.Fatalf("Versions after promote: %v", err)
}
if len(vs) != 4 {
t.Fatalf("expected 4 versions after promote, got %d", len(vs))
}
if vs[0].Version != 4 || !vs[0].Current {
t.Errorf("new current should be v4: %+v", vs[0])
}
if vs[0].Name != "V1" {
t.Errorf("promoted content should be V1's, got name %q", vs[0].Name)
}
if vs[0].Tag != "restored from v1" {
t.Errorf("promote tag = %q", vs[0].Tag)
}
}
func TestPromoteMissing(t *testing.T) {
s, id := mkVersioned(t)
if err := s.Promote(id, 99); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Promote missing rev: want ErrNotFound, got %v", err)
}
}
// -------------------------------------------------------------------------- //
// Fork //
// -------------------------------------------------------------------------- //
func TestForkResetsVersionAndClearsTag(t *testing.T) {
s, id := mkVersioned(t)
if err := s.SetVersionTag(id, 2, "tagged"); err != nil {
t.Fatalf("SetVersionTag: %v", err)
}
newID, err := s.Fork(id, 2)
if err != nil {
t.Fatalf("Fork(2): %v", err)
}
if newID == id {
t.Fatal("Fork must produce a distinct ID")
}
data, err := s.Get(newID)
if err != nil {
t.Fatalf("Get(fork): %v", err)
}
str := string(data)
if !strings.Contains(str, `name="V2"`) {
t.Errorf("fork should carry V2 content: %s", str)
}
if !strings.Contains(str, `version="1"`) {
t.Errorf("fork version should reset to 1: %s", str)
}
if !strings.Contains(str, `id="`+newID+`"`) {
t.Errorf("fork id attr should be stamped: %s", str)
}
if strings.Contains(str, "tagged") {
t.Errorf("fork should clear the tag: %s", str)
}
// The fork is an independent, listable interface at v1.
vs, err := s.Versions(newID)
if err != nil {
t.Fatalf("Versions(fork): %v", err)
}
if len(vs) != 1 || vs[0].Version != 1 {
t.Errorf("fork should have a single v1 revision, got %+v", vs)
}
}
func TestForkMissing(t *testing.T) {
s := newStore(t)
if _, err := s.Fork("ghost", 1); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Fork missing: want ErrNotFound, got %v", err)
}
}
// -------------------------------------------------------------------------- //
// Create: slug derivation + attribute stamping //
// -------------------------------------------------------------------------- //
func TestCreateSlugifiesNameWhenNoID(t *testing.T) {
s := newStore(t)
id, err := s.Create([]byte(`<interface name="My Cool Panel!" version="1"/>`), "")
if err != nil {
t.Fatalf("Create: %v", err)
}
if id != "my-cool-panel" {
t.Errorf("slug id = %q, want %q", id, "my-cool-panel")
}
}
func TestCreateEmptyNameFallsBackToInterface(t *testing.T) {
s := newStore(t)
id, err := s.Create([]byte(`<interface name="" version="1"/>`), "")
if err != nil {
t.Fatalf("Create: %v", err)
}
if id != "interface" {
t.Errorf("fallback id = %q, want %q", id, "interface")
}
}
func TestCreateStampsMissingVersionAndID(t *testing.T) {
s := newStore(t)
// XML carries neither id nor version attributes — Create must insert both.
id, err := s.Create([]byte(`<interface name="Stamp Me"><widget/></interface>`), "label")
if err != nil {
t.Fatalf("Create: %v", err)
}
data, err := s.Get(id)
if err != nil {
t.Fatalf("Get: %v", err)
}
str := string(data)
if !strings.Contains(str, `version="1"`) {
t.Errorf("Create should stamp version=1: %s", str)
}
if !strings.Contains(str, `id="`+id+`"`) {
t.Errorf("Create should stamp id: %s", str)
}
if !strings.Contains(str, `tag="label"`) {
t.Errorf("Create should stamp tag: %s", str)
}
}
// -------------------------------------------------------------------------- //
// Update tagging + backup chain //
// -------------------------------------------------------------------------- //
func TestDeleteMovesVersionedBackups(t *testing.T) {
s, id := mkVersioned(t) // current + id.v1.xml + id.v2.xml
if err := s.Delete(id); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := s.Get(id); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Get after delete: want ErrNotFound, got %v", err)
}
if _, err := s.Versions(id); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Versions after delete: want ErrNotFound, got %v", err)
}
}
func TestListSkipsVersionedBackups(t *testing.T) {
s, _ := mkVersioned(t) // leaves id.v1.xml and id.v2.xml on disk
list, err := s.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 1 {
t.Fatalf("List should skip versioned backups, got %d entries: %+v", len(list), list)
}
}
func TestUpdateStampsIncrementingVersion(t *testing.T) {
s, id := mkVersioned(t)
data, err := s.Get(id)
if err != nil {
t.Fatalf("Get: %v", err)
}
if !strings.Contains(string(data), `version="3"`) {
t.Errorf("current should be version 3 after two updates: %s", data)
}
}
+1 -1
View File
@@ -7,8 +7,8 @@ import (
"time" "time"
"github.com/uopi/goca" "github.com/uopi/goca"
"github.com/uopi/goca/testca"
"github.com/uopi/goca/proto" "github.com/uopi/goca/proto"
"github.com/uopi/goca/testca"
) )
// newTestClient creates a Client pointing only at the fake server's addresses. // newTestClient creates a Client pointing only at the fake server's addresses.
+109
View File
@@ -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)
}
+47
View File
@@ -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")
}
}
+104
View File
@@ -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")
}
}
}
+57
View File
@@ -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")
}
})
}
+139
View File
@@ -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)
}
}
+163
View File
@@ -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")
}
}
+60 -1
View File
@@ -32,10 +32,18 @@ type Server struct {
mu sync.RWMutex mu sync.RWMutex
pvs map[string]*serverPV pvs map[string]*serverPV
subs map[uint32]*serverSub // subID → sub subs map[uint32]*serverSub // subID → sub
getFault int // fault to inject on the next READ_NOTIFY
done chan struct{} 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 { type serverPV struct {
spec PVSpec spec PVSpec
mu sync.RWMutex mu sync.RWMutex
@@ -93,6 +101,29 @@ func (s *Server) Close() {
s.udpCn.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. // SetValue updates a PV's value and pushes a monitor event to all subscribers.
func (s *Server) SetValue(pvName string, val any) error { func (s *Server) SetValue(pvName string, val any) error {
s.mu.RLock() s.mu.RLock()
@@ -350,6 +381,31 @@ func (s *Server) handleConn(conn net.Conn) {
continue 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() s.mu.RLock()
pv := s.pvs[pvName] pv := s.pvs[pvName]
s.mu.RUnlock() s.mu.RUnlock()
@@ -591,7 +647,10 @@ func nullStr(b []byte) string {
// Minimal io.Reader for proto.DecodeHeader // // 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} } func newBytesReader(b []byte) *bytesReader { return &bytesReader{b: b} }
-1
View File
@@ -573,4 +573,3 @@ func asError(s Status) error {
} }
return fmt.Errorf("%s", s.Error()) return fmt.Errorf("%s", s.Error())
} }
+175
View File
@@ -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)
}
}
+2
View File
@@ -3,7 +3,9 @@ package pvdata
import "io" import "io"
// BitSet is a variable-length set of bit indices, encoded on the wire as: // 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. // 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. // Used in pvData Monitor responses for "changed" and "overrun" masks.
type BitSet struct { type BitSet struct {
+361
View File
@@ -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())
}
}
+1
View File
@@ -293,6 +293,7 @@ func WriteFieldDesc(w io.Writer, fd FieldDesc) error {
// callers type-assert to concrete types listed below. // callers type-assert to concrete types listed below.
// //
// Scalar types: // Scalar types:
//
// bool, int8, int16, int32, int64, // bool, int8, int16, int32, int64,
// uint8, uint16, uint32, uint64, // uint8, uint16, uint32, uint64,
// float32, float64, string // float32, float64, string
+5
View File
@@ -86,6 +86,11 @@ insecure_skip_verify = false
enabled = false enabled = false
cert = "" # e.g. "/etc/uopi/tls/cert.pem" cert = "" # e.g. "/etc/uopi/tls/cert.pem"
key = "" # e.g. "/etc/uopi/tls/key.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 # Role-based access through group memberships. Each [[groups]] block lists
# members by role; a user's effective global capability is the highest role # members by role; a user's effective global capability is the highest role
+26 -1
View File
@@ -1,6 +1,6 @@
import { h, Fragment } from 'preact'; import { h, Fragment } from 'preact';
import { useState, useCallback, useRef, useEffect } from 'preact/hooks'; 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 { serializeInterface, parseInterface } from './lib/xml';
import { initLocalState } from './lib/localstate'; import { initLocalState } from './lib/localstate';
import SignalTree from './SignalTree'; import SignalTree from './SignalTree';
@@ -8,6 +8,7 @@ import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas';
import PlotPanelCanvas from './PlotPanelCanvas'; import PlotPanelCanvas from './PlotPanelCanvas';
import LogicEditor from './LogicEditor'; import LogicEditor from './LogicEditor';
import PropertiesPane from './PropertiesPane'; import PropertiesPane from './PropertiesPane';
import ShareDialog from './ShareDialog';
import HelpModal from './HelpModal'; import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl'; import ZoomControl from './ZoomControl';
import { useAuth, canWrite } from './lib/auth'; import { useAuth, canWrite } from './lib/auth';
@@ -121,6 +122,10 @@ export default function EditMode({ initial, onDone }: Props) {
const [showGroupMenu, setShowGroupMenu] = useState(false); const [showGroupMenu, setShowGroupMenu] = useState(false);
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
const [helpSection, setHelpSection] = useState('edit'); 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<Folder[]>([]);
const [leftW, setLeftW] = useState(220); const [leftW, setLeftW] = useState(220);
const [rightW, setRightW] = useState(260); const [rightW, setRightW] = useState(260);
// Center column tab: the drag-and-drop layout editor, or the panel-logic editor. // 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); 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 // Undo / redo. Snapshots capture both widgets and (for plot panels) the split
// layout, so split/close/resize operations are undone atomically. // layout, so split/close/resize operations are undone atomically.
type Snapshot = { widgets: Widget[]; layout?: PlotLayout }; type Snapshot = { widgets: Widget[]; layout?: PlotLayout };
@@ -748,6 +762,7 @@ export default function EditMode({ initial, onDone }: Props) {
onChange={handleWidgetChange} onChange={handleWidgetChange}
onRenameId={handleWidgetRename} onRenameId={handleWidgetRename}
onIfaceChange={handleIfaceChange} onIfaceChange={handleIfaceChange}
onShare={writable && iface.id ? openShare : undefined}
width={rightW} width={rightW}
/> />
</Fragment> </Fragment>
@@ -797,6 +812,16 @@ export default function EditMode({ initial, onDone }: Props) {
{showHelp && ( {showHelp && (
<HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} /> <HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
)} )}
{showShare && iface.id && (
<ShareDialog
ifaceId={iface.id}
ifaceName={iface.name}
folders={folders}
onClose={() => setShowShare(false)}
onSaved={() => window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'))}
/>
)}
</div> </div>
); );
} }
+9 -1
View File
@@ -10,6 +10,9 @@ interface Props {
onChange: (updated: Widget) => void; onChange: (updated: Widget) => void;
onRenameId: (oldId: string, newId: string) => void; onRenameId: (oldId: string, newId: string) => void;
onIfaceChange: (updated: Interface) => 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; width?: number;
} }
@@ -86,7 +89,7 @@ const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue',
// Widgets with multiple signals // Widgets with multiple signals
const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled', 'table']); 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 [collapsed, setCollapsed] = useState(false);
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]); const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
@@ -173,6 +176,11 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
</Field> </Field>
</div> </div>
)} )}
{onShare && (
<button class="panel-btn props-share-btn" onClick={onShare} title="Manage folder, public visibility and per-user/group sharing">
Share
</button>
)}
</div> </div>
{w && ( {w && (
+5
View File
@@ -3016,6 +3016,11 @@ body {
.props-section:last-child { border-bottom: none; } .props-section:last-child { border-bottom: none; }
.props-share-btn {
margin: 0.4rem 0.6rem 0;
width: calc(100% - 1.2rem);
}
.props-section-title { .props-section-title {
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 700; font-weight: 700;
+2
View File
@@ -2,3 +2,5 @@ Starting iocInit
iocRun: All initialization complete 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 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 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