337 строки
12 KiB
Go
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
|
|
)
|