Files
felhom-controller/controller/internal/web/sharing_status_contract_test.go
T
admin badf17bebd v0.151.0 — the Megosztás page stops reloading, and says how to connect
S-1: /sharing/status coerced idle->running on the PHASE channel, so the first
poll of every steady-state page load reported a terminal job that never ran and
the client's repaint-reload fired ~1.2s apart, forever. The coercion's real duty
(liveness must never be contradicted) belongs to the 'running' LEVEL field
beside it, and is now pinned by its own regression test.

S-4 core: a terminal 'running' is served exactly once, so a REAL bring-up cannot
re-arm the reload on the page it just caused. failed/needs_password/in-flight
are never consumed. Unified async-job feedback stays the ROADMAP item.

S-2/S-5: new connect card with the Windows form, the Mac form and the direct
smb://<IP>, read from the SAMBA container's netns (the controller is on a docker
bridge and would answer 172.x). Derived per render, cached nowhere - the address
is a DHCP lease. Underivable => the line is omitted.

sharing.html's <script> block is byte-identical to v0.150.0. Red-proofed three
ways. 23/23 packages green.
2026-07-20 10:46:21 +02:00

187 lines
7.5 KiB
Go

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)
}
}