Files
worker/internal/webapp/handlers_cluster_test.go
Gleb Tv 2c7a0236da feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
2026-07-13 17:55:14 +03:00

337 строки
12 KiB
Go

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
)