package web import ( "encoding/json" "io" "log" "net/http" "net/http/httptest" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/config" ) // v0.151.0 — the /sharing/status CONTRACT (DIAG-sharing-2026-07-20.md, S-1 + S-4 core). // // The endpoint carries two channels that mean different things to the client: // // phase — the ensure JOB, and `running` on it is an EDGE the client answers with location.reload() // running — the service LEVEL, true whenever the container is up // // v0.147.0 coerced idle→running on the PHASE channel so liveness could never be contradicted. Every // steady-state page load then reported a terminal edge on its first poll and reloaded — forever, // ~1.2s apart, for every customer with sharing enabled. These tests pin the separation from both // sides: a live container must NOT manufacture a phase (A), and a real bring-up must be reported // exactly ONCE (B) while the non-reloading phases stay put (C). // statusServer returns a Server whose liveness probe answers `running` and whose ensure slot starts // empty — the steady state of every box that has had sharing on for more than a moment. func statusServer(t *testing.T, running bool) *Server { t.Helper() s := testPageServer(t) s.stackMgr.SetSambaRunProbe(func() bool { return running }) return s } func getStatus(t *testing.T, s *Server) (phase string, isRunning bool) { t.Helper() rec := httptest.NewRecorder() s.sharingStatusHandler(rec, httptest.NewRequest(http.MethodGet, "/sharing/status", nil)) if rec.Code != http.StatusOK { t.Fatalf("status: got HTTP %d, want 200", rec.Code) } var env struct { OK bool `json:"ok"` Data struct { Phase string `json:"phase"` Error string `json:"error"` Running bool `json:"running"` } `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil { t.Fatalf("decode %q: %v", rec.Body.String(), err) } if !env.OK { t.Fatalf("envelope not ok: %s", rec.Body.String()) } return env.Data.Phase, env.Data.Running } // Scenario A — a live container alone must never produce an edge. THE loop test: with the v0.147.0 // coercion in place this fails on the very first call, which is exactly when the customer's page // reloaded. func TestSharingStatus_SteadyStateProducesNoEdge(t *testing.T) { s := statusServer(t, true) if job := s.sambaEnsure.snapshot(); job != nil { t.Fatalf("precondition: ensure slot must be empty, got phase %q", job.Phase) } for i := 1; i <= 2; i++ { phase, running := getStatus(t, s) if phase != sambaPhaseIdle { t.Errorf("call %d: phase = %q, want %q — a live container must not manufacture a job edge (this IS the reload loop)", i, phase, sambaPhaseIdle) } if !running { t.Errorf("call %d: running = false, want true", i) } } } // The b5d78d1 REGRESSION test. The coercion was defensive, not a fix for a named repro: its comment // read „a stale idle/running job must never contradict reality: liveness wins on a fresh page". That // duty is real and it survives — discharged by the `running` field, which is where a LEVEL belongs. // This is the test that would catch someone deleting the coercion's intent along with its code. func TestSharingStatus_LivenessStillReportedWithoutAnyJob(t *testing.T) { for _, alive := range []bool{true, false} { s := statusServer(t, alive) phase, running := getStatus(t, s) if running != alive { t.Errorf("SambaRunning()=%v: running = %v, want %v — liveness must reach the client with no job in memory", alive, running, alive) } if phase != sambaPhaseIdle { t.Errorf("SambaRunning()=%v: phase = %q, want %q", alive, phase, sambaPhaseIdle) } } } // Scenario B — a REAL bring-up: one edge, then silence. Without serve-once the loop returns after // every future image update, because the finished job outlives the reload it triggered. func TestSharingStatus_RealBringUpServedExactlyOnce(t *testing.T) { s := statusServer(t, true) s.sambaEnsure.set(&sambaEnsureJob{Phase: sambaPhaseRunning}) phase, running := getStatus(t, s) if phase != sambaPhaseRunning { t.Fatalf("first call: phase = %q, want %q — the genuine success edge must survive", phase, sambaPhaseRunning) } if !running { t.Error("first call: running = false, want true") } // The page the first response caused to reload polls again. It must find nothing. phase, running = getStatus(t, s) if phase != sambaPhaseIdle { t.Errorf("second call: phase = %q, want %q — a re-served success re-arms the reload (post-bring-up loop)", phase, sambaPhaseIdle) } if !running { t.Error("second call: running = false, want true — the LEVEL must outlive the consumed edge") } } // An in-flight job must never be consumed out from under itself: the goroutine sets the terminal // phase BEFORE its deferred release(), and eating it in that window loses the success the customer // is waiting on. func TestSharingStatus_RunningNotConsumedWhileInFlight(t *testing.T) { s := statusServer(t, true) if !s.sambaEnsure.acquire(&sambaEnsureJob{Phase: sambaPhaseRunning}) { t.Fatal("acquire refused") } if phase, _ := getStatus(t, s); phase != sambaPhaseRunning { t.Fatalf("in-flight: phase = %q, want %q", phase, sambaPhaseRunning) } if phase, _ := getStatus(t, s); phase != sambaPhaseRunning { t.Errorf("in-flight second call: phase = %q, want %q — consumed while the slot was still held", phase, sambaPhaseRunning) } s.sambaEnsure.release() if phase, _ := getStatus(t, s); phase != sambaPhaseRunning { t.Errorf("after release: phase = %q, want %q — the edge must be served once, now", phase, sambaPhaseRunning) } if phase, _ := getStatus(t, s); phase != sambaPhaseIdle { t.Errorf("after release, second call: phase = %q, want %q", phase, sambaPhaseIdle) } } // Scenario C — serve-once applies to `running` ONLY. `failed` and `needs_password` are durable // explanations whose client path stops the timer and shows a card with NO reload, so stickiness is // informative and cannot loop; in-flight phases must survive being polled. func TestSharingStatus_NonEdgePhasesStaySticky(t *testing.T) { for _, phase := range []string{sambaPhaseFailed, sambaPhaseNeedsPassword, sambaPhasePulling, sambaPhaseStarting} { s := statusServer(t, true) s.sambaEnsure.set(&sambaEnsureJob{Phase: phase, Error: "boom"}) for i := 1; i <= 3; i++ { if got, _ := getStatus(t, s); got != phase { t.Errorf("%s call %d: phase = %q, want it to persist", phase, i, got) } } } } // The JSON contract itself: keys and shape are what sharing.html's untouched poll reads. func TestSharingStatus_EnvelopeShapeUnchanged(t *testing.T) { s := statusServer(t, true) rec := httptest.NewRecorder() s.sharingStatusHandler(rec, httptest.NewRequest(http.MethodGet, "/sharing/status", nil)) var env struct { OK bool `json:"ok"` Data map[string]json.RawMessage `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil { t.Fatal(err) } for _, k := range []string{"phase", "error", "running"} { if _, ok := env.Data[k]; !ok { t.Errorf("data.%s missing — the client contract changed", k) } } if len(env.Data) != 3 { t.Errorf("data has %d keys, want exactly 3", len(env.Data)) } } // A nil stack manager must not panic the poll (the handler's own liveness guard). func TestSharingStatus_NilStackManagerIsQuiet(t *testing.T) { s := &Server{logger: log.New(io.Discard, "", 0), cfg: &config.Config{}} if phase, running := getStatus(t, s); phase != sambaPhaseIdle || running { t.Errorf("nil stackMgr: phase=%q running=%v, want idle/false", phase, running) } }