badf17bebd
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.
187 lines
7.5 KiB
Go
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)
|
|
}
|
|
}
|