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 )