Некоторые проверки не удались
CI / test (push) Successful in 5m17s
Docker / Build and publish worker image (push) Has been cancelled
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.
602 строки
21 KiB
Go
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
|
|
)
|