package webapp import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "rocketgit.ru/rsmon/worker/internal/distworker" ) // _ = assert is a guard so test_helpers.go stays import-clean when // only the helpers below are needed. var _ = assert.New // distworkerHTTPConfig is a local alias for distworker.HTTPConfig so // the test stub matches the WorkerView interface signature. type _ = distworker.HTTPConfig // stubRunner is a minimal WorkerView used by tests. RecentResults // and RecentNotifications return the slices we configured; the rest // are simple getters. type stubRunner struct { id string region string version string caps []string lastAck time.Time token string rotated time.Time results []ResultRow notifs []NotificationRow // masterUp / masterAt let tests pin a specific // /api/peer/status response. The default is the zero value // ("no probe yet" / nil up), matching the runner's pre-Start // behavior. masterUp *bool masterAt time.Time } // distworkerHTTPConfig is a local alias for distworker.HTTPConfig so // the test stub does not pull in the worker package's full surface. type distworkerHTTPConfig = distworker.HTTPConfig func (s *stubRunner) HTTPConfig() distworker.HTTPConfig { return distworker.HTTPConfig{} } func (s *stubRunner) Token() string { return s.token } func (s *stubRunner) TokenRotatedAt() time.Time { return s.rotated } func (s *stubRunner) WorkerID() string { return s.id } func (s *stubRunner) RegionCode() string { return s.region } func (s *stubRunner) WorkerVersion() string { return s.version } func (s *stubRunner) WorkerCapabilities() []string { return s.caps } func (s *stubRunner) LastHeartbeatAck() time.Time { return s.lastAck } func (s *stubRunner) RecentResults(_ int) []ResultRow { return append([]ResultRow{}, s.results...) } func (s *stubRunner) RecentNotifications(_ int) []NotificationRow { return append([]NotificationRow{}, s.notifs...) } func (s *stubRunner) MasterStatus() (*bool, time.Time) { return s.masterUp, s.masterAt } // newTestServer builds an in-memory Server with a stubbed worker // view and no DB persistence (dataDir is a t.TempDir()). The // inventory and metrics goroutines are NOT started so tests stay // hermetic. func newTestServer(t *testing.T, runner WorkerView) *Server { t.Helper() return newTestServerWithBasicAuth(t, runner, "", "") } // newTestServerWithBasicAuth is the basic-auth-aware variant of // newTestServer. login == "" or password == "" disables basic auth. func newTestServerWithBasicAuth(t *testing.T, runner WorkerView, login, password string) *Server { t.Helper() dir := t.TempDir() cfg := Config{ Addr: "127.0.0.1:0", DataDir: dir, BasicAuthLogin: login, BasicAuthPassword: password, } srv, err := New(cfg, &Deps{ Runner: runner, Version: "test", BuildDate: "test-build", StartedAt: time.Now().UTC(), }) require.NoError(t, err) t.Cleanup(func() { _ = srv.Close(context.Background()) }) return srv } // newHTTPTestServer wraps srv.Handler in an httptest.Server so tests // can dial a real loopback port and let cookie jar + redirects work // naturally. func newHTTPTestServer(t *testing.T, srv *Server) *httptest.Server { t.Helper() ts := httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) return ts } // httpClient returns a cookie-aware *http.Client that does not // follow redirects automatically (tests want to see the 302). func httpClient() *http.Client { jar, _ := cookiejar.New(nil) return &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} } // loginAsFirstRun provisions a fresh password via the same path the // cmd binary uses (first-run hash written to the store, plaintext // returned), then logs the operator in by POSTing the credentials. // The cookie jar is returned so callers can re-use the session. // // The first-run flow is frictionless: the login lands on /overview // without bouncing through /web/change-password. The flag is still // recorded on the user row for a future hardening knob. func loginAsFirstRun(t *testing.T, baseURL string, srv *Server) (*http.Client, string) { t.Helper() plain, err := GenerateFirstRunPassword() require.NoError(t, err) hash, err := HashPassword(plain) require.NoError(t, err) _, err = srv.store.CreateFirstRunUser(context.Background(), hash) require.NoError(t, err) c := httpClient() form := url.Values{} form.Set("password", plain) req, _ := http.NewRequest(http.MethodPost, baseURL+"/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, "login should redirect on success") require.Equal(t, "/overview", resp.Header.Get("Location")) return c, plain } // provisionFirstRunUser seeds the store with a user whose bcrypt hash // matches the supplied plaintext and whose requires_change flag is // set. loginAsFirstRun wraps the round trip that consumes the seed. func provisionFirstRunUser(t *testing.T, srv *Server, plain string) { t.Helper() hash, err := HashPassword(plain) require.NoError(t, err) _, err = srv.store.CreateFirstRunUser(context.Background(), hash) require.NoError(t, err) } // clearRequiresChange flips the requires_change flag off after the // operator has changed their password (in tests we skip the change // form to assert the regular page flow). func clearRequiresChange(t *testing.T, srv *Server) { t.Helper() user, err := srv.store.GetUser(context.Background()) require.NoError(t, err) require.NoError(t, srv.store.MarkRequiresChange(context.Background(), user.ID)) // MarkRequiresChange sets it to 1; we want it off. Use a direct // helper. _, err = srv.store.db.ExecContext(context.Background(), `UPDATE webapp_users SET requires_change = 0 WHERE id = ?`, user.ID) require.NoError(t, err) } var ( _ = os.Getenv _ = filepath.Join _ = json.Marshal _ = bytes.NewReader _ = io.Copy _ = fmt.Sprintf )