Files
worker/internal/webapp/server_test.go
root dde720eb44
Некоторые проверки не удались
CI / test (push) Successful in 5m17s
Docker / Build and publish worker image (push) Has been cancelled
fix(worker): correct inverted WORKER_COMPOSE_ENABLED parsing
parseBoolFalseDefault returned false only for explicit falsy literals
(including the empty string) and true otherwise, but the ComposeEnabled
call site negated it. The double error cancelled for an unset variable
(empty -> false -> !false -> true) but inverted every explicit value:
WORKER_COMPOSE_ENABLED=true disabled the subsystem while =false enabled
it. Rename the helper to parseBoolTrueDefault, drop the empty string
from the falsy set so unset stays on, and drop the negation. Add a
table-driven regression test pinning unset/true/1/yes -> on and
false/0/no/off (any case, trimmed) -> off.
2026-08-03 11:50:47 +03:00

602 строки
21 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")
}
// TestConfigFromEnvComposeEnabled pins the true-default parsing of
// WORKER_COMPOSE_ENABLED: the feature is on when unset and only an
// explicit falsy literal turns it off. Regression guard for the
// inverted-negation bug that made "true" disable the subsystem.
func TestConfigFromEnvComposeEnabled(t *testing.T) {
cases := []struct {
name string
env string
want bool
}{
{"unset empty", "", true},
{"explicit true", "true", true},
{"explicit one", "1", true},
{"explicit yes", "yes", true},
{"random non-falsy", "on", true},
{"explicit false", "false", false},
{"explicit zero", "0", false},
{"explicit no", "no", false},
{"explicit off", "off", false},
{"falsy with case", "FALSE", false},
{"falsy with whitespace", " off ", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
env := map[string]string{}
if c.env != "" {
env[envComposeEnabled] = c.env
}
cfg, err := ConfigFromEnv(env, t.TempDir())
require.NoError(t, err)
assert.Equal(t, c.want, cfg.ComposeEnabled)
})
}
}
// 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
)