package webapp import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "strings" "sync" "testing" "time" "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 applyIndex uint64 applyErr error applyCalled int applyMu sync.Mutex clusterIDOut string addrOut string } func (s *stubCluster) Stats() ClusterStats { return s.stats } func (s *stubCluster) ApplyTestConfig() (uint64, error) { s.applyMu.Lock() defer s.applyMu.Unlock() s.applyCalled++ return s.applyIndex, s.applyErr } 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_NotConfigured verifies the 404 path // when WORKER_CLUSTER_DEBUG_APPLY is false (the production default) // and no cluster is attached. The handler must refuse before it // even checks the cluster because the debug flag is off. func TestClusterApplyTestConfig_NotConfigured(t *testing.T) { srv := newTestServer(t, &stubRunner{id: "w-1"}) require.False(t, srv.cfg.DebugClusterApply, "default config must leave the debug apply flag off") ts := newHTTPTestServer(t, srv) c, _ := loginAsFirstRun(t, ts.URL, srv) clearRequiresChange(t, srv) resp, err := c.PostForm(ts.URL+"/web/api/cluster/apply-test-config", url.Values{}) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusNotFound, resp.StatusCode, "debug apply must be invisible (404) when WORKER_CLUSTER_DEBUG_APPLY is unset") } // TestClusterApplyTestConfig_DebugOffReturns404 verifies that even // with a cluster attached the apply endpoint stays 404 unless the // debug flag is on. The flag, not cluster presence, gates the // endpoint. func TestClusterApplyTestConfig_DebugOffReturns404(t *testing.T) { stub := &stubCluster{applyIndex: 42} ts, _, c := withClusterServer(t, stub) resp, err := c.Get(ts.URL + "/overview") require.NoError(t, err) bodyBytes, _ := io.ReadAll(resp.Body) resp.Body.Close() //nolint:errcheck csrf := extractCSRFToken(t, string(bodyBytes)) form := url.Values{} form.Set("csrf_token", csrf) req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/cluster/apply-test-config", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err = c.Do(req) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusNotFound, resp.StatusCode) assert.Equal(t, 0, stub.applyCalled, "ApplyTestConfig must never be called when the debug flag is off") } // TestClusterApplyTestConfig_HappyPath verifies that the apply-test- // config endpoint returns the applied index when the cluster // subsystem accepts the entry. CSRF is checked. The DebugClusterApply // flag must be on for the endpoint to be reachable. func TestClusterApplyTestConfig_HappyPath(t *testing.T) { stub := &stubCluster{ stats: ClusterStats{ NodeID: "worker1", State: "Leader", Leader: "worker1", Voters: []string{"worker1"}, }, applyIndex: 13, clusterIDOut: "worker1", addrOut: "127.0.0.1:17401", } ts, srv, c := withClusterServer(t, stub) srv.cfg.DebugClusterApply = true // Fetch CSRF token from any authenticated page. resp, err := c.Get(ts.URL + "/overview") require.NoError(t, err) bodyBytes, _ := io.ReadAll(resp.Body) resp.Body.Close() //nolint:errcheck csrf := extractCSRFToken(t, string(bodyBytes)) form := url.Values{} form.Set("csrf_token", csrf) req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/cluster/apply-test-config", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err = c.Do(req) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusOK, resp.StatusCode) bodyBytes, _ = io.ReadAll(resp.Body) var got map[string]uint64 require.NoError(t, json.Unmarshal(bodyBytes, &got)) assert.EqualValues(t, 13, got["applied_index"]) assert.Equal(t, 1, stub.applyCalled) } // TestClusterApplyTestConfig_PropagatesError verifies that errors // from the cluster subsystem surface as 502 Bad Gateway. Debug flag // must be on. func TestClusterApplyTestConfig_PropagatesError(t *testing.T) { stub := &stubCluster{ applyErr: errStubApply, clusterIDOut: "worker1", addrOut: "127.0.0.1:17401", } ts, srv, c := withClusterServer(t, stub) srv.cfg.DebugClusterApply = true resp, err := c.Get(ts.URL + "/overview") require.NoError(t, err) bodyBytes, _ := io.ReadAll(resp.Body) resp.Body.Close() //nolint:errcheck csrf := extractCSRFToken(t, string(bodyBytes)) form := url.Values{} form.Set("csrf_token", csrf) req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/cluster/apply-test-config", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err = c.Do(req) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusBadGateway, resp.StatusCode) } // TestClusterApplyTestConfig_RequiresCSRF ensures the apply-test- // config POST is refused without a CSRF token. Debug flag must be // on for the endpoint to be reachable; without the flag it returns // 404 (priority over CSRF check). func TestClusterApplyTestConfig_RequiresCSRF(t *testing.T) { stub := &stubCluster{applyIndex: 99} ts, srv, c := withClusterServer(t, stub) srv.cfg.DebugClusterApply = true resp, err := c.PostForm(ts.URL+"/web/api/cluster/apply-test-config", url.Values{}) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusForbidden, resp.StatusCode, "apply-test-config without CSRF must be 403") assert.Equal(t, 0, stub.applyCalled, "ApplyTestConfig must not be called without CSRF") } // errStubApply is a sentinel error used by the apply-error test. var errStubApply = errApply("worker not leader") type errApply string func (e errApply) Error() string { return string(e) } // 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{applyIndex: 7} 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) } // _ = context.Background and time.Time keep the linter quiet about // unused imports if the file shrinks. var ( _ = context.Background _ = time.Now _ = url.Parse )