package httpapi import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "testing" "webui4frpc/internal/cluster" "webui4frpc/internal/process" "webui4frpc/internal/store" ) // newRingTestHandler builds a Handler WITH a ring engine attached, so the // paths that publish tasks into the token actually execute. newTestHandler // leaves Ring nil, which silently skips them — a test built on it can pass // while the publish side is completely broken. func newRingTestHandler(t *testing.T) (*Handler, *cluster.Engine, *httptest.Server) { t.Helper() dir := t.TempDir() st, err := store.New(filepath.Join(dir, "test.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = st.Close() }) pm := process.NewManager(process.Options{ ConfigsDir: filepath.Join(dir, "configs"), LogsDir: filepath.Join(dir, "logs"), BinaryPath: func() string { return "" }, Render: func(string) ([]byte, error) { return []byte(`{}`), nil }, AutoRestart: func(string) bool { return false }, RestartInterval: func() int { return 5 }, }) ring := cluster.NewEngine("n1", "n1:7500", "u", "p", "0.1.0", nil, &cluster.AppHandler{}, func(ctx context.Context, next string, tk *cluster.Token) error { return nil }, "n1:7500", true, "") h := &Handler{Store: st, Process: pm, WorkDir: dir, User: "admin", Password: "pw", Ring: ring} mux, err := NewServeMux(h) if err != nil { t.Fatal(err) } ts := httptest.NewServer(mux) t.Cleanup(ts.Close) return h, ring, ts } func saveCanvas(t *testing.T, srv *httptest.Server, body string) { t.Helper() req, _ := http.NewRequest(http.MethodPut, srv.URL+"/api/manager/canvas", bytes.NewBufferString(body)) req.SetBasicAuth("admin", "pw") resp, err := srv.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) } } // TestStopForwardPublishesDisabledFlagInRevokeTask is the regression test for // the actual defect. // // stopForward() read the link, called SetLinkDisabled(true), and then handed // the STALE copy (disabled=false) to RevokeTask. The revoke travels to the // node that OWNS the forward, and that node's Claim/Revoke path keys off the // flag — so a stale false meant: // - the owner could not tell the stop was deliberate, and // - nothing retired the topology entry, // // so the next restart's reconcile re-claimed the forward and spawned a worker // for something the user had stopped (seen live: endless connection-refused // against an intentionally-down service). // // This asserts the flag ON THE PUBLISHED TASK, which is the value that was // actually wrong. It cannot be satisfied by the store write alone. func TestStopForwardPublishesDisabledFlagInRevokeTask(t *testing.T) { _, ring, ts := newRingTestHandler(t) saveCanvas(t, ts, `{ "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}] }`) // Stop the forward over the API. b, _ := json.Marshal(stopForwardReq{"svc", "srv-a", 45999}) req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/manager/forwards/stop", bytes.NewReader(b)) 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("stop status = %d", resp.StatusCode) } // Find the revoke task that was published into the token. var revoke *cluster.Task for _, tk := range ring.State().PendingList() { if tk.Revoke && tk.Local.Name == "svc" && tk.Link.RemotePort == 45999 { revoke = tk break } } if revoke == nil { t.Fatal("stop did not publish a revoke task for the forward") } if !revoke.Link.Disabled { t.Fatal("the published revoke task carries disabled=false — the owner node cannot tell " + "this stop was deliberate, which is the bug that let stopped forwards resurrect") } } // TestStopThenStartPublishesRestartTask guards the failure mode that // mark-don't-remove introduces, and that only shows up on the WIRE. // // A stop keeps the topology entry (it is the carrier for the flag), so on // re-enable: // - SubmitTask dedupes, because the entry exists; // - the claim path discards any task for a forward that already has an owner. // // Both channels therefore refuse, no task is published, the owner never learns // about the re-enable, and the forward stays stopped forever — a forward the // user can stop but never restart. // // Asserting the task is PUBLISHED is the point: asserting only that the flag // cleared would pass while nothing reached the owner. (The earlier version of // this test did exactly that and stayed green against the broken code.) func TestStopThenStartPublishesRestartTask(t *testing.T) { _, ring, ts := newRingTestHandler(t) saveCanvas(t, ts, `{ "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}] }`) // Claim it so a topology entry exists (that is what makes the two normal // channels refuse later). if _, err := ring.OnToken(context.Background(), &cluster.Token{Cycle: 1, State: *ring.State()}); err != nil { t.Fatal(err) } if !ring.HasActiveTask("svc", "srv-a", 45999) { t.Fatalf("precondition: forward should be active, topo=%+v", ring.State().TopologyList()) } forwards := func(action string) { t.Helper() b, _ := json.Marshal(stopForwardReq{"svc", "srv-a", 45999}) req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/manager/forwards/"+action, bytes.NewReader(b)) 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("%s status = %d", action, resp.StatusCode) } } // --- stop: entry kept, marked disabled ------------------------------- forwards("stop") if d, known := ring.TopologyDisabled("svc", "srv-a", 45999); !known || !d { t.Fatalf("stop did not mark the topology entry disabled (known=%v disabled=%v)", known, d) } if ring.HasActiveTask("svc", "srv-a", 45999) { t.Fatal("a stopped forward must not count as active") } // --- start: a task MUST reach the owner ------------------------------ forwards("start") if d, _ := ring.TopologyDisabled("svc", "srv-a", 45999); d { t.Fatal("start did not clear the disabled flag") } if !ring.HasActiveTask("svc", "srv-a", 45999) { t.Fatal("after start the forward must be active again") } // The actual regression: something has to be queued for the owner. The // re-enable must be published as a restart task, since a plain creation task // would be deduped or discarded. var restart *cluster.Task for _, tk := range ring.State().PendingList() { if tk.Restart && tk.Local.Name == "svc" && tk.Link.RemotePort == 45999 { restart = tk break } } if restart == nil { t.Fatalf("start published no restart task — the owner would never bring the "+ "worker back, leaving the forward permanently stopped (pending=%+v)", ring.State().PendingList()) } if restart.Link.Disabled { t.Fatal("the restart task carries disabled=true, so the owner would re-apply the stop") } }