2487681396
gofmt -w across the controller tree (46 files) so gofmt -l is empty — disarms the
formatting landmine where a targeted edit + accidental gofmt -w swept ~46 unrelated
files. Pure formatting: whitespace + gofmt's optional-semicolon removal in reflowed
inline closures. One doc comment reworded ('' -> 'the empty string') to avoid gofmt's
Go-1.19 doc-comment typographic substitition ('' -> curly quote) muddying its meaning.
No build/vet/test behavior change.
172 lines
6.9 KiB
Go
172 lines
6.9 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
|
)
|
|
|
|
// Capability-gate tests (agent-outdated backstop): the probe runs through the SAME netAgent seam
|
|
// as the orchestration — fakeNetAgent's NetVerifyStatus doubles as the probed route.
|
|
|
|
// counts reads the fake's recorded call counters under its lock.
|
|
func (f *fakeNetAgent) counts() (adds, verifyCalls int) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.addCalls, f.verifyPoll
|
|
}
|
|
|
|
// setAddRes swaps the scripted add result between requests (under the fake's lock).
|
|
func (f *fakeNetAgent) setAddRes(res agentapi.NetStorageAddResult) {
|
|
f.mu.Lock()
|
|
f.addRes = res
|
|
f.mu.Unlock()
|
|
}
|
|
|
|
// postNetAdd drives the REAL handler (the pipeline a user triggers) with a valid body.
|
|
func postNetAdd(t *testing.T, s *Server, name string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
body := `{"name":"` + name + `","protocol":"nfs","server":"10.0.0.5","export":"/srv/` + name + `"}`
|
|
r := httptest.NewRequest(http.MethodPost, "/api/storage/netstorage/add", strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
s.handleNetStorageAdd(w, r)
|
|
return w
|
|
}
|
|
|
|
// --- T1 (Scenario A): old agent ⇒ sync refusal; the job NEVER starts ---------------------------
|
|
// Companion red-proof: remove the gate call in handleNetStorageAdd → the job starts against the
|
|
// old add semantics → the "never started" + zero-add-calls assertions fail.
|
|
func TestNetAddGate_OldAgent_RefusedUpFront(t *testing.T) {
|
|
s := testServer(t)
|
|
// The ≤0.80 shape: the probed route 404s (typed — the wire path is pinned in agentapi tests).
|
|
agent := &fakeNetAgent{
|
|
addRes: okAddRes("media"),
|
|
verifyErr: &agentapi.StatusError{Path: "/netstorage/verify-status", Code: http.StatusNotFound},
|
|
}
|
|
s.netAgentFn = func() (netAgent, error) { return agent, nil }
|
|
s.netProbeFn = func(context.Context, string) probeOutcome {
|
|
t.Error("probe must never run on a gated add")
|
|
return probeOutcome{}
|
|
}
|
|
|
|
w := postNetAdd(t, s, "media")
|
|
if w.Code != http.StatusPreconditionFailed {
|
|
t.Fatalf("gate: got %d want 412 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Body.String(), `"code":"agent_outdated"`) {
|
|
t.Errorf("refusal must carry the machine code agent_outdated: %s", w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Body.String(), "Az ügynök frissítése szükséges ehhez a funkcióhoz") {
|
|
t.Errorf("refusal must carry the §Part-3 Hungarian message: %s", w.Body.String())
|
|
}
|
|
// The orchestration was NEVER started and no agent add/remove ran.
|
|
if j := s.netAdd.snapshot(); j != nil {
|
|
t.Errorf("job slot must stay empty on a gated add, got %+v", j)
|
|
}
|
|
adds, _ := agent.counts()
|
|
if adds != 0 {
|
|
t.Errorf("agent add calls = %d, want 0", adds)
|
|
}
|
|
if got := agent.removed(); len(got) != 0 {
|
|
t.Errorf("no rollback should ever run (removes=%v)", got)
|
|
}
|
|
if got := networkPathCount(s); got != 0 {
|
|
t.Errorf("nothing may register on a gated add (got %d)", got)
|
|
}
|
|
// The single-flight slot was NEVER claimed — it must still be acquirable.
|
|
if !s.netAdd.acquire(&netAddJob{Name: "slot-check"}) {
|
|
t.Error("single-flight slot was consumed by the refused add")
|
|
}
|
|
s.netAdd.release()
|
|
}
|
|
|
|
// --- T2 (Scenario B): current agent ⇒ pre-gate behavior + ONE probe per cache window ------------
|
|
// Companion red-proof: drop the cache in SupportCache.Supports (always probe) → the warm-cache
|
|
// negative assertion (verify calls stays 1) fails with 2.
|
|
func TestNetAddGate_CurrentAgent_UnchangedAndProbeCached(t *testing.T) {
|
|
s := testServer(t)
|
|
// Pre-verify-shaped OK result (Verify != "started"): the orchestrator skips the verify poll, so
|
|
// the fake's NetVerifyStatus count measures the GATE PROBE alone.
|
|
res1 := okAddRes("m1")
|
|
res1.Verify, res1.JobID = "", ""
|
|
agent := &fakeNetAgent{addRes: res1, verify: agentapi.NetVerifyStatus{Phase: "none"}}
|
|
s.netAgentFn = func() (netAgent, error) { return agent, nil }
|
|
s.netProbeFn = func(context.Context, string) probeOutcome { return probeOutcome{OK: true} }
|
|
|
|
// Add #1 — identical end-to-end shape to the pre-gate happy path (C1's contract).
|
|
w := postNetAdd(t, s, "m1")
|
|
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"started":true`) {
|
|
t.Fatalf("add #1: got %d %s, want 200 started", w.Code, w.Body.String())
|
|
}
|
|
if job := waitNetAdd(t, s); job.Phase != netAddPhaseDone {
|
|
t.Fatalf("add #1 phase = %s (category=%s detail=%s), want done", job.Phase, job.Category, job.Detail)
|
|
}
|
|
if got := networkPathCount(s); got != 1 {
|
|
t.Fatalf("registered paths after add #1 = %d, want 1", got)
|
|
}
|
|
if got := agent.removed(); len(got) != 0 {
|
|
t.Fatalf("happy path must not roll back: %v", got)
|
|
}
|
|
|
|
// Add #2 inside the TTL window — the probe must answer from the cache.
|
|
res2 := okAddRes("m2")
|
|
res2.Verify, res2.JobID = "", ""
|
|
agent.setAddRes(res2)
|
|
w = postNetAdd(t, s, "m2")
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("add #2: got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
if job := waitNetAdd(t, s); job.Phase != netAddPhaseDone || job.Name != "m2" {
|
|
t.Fatalf("add #2 job = %+v, want done/m2", job)
|
|
}
|
|
adds, verifyCalls := agent.counts()
|
|
if adds != 2 {
|
|
t.Errorf("agent add calls = %d, want 2", adds)
|
|
}
|
|
if verifyCalls != 1 { // the NEGATIVE assertion: no probe increment on a warm cache
|
|
t.Errorf("probe (verify-status) calls across two adds = %d, want exactly 1 (cached)", verifyCalls)
|
|
}
|
|
}
|
|
|
|
// --- T3 (Scenario C): probe indeterminate ⇒ NEVER "too old"; the existing error paths speak -----
|
|
// Companion red-proof (the classification trap): mutate classifySupportErr to return SupportNo on
|
|
// ANY error → both subtests fail with the false 412 agent_outdated refusal.
|
|
func TestNetAddGate_ProbeIndeterminate_PassesThrough(t *testing.T) {
|
|
cases := map[string]error{
|
|
"transport error": errors.New("dial tcp 10.0.0.9:8443: connect: connection refused"),
|
|
"http 5xx": &agentapi.StatusError{Path: "/netstorage/verify-status", Code: http.StatusBadGateway},
|
|
}
|
|
for name, perr := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
s := testServer(t)
|
|
agent := &fakeNetAgent{
|
|
verifyErr: perr,
|
|
addErr: errors.New("agentapi: POST /netstorage/add: connection refused"),
|
|
}
|
|
s.netAgentFn = func() (netAgent, error) { return agent, nil }
|
|
|
|
w := postNetAdd(t, s, "media")
|
|
// The gate must NOT refuse: the add is accepted and fails through the EXISTING
|
|
// agent-error path with its honest message.
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("indeterminate probe must pass the gate: got %d (%s)", w.Code, w.Body.String())
|
|
}
|
|
if strings.Contains(w.Body.String(), "agent_outdated") || strings.Contains(w.Body.String(), "frissítés") {
|
|
t.Fatalf("a down agent must never be called outdated: %s", w.Body.String())
|
|
}
|
|
job := waitNetAdd(t, s)
|
|
if job.Phase != netAddPhaseFailed || job.Category != "agent_error" {
|
|
t.Errorf("job = %s/%s, want failed/agent_error (the existing path)", job.Phase, job.Category)
|
|
}
|
|
if got := networkPathCount(s); got != 0 {
|
|
t.Errorf("nothing may register (got %d)", got)
|
|
}
|
|
})
|
|
}
|
|
}
|