package webapp import ( "encoding/json" "io" "net/http" "net/http/httptest" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // stubCluster is a minimal ClusterView implementation used by the // handler tests. It returns canned values so the JSON shape can be // pinned without standing up a real raft group. type stubCluster struct { stats ClusterStats clusterIDOut string addrOut string } func (s *stubCluster) Stats() ClusterStats { return s.stats } func (s *stubCluster) ClusterID() string { return s.clusterIDOut } func (s *stubCluster) LocalAddr() string { return s.addrOut } // withClusterServer returns a test server whose ClusterView is the // supplied stub. The first-run password path is also exercised so // the session cookie is available for the cluster-endpoint probes. // Returns the *httptest.Server, the underlying *Server, and the // authenticated http.Client (cookie jar already populated). func withClusterServer(t *testing.T, c ClusterView) (*httptest.Server, *Server, *http.Client) { t.Helper() srv := newTestServer(t, &stubRunner{id: "w-1"}) srv.SetCluster(c) ts := newHTTPTestServer(t, srv) client, _ := loginAsFirstRun(t, ts.URL, srv) clearRequiresChange(t, srv) return ts, srv, client } // TestClusterStatus_NotConfigured verifies the 503 path when no // cluster subsystem is attached to the webapp. func TestClusterStatus_NotConfigured(t *testing.T) { srv := newTestServer(t, &stubRunner{id: "w-1"}) ts := newHTTPTestServer(t, srv) c, _ := loginAsFirstRun(t, ts.URL, srv) clearRequiresChange(t, srv) resp, err := c.Get(ts.URL + "/web/api/cluster/status") require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode, "cluster status must 503 when no cluster is attached") } // TestClusterStatus_HappyPath verifies the JSON shape of the // /web/api/cluster/status response when a stub cluster is attached. func TestClusterStatus_HappyPath(t *testing.T) { stub := &stubCluster{ stats: ClusterStats{ NodeID: "worker1", LocalAddr: "127.0.0.1:17401", State: "Leader", Leader: "worker1", Term: 17, AppliedIndex: 42, LastIndex: 42, NumPeers: 2, Voters: []string{"worker1", "worker2"}, FSMChecks: 1, FSMMembers: 2, FSMConfigVersion: 7, FSMOutboxLen: 3, FSMPartition: "steady", }, clusterIDOut: "worker1", addrOut: "127.0.0.1:17401", } ts, _, c := withClusterServer(t, stub) resp, err := c.Get(ts.URL + "/web/api/cluster/status") require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, "application/json; charset=utf-8", resp.Header.Get("Content-Type")) body, _ := io.ReadAll(resp.Body) var got clusterStatusResponse require.NoError(t, json.Unmarshal(body, &got)) assert.Equal(t, "worker1", got.SelfID) assert.Equal(t, "Leader", got.Role) assert.EqualValues(t, 17, got.Term) assert.Equal(t, "worker1", got.LeaderID) assert.Equal(t, []string{"worker1", "worker2"}, got.Voters) assert.EqualValues(t, 42, got.AppliedIndex) assert.EqualValues(t, 42, got.CommitIndex) assert.Equal(t, 1, got.FSMChecks) assert.Equal(t, 2, got.FSMMembers) assert.EqualValues(t, 7, got.FSMConfigVersion) assert.Equal(t, 3, got.FSMOutboxLen) assert.Equal(t, "steady", got.FSMPartition) assert.Equal(t, "worker1", got.ClusterID) assert.Equal(t, "127.0.0.1:17401", got.LocalAddr) } // TestClusterStatus_FSMFieldsZeroByDefault pins the FSM-side fields // to the zero value when the stub cluster does not set them. Guards // against a future refactor accidentally widening the wire format // with a non-zero default for a fresh cluster. func TestClusterStatus_FSMFieldsZeroByDefault(t *testing.T) { stub := &stubCluster{ stats: ClusterStats{ NodeID: "worker1", State: "Follower", Leader: "worker2", Voters: []string{"worker1", "worker2"}, }, clusterIDOut: "worker1", addrOut: "127.0.0.1:17401", } ts, _, c := withClusterServer(t, stub) resp, err := c.Get(ts.URL + "/web/api/cluster/status") require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck body, _ := io.ReadAll(resp.Body) var got clusterStatusResponse require.NoError(t, json.Unmarshal(body, &got)) assert.EqualValues(t, 0, got.FSMConfigVersion, "fresh cluster must report config_version 0") assert.Equal(t, 0, got.FSMOutboxLen, "fresh cluster must report outbox_len 0") assert.Equal(t, "", got.FSMPartition, "fresh cluster must report partition empty/zero") } // TestClusterStatus_RequiresSession ensures the cluster admin // endpoint is gated by the session middleware. func TestClusterStatus_RequiresSession(t *testing.T) { stub := &stubCluster{} srv := newTestServer(t, &stubRunner{id: "w-1"}) srv.SetCluster(stub) ts := newHTTPTestServer(t, srv) // No session cookie — should redirect to login. client := httpClient() resp, err := client.Get(ts.URL + "/web/api/cluster/status") require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusFound, resp.StatusCode, "cluster status must redirect to login without session") assert.Equal(t, "/web/login", resp.Header.Get("Location")) } // TestClusterApplyTestConfig_NotExposed verifies production requests cannot // append a hardcoded config through the former debug endpoint. func TestClusterApplyTestConfig_NotExposed(t *testing.T) { t.Setenv("WORKER_CLUSTER_DEBUG_APPLY", "true") ts, _, c := withClusterServer(t, &stubCluster{}) resp, err := c.Post(ts.URL+"/web/api/cluster/apply-test-config", "", nil) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusNotFound, resp.StatusCode) } // TestSetClusterDetaches verifies SetCluster(nil) returns the server // to the no-cluster-attached state (503 from the endpoints). func TestSetClusterDetaches(t *testing.T) { srv := newTestServer(t, &stubRunner{id: "w-1"}) stub := &stubCluster{} srv.SetCluster(stub) require.NotNil(t, srv.Cluster()) srv.SetCluster(nil) require.Nil(t, srv.Cluster()) ts := newHTTPTestServer(t, srv) c, _ := loginAsFirstRun(t, ts.URL, srv) clearRequiresChange(t, srv) resp, err := c.Get(ts.URL + "/web/api/cluster/status") require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) }