Implemented admin pane + user permission

This commit is contained in:
Martino Ferrari
2026-06-22 17:49:14 +02:00
parent 73fcbe7b28
commit ac24011487
67 changed files with 5925 additions and 411 deletions
+196
View File
@@ -0,0 +1,196 @@
package controllogic
import (
"context"
"sync"
"testing"
"time"
)
// fakeObserver records every DebugEvent it receives, in order.
type fakeObserver struct {
mu sync.Mutex
events []DebugEvent
}
func (f *fakeObserver) Observe(ev DebugEvent) {
f.mu.Lock()
f.events = append(f.events, ev)
f.mu.Unlock()
}
func (f *fakeObserver) snapshot() []DebugEvent {
f.mu.Lock()
defer f.mu.Unlock()
return append([]DebugEvent(nil), f.events...)
}
func (f *fakeObserver) last(nodeID string) (DebugEvent, bool) {
f.mu.Lock()
defer f.mu.Unlock()
for i := len(f.events) - 1; i >= 0; i-- {
if f.events[i].NodeID == nodeID {
return f.events[i], true
}
}
return DebugEvent{}, false
}
// thresholdWriteGraph is a 2-node flow: a timer trigger → an action.write of 42
// to "tgt:OUT". Used by both the observe and simulate tests.
func thresholdWriteGraph(id string) Graph {
return Graph{
ID: id,
Name: "flow",
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "100"}},
{ID: "w", Kind: "action.write", Params: map[string]string{"target": "tgt:OUT", "expr": "42"}},
},
Wires: []Wire{{From: "t", To: "w"}},
}
}
// TestDebugObserverCapturesNodeValues drives a flow directly and asserts the
// observer saw the trigger fire and the write node's value — and that the real
// data-source write still happened (live mode, not dry-run).
func TestDebugObserverCapturesNodeValues(t *testing.T) {
e, src, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
e.SetDebugWatch(map[string]bool{"g1": true})
cg := compile(thresholdWriteGraph("g1"))
cg.engine = e
cg.genCtx = context.Background()
cg.wg = &sync.WaitGroup{}
cg.activate("t")
cg.wg.Wait()
events := obs.snapshot()
if len(events) == 0 {
t.Fatal("observer captured no events")
}
// Trigger then write must both be reported.
if _, ok := obs.last("t"); !ok {
t.Error("no event for trigger node 't'")
}
wEv, ok := obs.last("w")
if !ok {
t.Fatal("no event for write node 'w'")
}
if !wEv.HasValue || wEv.Value != 42 {
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
}
if wEv.GraphID != "g1" {
t.Errorf("event GraphID = %q, want g1", wEv.GraphID)
}
// Live mode: the real write went through.
if got, ok := src.get("OUT"); !ok || toNum(got) != 42 {
t.Errorf("OUT = %v (ok=%v), want 42 written", got, ok)
}
}
// manualWriteGraph is a flow whose trigger never fires on its own (a threshold
// with no signal wired) → an action.write of 42 to "tgt:OUT". Used to prove
// FireTrigger forces a run that would otherwise never happen.
func manualWriteGraph(id string) Graph {
return Graph{
ID: id,
Name: "flow",
Nodes: []Node{
{ID: "t", Kind: "trigger.threshold", Params: map[string]string{"op": ">", "value": "0"}},
{ID: "w", Kind: "action.write", Params: map[string]string{"target": "tgt:OUT", "expr": "42"}},
},
Wires: []Wire{{From: "t", To: "w"}},
}
}
// TestFireTriggerForcesRun verifies FireTrigger drives a flow whose trigger
// never fires on its own, routed through the simulate sandbox.
func TestFireTriggerForcesRun(t *testing.T) {
e, _, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
e.SetDebugWatch(map[string]bool{})
stop := e.StartSimulate(manualWriteGraph("sim"))
defer stop()
// Nothing should have run yet — the threshold trigger has no signal.
time.Sleep(50 * time.Millisecond)
if _, ok := obs.last("w"); ok {
t.Fatal("write node ran before any manual fire")
}
if !e.FireTrigger("sim", "t") {
t.Fatal("FireTrigger returned false for an active simulate route")
}
// Poll for the write node event (the flow runs on its own goroutine).
var wEv DebugEvent
for i := 0; i < 100; i++ {
if ev, ok := obs.last("w"); ok {
wEv = ev
break
}
time.Sleep(10 * time.Millisecond)
}
if !wEv.HasValue || wEv.Value != 42 {
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
}
// An unknown route id must be a no-op (best-effort, returns false).
if e.FireTrigger("does-not-exist", "t") {
t.Error("FireTrigger returned true for an unknown route")
}
}
// TestDebugWatchGatesEmission verifies emitDebug is silent for graphs absent
// from the watch set, so unwatched live graphs cost nothing.
func TestDebugWatchGatesEmission(t *testing.T) {
e, _, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
e.SetDebugWatch(map[string]bool{}) // nobody watching
cg := compile(thresholdWriteGraph("g1"))
cg.engine = e
cg.genCtx = context.Background()
cg.wg = &sync.WaitGroup{}
cg.activate("t")
cg.wg.Wait()
if got := obs.snapshot(); len(got) != 0 {
t.Errorf("observer received %d events for an unwatched graph, want 0", len(got))
}
}
// TestSimulateSuppressesWrites runs the graph through the dry-run sandbox and
// asserts node events are still emitted (alwaysDebug) but NO real write occurs.
func TestSimulateSuppressesWrites(t *testing.T) {
e, src, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
// Deliberately leave the watch set empty: simulate must emit regardless.
e.SetDebugWatch(map[string]bool{})
stop := e.StartSimulate(thresholdWriteGraph("sim"))
time.Sleep(250 * time.Millisecond) // let the 100 ms timer fire at least once
stop()
if _, ok := src.get("OUT"); ok {
t.Errorf("simulate performed a real write to OUT, want none")
}
wEv, ok := obs.last("w")
if !ok {
t.Fatal("simulate produced no event for write node 'w'")
}
if !wEv.HasValue || wEv.Value != 42 {
t.Errorf("simulate write event = %+v, want value 42 hasValue true", wEv)
}
if wEv.GraphID != "sim" {
t.Errorf("simulate event GraphID = %q, want sim", wEv.GraphID)
}
}