Files
worker/internal/webapp/server_test.go
Gleb Tv e987f24903
Все проверки выполнены успешно
CI / test (push) Successful in 2m32s
Docker / Build and publish worker image (push) Successful in 18m17s
fix(worker): harden control-plane lifecycle
- reconnect safely after token rotation and retry leased results
- reject malformed tasks and remove production cluster debug mutation
- validate environment files and require immutable container images

BREAKING CHANGE: Docker install, deploy, and Compose now require an
immutable repository@sha256 image reference.
2026-07-19 23:11:43 +03:00

567 строки
20 KiB
Go

package webapp
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestValidateBasicAuth pins the XOR rejection invariant for
// WORKER_LOGIN / WORKER_PASSWORD. Both empty disables basic auth;
// both set enables it; mixed (XOR) is a config bug.
func TestValidateBasicAuth(t *testing.T) {
cases := []struct {
name string
login string
password string
wantErr bool
}{
{"both empty", "", "", false},
{"login only", "user", "", true},
{"password only", "", "secret", true},
{"both set", "user", "secret", false},
{"whitespace login ignored", " ", "", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := ValidateBasicAuth(c.login, c.password)
if c.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
// TestConfigFromEnvDefaults pins the env-less default bind: the
// webapp must listen on 0.0.0.0:27401 (not on the main RSMon port
// 7401) when no env overrides are set. The change from 127.0.0.1
// to 0.0.0.0 was made in tandem with the basic-auth rewrite so the
// webapp can be reached on a real interface by an operator who
// fronted the worker with Traefik/nginx.
func TestConfigFromEnvDefaults(t *testing.T) {
cfg, err := ConfigFromEnv(map[string]string{}, t.TempDir())
require.NoError(t, err)
assert.Equal(t, "0.0.0.0", cfg.Addr[:7], "default host must be 0.0.0.0")
assert.Equal(t, "27401", cfg.Addr[len(cfg.Addr)-5:], "default port must be 27401")
assert.False(t, cfg.BasicAuthLogin != "" || cfg.BasicAuthPassword != "",
"basic auth must be off by default")
}
// TestConfigFromEnvBasicAuthAcceptsBothEnvVars exercises the new
// happy path where WORKER_LOGIN and WORKER_PASSWORD are both set.
func TestConfigFromEnvBasicAuthAcceptsBothEnvVars(t *testing.T) {
env := map[string]string{
"WORKER_LOGIN": "alice",
"WORKER_PASSWORD": "s3cret",
"WORKER_HOST": "0.0.0.0",
"WORKER_PORT": "30000",
}
cfg, err := ConfigFromEnv(env, t.TempDir())
require.NoError(t, err)
assert.Equal(t, "alice", cfg.BasicAuthLogin)
assert.Equal(t, "s3cret", cfg.BasicAuthPassword)
assert.Equal(t, "0.0.0.0:30000", cfg.Addr)
}
// TestConfigFromEnvBasicAuthRejectsXOR pins the regression guard:
// setting only one of WORKER_LOGIN / WORKER_PASSWORD must fail fast
// so an operator notices the misconfiguration.
func TestConfigFromEnvBasicAuthRejectsXOR(t *testing.T) {
_, err := ConfigFromEnv(map[string]string{
"WORKER_LOGIN": "alice",
}, t.TempDir())
assert.Error(t, err, "XOR (login only) must be rejected")
_, err = ConfigFromEnv(map[string]string{
"WORKER_PASSWORD": "s3cret",
}, t.TempDir())
assert.Error(t, err, "XOR (password only) must be rejected")
}
// TestConfigFromEnvReleaseURL pins the env-driven WORKER_RELEASE_URL
// plumbing. The handler reads cfg.ReleaseURL when the page renders,
// so the value must survive ConfigFromEnv exactly.
func TestConfigFromEnvReleaseURL(t *testing.T) {
cfg, err := ConfigFromEnv(map[string]string{}, t.TempDir())
require.NoError(t, err)
assert.Empty(t, cfg.ReleaseURL, "default ReleaseURL must be empty")
cfg, err = ConfigFromEnv(map[string]string{
"WORKER_RELEASE_URL": " https://example.com/releases ",
}, t.TempDir())
require.NoError(t, err)
assert.Equal(t, "https://example.com/releases", cfg.ReleaseURL,
"ReleaseURL must be trimmed before storage")
}
// TestLoginPageRendersAnonymous verifies the login page is
// reachable without a session and returns the right HTTP headers.
// In local-only mode the form has only a password field; the
// basic-auth variant adds a username field.
func TestLoginPageRendersAnonymous(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
ts := newHTTPTestServer(t, srv)
resp, err := http.Get(ts.URL + "/web/login")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "default-src 'self'")
assert.Equal(t, "no-store", resp.Header.Get("Cache-Control"))
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "RSMon worker login")
assert.Contains(t, string(body), `name="password"`)
assert.NotContains(t, string(body), `name="login"`,
"local-only login form must not show a username field")
}
// TestLoginPageShowsUsernameFieldWhenBasicAuthConfigured confirms the
// login form renders a username input and the basic-auth explanatory
// copy when WORKER_LOGIN/WORKER_PASSWORD are set on the server.
func TestLoginPageShowsUsernameFieldWhenBasicAuthConfigured(t *testing.T) {
srv := newTestServerWithBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
ts := newHTTPTestServer(t, srv)
resp, err := http.Get(ts.URL + "/web/login")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), `name="login"`,
"basic-auth login form must show a username field")
assert.Contains(t, string(body), "WORKER_LOGIN")
}
// TestAuthenticatedPagesRedirectToLogin asserts every authenticated
// route returns 302 to /web/login when no session cookie is sent.
func TestAuthenticatedPagesRedirectToLogin(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
ts := newHTTPTestServer(t, srv)
pages := []string{
"/overview",
"/apps",
"/checks",
"/notifications",
"/logs",
"/status",
"/settings",
"/updates",
}
for _, p := range pages {
t.Run(p, func(t *testing.T) {
c := httpClient()
req, _ := http.NewRequest(http.MethodGet, ts.URL+p, nil)
resp, err := c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusFound, resp.StatusCode,
"%s must redirect to login when unauthenticated", p)
assert.Equal(t, "/web/login", resp.Header.Get("Location"))
})
}
}
// TestFirstLoginReachesOverviewDirectly pins the frictionless
// first-login flow: the operator lands on /overview immediately and
// is NOT redirected to /web/change-password even though
// requires_change is set on the bcrypt user row. The flag is kept
// in the schema as a future hardening knob but the login flow no
// longer enforces it.
func TestFirstLoginReachesOverviewDirectly(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
// /overview must NOT redirect to change-password any more.
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/overview", nil)
resp, err := c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusOK, resp.StatusCode,
"first-login must reach /overview without bouncing to change-password")
// The flag is still recorded on the user row so a future
// hardening toggle can re-enforce it without a schema change.
user, err := srv.store.GetUser(context.Background())
require.NoError(t, err)
assert.True(t, user.RequiresChange,
"requires_change flag must remain set on the user row for future use")
}
// TestRequiresChangeFlagDoesNotBlockAPIAccess complements the
// overview test: even on /web/api/* the flag must not bounce the
// operator. The path is the change-password page itself (which is
// under /web/, not /web/api/), so we exercise the overview path as
// a proxy for "any authenticated page".
func TestRequiresChangeFlagDoesNotBlockAPIAccess(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
for _, path := range []string{"/overview", "/apps", "/checks", "/status", "/settings"} {
req, _ := http.NewRequest(http.MethodGet, ts.URL+path, nil)
resp, err := c.Do(req)
require.NoError(t, err)
resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusOK, resp.StatusCode,
"%s must not redirect away despite requires_change flag", path)
}
}
// TestChangePasswordClearsFlagAndAllowsOverview exercises the full
// change-password form. CSRF check is enforced, the requires_change
// flag clears. The change-password page is reachable without a
// forced redirect from /overview, so the test starts by walking
// the operator straight into the form.
func TestChangePasswordClearsFlagAndAllowsOverview(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
ts := newHTTPTestServer(t, srv)
c, plain := loginAsFirstRun(t, ts.URL, srv)
// /overview is reachable immediately (no forced redirect any
// more); the operator clicks "change password" themselves.
resp, err := c.Get(ts.URL + "/overview")
require.NoError(t, err)
resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
// Now drive the change-password form directly.
resp, err = c.Get(ts.URL + "/web/change-password")
require.NoError(t, err)
body, _ := io.ReadAll(resp.Body)
resp.Body.Close() //nolint:errcheck
csrf := extractCSRFToken(t, string(body))
form := url.Values{}
form.Set("current_password", plain)
form.Set("new_password", "new-stronger-password")
form.Set("new_password_confirm", "new-stronger-password")
form.Set("csrf_token", csrf)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/change-password", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err = c.Do(req)
require.NoError(t, err)
resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusFound, resp.StatusCode)
assert.Equal(t, "/overview", resp.Header.Get("Location"))
// The flag must now be cleared on the user row.
user, err := srv.store.GetUser(context.Background())
require.NoError(t, err)
assert.False(t, user.RequiresChange,
"requires_change must clear after the operator updates the password")
}
// TestChangePasswordRequiresCSRF ensures state-changing endpoints
// refuse requests without a CSRF token (defense in depth on top of
// the double-submit comparison).
func TestChangePasswordRequiresCSRF(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
form := url.Values{}
form.Set("current_password", "irrelevant")
form.Set("new_password", "new-stronger-password")
form.Set("new_password_confirm", "new-stronger-password")
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/change-password", 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.StatusForbidden, resp.StatusCode,
"change-password POST without CSRF must be 403")
}
// TestLogoutClearsSession verifies the session row is removed and
// subsequent authenticated pages redirect to login again.
func TestLogoutClearsSession(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
clearRequiresChange(t, srv)
// Now the operator can reach /overview.
resp, err := c.Get(ts.URL + "/overview")
require.NoError(t, err)
resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
// Fetch CSRF, then POST /web/logout.
resp, err = c.Get(ts.URL + "/overview")
require.NoError(t, err)
body, _ := io.ReadAll(resp.Body)
resp.Body.Close() //nolint:errcheck
csrf := extractCSRFToken(t, string(body))
form := url.Values{}
form.Set("csrf_token", csrf)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/logout", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err = c.Do(req)
require.NoError(t, err)
resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusFound, resp.StatusCode)
// Authenticated pages must now redirect again.
req, _ = http.NewRequest(http.MethodGet, ts.URL+"/overview", nil)
resp, err = c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusFound, resp.StatusCode)
assert.Equal(t, "/web/login", resp.Header.Get("Location"))
}
// TestAuditRowsWrittenOnLoginAndLogout ensures the audit log
// captures the auth events with the right shape.
func TestAuditRowsWrittenOnLoginAndLogout(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 + "/overview")
require.NoError(t, err)
body2, _ := io.ReadAll(resp.Body)
resp.Body.Close() //nolint:errcheck
csrf := extractCSRFToken(t, string(body2))
// Logout.
form := url.Values{}
form.Set("csrf_token", csrf)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/logout", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err = c.Do(req)
require.NoError(t, err)
resp.Body.Close() //nolint:errcheck
entries, err := srv.store.RecentAudit(context.Background(), 10)
require.NoError(t, err)
var actions []string
for _, e := range entries {
actions = append(actions, e.Action)
}
assert.Contains(t, actions, "login")
assert.Contains(t, actions, "logout")
}
// TestOverviewRendersStubData wires the stubbed worker view and
// asserts /overview renders without panicking and includes the
// stubbed worker id.
func TestOverviewRendersStubData(t *testing.T) {
srv := newTestServer(t, &stubRunner{
id: "w-stub-1",
token: "abcdefghijklmnop",
lastAck: time.Now().Add(-time.Minute).UTC(),
})
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
clearRequiresChange(t, srv)
resp, err := c.Get(ts.URL + "/overview")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
body := mustBody(t, resp)
assert.Contains(t, body, "w-stub-1")
assert.Contains(t, body, "RSMon worker v")
}
// TestHealthzPublic verifies the health endpoint is reachable
// without a session and returns 200 + "ok".
func TestHealthzPublic(t *testing.T) {
srv := newTestServer(t, &stubRunner{})
ts := newHTTPTestServer(t, srv)
resp, err := http.Get(ts.URL + "/healthz")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, "ok\n", string(body))
}
// TestStaticAssetsServedWithoutAuth confirms /static/ is served
// publicly so the login page can render its CSS without a session.
func TestStaticAssetsServedWithoutAuth(t *testing.T) {
srv := newTestServer(t, &stubRunner{})
ts := newHTTPTestServer(t, srv)
resp, err := http.Get(ts.URL + "/static/style.css")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), ".topbar")
}
// TestFirstRunPasswordStableAcrossRestart ensures the first-run
// password survives a process restart (the same bcrypt hash is
// re-loaded).
func TestFirstRunPasswordStableAcrossRestart(t *testing.T) {
dir := t.TempDir()
hash, err := HashPassword("seed-password-for-test")
require.NoError(t, err)
_, err = openWithSeed(dir, hash)
require.NoError(t, err)
_, err = openWithSeed(dir, hash)
require.NoError(t, err)
s3, err := openWithSeed(dir, hash)
require.NoError(t, err)
user, err := s3.GetUser(context.Background())
require.NoError(t, err)
assert.True(t, passwordMatches(user.BcryptHash, "seed-password-for-test"))
}
func openWithSeed(dir, hash string) (*Store, error) {
store, err := OpenStore(filepathJoin(dir, "webapp.db"))
if err != nil {
return nil, err
}
if _, err := store.GetUser(context.Background()); err != nil {
// No user yet, seed one.
_, err := store.CreateUser(context.Background(), hash, false)
if err != nil {
return nil, err
}
}
return store, nil
}
func passwordMatches(hash, plain string) bool {
return VerifyPassword(hash, plain) == nil
}
// helpers (kept local to avoid leaking test-only helpers into prod).
func filepathJoin(a, b string) string {
// tiny re-impl to avoid importing path/filepath at the top of
// every test case (the import is pulled in via test_helpers.go).
return a + "/" + b
}
func extractCSRFToken(t *testing.T, body string) string {
t.Helper()
const marker = `name="csrf_token" value="`
idx := strings.Index(body, marker)
require.GreaterOrEqual(t, idx, 0, "no csrf token found in body")
rest := body[idx+len(marker):]
end := strings.Index(rest, `"`)
require.GreaterOrEqual(t, end, 0, "csrf token not terminated")
return rest[:end]
}
func mustBody(t *testing.T, resp *http.Response) string {
t.Helper()
defer resp.Body.Close() //nolint:errcheck
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return string(b)
}
// TestBasicAuthMiddlewareAcceptsCredentials exercises the HTTP Basic
// auth fast-path on /web/api/*: a correctly-configured client gets
// 200 from the cluster status endpoint without a session cookie.
func TestBasicAuthMiddlewareAcceptsCredentials(t *testing.T) {
srv := newTestServerWithBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
ts := newHTTPTestServer(t, srv)
c := httpClient()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/cluster/status", nil)
req.SetBasicAuth("alice", "s3cret")
resp, err := c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
// The stub server has no cluster attached, so the handler
// returns 503 "no cluster subsystem". What matters here is that
// the basic-auth check passed (no 401 with WWW-Authenticate).
assert.NotEqual(t, http.StatusUnauthorized, resp.StatusCode)
}
// TestBasicAuthMiddlewareRejectsBadPassword confirms an incorrect
// password returns 401 + WWW-Authenticate header so curl prompts the
// operator to retry.
func TestBasicAuthMiddlewareRejectsBadPassword(t *testing.T) {
srv := newTestServerWithBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
ts := newHTTPTestServer(t, srv)
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/cluster/status", nil)
req.SetBasicAuth("alice", "wrong")
c := http.DefaultClient
resp, err := c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
assert.Contains(t, resp.Header.Get("WWW-Authenticate"), `Basic realm=`)
}
// TestBasicAuthLoginFormHappyPath drives the form-submission path:
// operator types username+password matching WORKER_LOGIN/WORKER_PASSWORD
// and lands on /overview.
func TestBasicAuthLoginFormHappyPath(t *testing.T) {
srv := newTestServerWithBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
ts := newHTTPTestServer(t, srv)
c := httpClient()
form := url.Values{}
form.Set("login", "alice")
form.Set("password", "s3cret")
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/login", 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
require.Equal(t, http.StatusFound, resp.StatusCode,
"basic-auth login should redirect on success")
assert.Equal(t, pathOverview, resp.Header.Get("Location"))
}
// TestBasicAuthLoginFormRejectsWrongPassword ensures a wrong password
// keeps the operator on the login page (302 NOT issued) and writes an
// audit row tagged basic_auth.
func TestBasicAuthLoginFormRejectsWrongPassword(t *testing.T) {
srv := newTestServerWithBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret")
ts := newHTTPTestServer(t, srv)
c := httpClient()
form := url.Values{}
form.Set("login", "alice")
form.Set("password", "wrong")
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.Do(req)
require.NoError(t, err)
resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusOK, resp.StatusCode,
"wrong password re-renders the login page (no 302)")
entries, err := srv.store.RecentAudit(context.Background(), 5)
require.NoError(t, err)
var sawFail bool
for _, e := range entries {
if e.Action == "login_failed" && e.AuthMode == "basic_auth" {
sawFail = true
}
}
assert.True(t, sawFail, "audit log must capture basic-auth login_failed")
}
var (
_ = json.Marshal
_ = os.Getenv
)