Все проверки выполнены успешно
CI / test (push) Successful in 2m32s
Docker / Build and publish worker image (push) Successful in 18m17s
- 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.
567 строки
20 KiB
Go
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
|
|
)
|