package httpapi import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" ) // stopForwardReq mirrors the forwards start/stop request body. type stopForwardReq struct { Local string `json:"local"` Remote string `json:"remote"` RemotePort int `json:"remotePort"` } func postForwards(t *testing.T, srv *httptest.Server, action string, body stopForwardReq) int { t.Helper() b, _ := json.Marshal(body) req, _ := http.NewRequest(http.MethodPost, srv.URL+"/api/manager/forwards/"+action, bytes.NewReader(b)) req.SetBasicAuth("admin", "pw") req.Header.Set("Content-Type", "application/json") resp, err := srv.Client().Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() return resp.StatusCode } // TestStopForwardPersistsDisabledFlag is the regression test for the // "stopped forwards resurrect on restart" bug. // // The original defect: stopForward() read the link, called // SetLinkDisabled(true), but then handed the STALE (disabled=false) copy to // RevokeTask — so the disabled flag never reached the node owning the forward, // and nothing removed the topology entry. On the next restart the startup // reconcile saw an owned forward with no worker and re-claimed it, spawning a // worker for a forward the user had deliberately stopped (observed live: // ~14k "proxy already exists" retries and endless connection-refused against a // service that was intentionally down). // // The contract this pins: after a successful stop, the persisted link MUST be // disabled — that flag is the single source of truth the claim path consults. func TestStopForwardPersistsDisabledFlag(t *testing.T) { h, ts := newTestHandler(t) body := `{ "locals": [{"name":"svc","ip":"127.0.0.1","port":59999,"protocol":"tcp"}], "remotes": [{"name":"srv-a","ip":"1.2.3.4","port":7000,"enabled":true}], "links": [{"local":"svc","remote":"srv-a","remotePort":45999}] }` req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/manager/canvas", bytes.NewBufferString(body)) req.SetBasicAuth("admin", "pw") resp, err := ts.Client().Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("canvas save status = %d", resp.StatusCode) } // The forward starts enabled. ln, found, err := h.Store.LinkByTriple("svc", "srv-a", 45999) if err != nil || !found { t.Fatalf("link not persisted: found=%v err=%v", found, err) } if ln.Disabled { t.Fatal("a freshly saved forward must start enabled") } // Stop it. if code := postForwards(t, ts, "stop", stopForwardReq{"svc", "srv-a", 45999}); code != http.StatusOK { t.Fatalf("stop status = %d, want 200", code) } // Persisted flag must now be set — this is what the claim path reads. ln, found, err = h.Store.LinkByTriple("svc", "srv-a", 45999) if err != nil { t.Fatal(err) } if !found { t.Fatal("link vanished after stop; stop must be non-destructive") } if !ln.Disabled { t.Fatal("stop did not persist disabled=true — the startup reconcile would resurrect this forward") } // Start must clear it again (the user-facing re-enable path). if code := postForwards(t, ts, "start", stopForwardReq{"svc", "srv-a", 45999}); code != http.StatusOK { t.Fatalf("start status = %d, want 200", code) } ln, _, err = h.Store.LinkByTriple("svc", "srv-a", 45999) if err != nil { t.Fatal(err) } if ln.Disabled { t.Fatal("start did not clear disabled; a re-enabled forward would stay stopped") } } // TestStopForwardIsNonDestructive pins the per-forward stop semantics the // revoke path was specifically rewritten for: stopping one forward must not // touch a sibling forward that shares the same local or remote. func TestStopForwardIsNonDestructive(t *testing.T) { h, ts := newTestHandler(t) body := `{ "locals": [{"name":"svc","ip":"127.0.0.1","port":59999,"protocol":"tcp"}], "remotes": [{"name":"srv-a","ip":"1.2.3.4","port":7000,"enabled":true}], "links": [ {"local":"svc","remote":"srv-a","remotePort":45999}, {"local":"svc","remote":"srv-a","remotePort":46000} ] }` req, _ := http.NewRequest(http.MethodPut, ts.URL+"/api/manager/canvas", bytes.NewBufferString(body)) req.SetBasicAuth("admin", "pw") resp, err := ts.Client().Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if code := postForwards(t, ts, "stop", stopForwardReq{"svc", "srv-a", 45999}); code != http.StatusOK { t.Fatalf("stop status = %d", code) } stopped, _, _ := h.Store.LinkByTriple("svc", "srv-a", 45999) sibling, found, _ := h.Store.LinkByTriple("svc", "srv-a", 46000) if !found { t.Fatal("sibling forward was destroyed by stopping its neighbour") } if !stopped.Disabled { t.Error("the stopped forward should be disabled") } if sibling.Disabled { t.Error("the sibling forward must stay enabled — per-forward stop, not per-local/remote") } }