package webapp import ( "context" "encoding/json" "net/http" "net/url" "os" "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "rocketgit.ru/rsmon/worker/internal/compose" ) // newComposeTestServer builds a session-auth server with the Compose // subsystem enabled. The default newTestServer leaves ComposeEnabled // false (zero-value Config), so the handlers would short-circuit to the // disabled banner; flipping the refresher flag on exercises the real // code paths without starting the background loop. func newComposeTestServer(t *testing.T, runner WorkerView) *Server { t.Helper() srv := newTestServer(t, runner) srv.compose.enabled = true return srv } // newComposeTestServerBasicAuth is the basic-auth variant used by the // /web/api/compose/* tests: those routes accept HTTP basic credentials // in lieu of a session cookie. func newComposeTestServerBasicAuth(t *testing.T, runner WorkerView, login, password string) *Server { t.Helper() srv := newTestServerWithBasicAuth(t, runner, login, password) srv.compose.enabled = true return srv } // injectComposeSnapshot seeds the refresher with a single "rsmon" // project so the list/detail/logs handlers have something to render // without exec'ing Docker. The project's WorkingDir is a real temp dir // so management operations (which chdir into it) succeed under a stub // Docker binary. func injectComposeSnapshot(t *testing.T, srv *Server, workDir string) { t.Helper() res := &compose.DiscoveryResult{ Projects: map[string]compose.Project{ "rsmon": { Name: "rsmon", Status: "running(1)", WorkingDir: workDir, ConfigFiles: filepath.Join(workDir, "docker-compose.yml"), Services: map[string]compose.Service{ "web": {Name: "web", Containers: []compose.Container{{ ID: "c1", Name: "rsmon-web-1", Image: "nginx:latest", State: "running", Status: "Up 5 minutes", Health: "healthy", PID: 4242, Ports: []compose.PortBinding{{HostIP: "0.0.0.0", HostPort: "8080"}}, Mounts: []compose.Mount{{Source: filepath.Join(workDir, "data"), Destination: "/data", Type: "bind"}}, Labels: map[string]string{ compose.LabelComposeProject: "rsmon", compose.LabelComposeService: "web", }, }}}, }, }, }, } snap := compose.Summarize(res) srv.compose.mu.Lock() srv.compose.snap = snap srv.compose.lastAt = time.Now().UTC() srv.compose.mu.Unlock() } // stubDockerOK points the compose package at an executable stub that // echoes its arguments and exits 0, so management/logs handlers run the // full exec path without a real Docker daemon. Restored on cleanup. func stubDockerOK(t *testing.T) { t.Helper() dir := t.TempDir() p := filepath.Join(dir, "stub-docker") script := "#!/bin/sh\necho \"stub: $*\"\nexit 0\n" require.NoError(t, os.WriteFile(p, []byte(script), 0o755)) compose.SetDockerBin(p) t.Cleanup(func() { compose.SetDockerBin("") }) } // stubDockerFail points at a stub that exits non-zero with a stderr // message, so the !OK management path is observable. func stubDockerFail(t *testing.T) { t.Helper() dir := t.TempDir() p := filepath.Join(dir, "stub-docker") script := "#!/bin/sh\necho \"compose boom\" >&2\nexit 1\n" require.NoError(t, os.WriteFile(p, []byte(script), 0o755)) compose.SetDockerBin(p) t.Cleanup(func() { compose.SetDockerBin("") }) } // auditHasAction reports whether the audit log contains a row matching // action and a target substring (e.g. "compose_restart" / "rsmon"). func auditHasAction(t *testing.T, srv *Server, action, targetSub string) bool { t.Helper() rows, err := srv.store.RecentAudit(context.Background(), 10) require.NoError(t, err) for _, r := range rows { if r.Action == action && strings.Contains(r.Target, targetSub) { return true } } return false } func TestComposeList_RendersProjects(t *testing.T) { srv := newComposeTestServer(t, &stubRunner{id: "w-1"}) injectComposeSnapshot(t, srv, t.TempDir()) ts := newHTTPTestServer(t, srv) c, _ := loginAsFirstRun(t, ts.URL, srv) resp, err := c.Get(ts.URL + "/compose") require.NoError(t, err) body := mustBody(t, resp) assert.Contains(t, body, "Docker Compose projects") assert.Contains(t, body, "rsmon") assert.Contains(t, body, "details") // link to the detail page } func TestComposeList_DisabledBanner(t *testing.T) { // Default server leaves Compose disabled. srv := newTestServer(t, &stubRunner{id: "w-1"}) ts := newHTTPTestServer(t, srv) c, _ := loginAsFirstRun(t, ts.URL, srv) resp, err := c.Get(ts.URL + "/compose") require.NoError(t, err) body := mustBody(t, resp) assert.Contains(t, body, "Compose management is disabled") } func TestComposeDetail_FoundAndNotFound(t *testing.T) { srv := newComposeTestServer(t, &stubRunner{id: "w-1"}) injectComposeSnapshot(t, srv, t.TempDir()) ts := newHTTPTestServer(t, srv) c, _ := loginAsFirstRun(t, ts.URL, srv) resp, err := c.Get(ts.URL + "/compose/rsmon") require.NoError(t, err) body := mustBody(t, resp) assert.Contains(t, body, "Project actions") assert.Contains(t, body, "rsmon-web-1") resp2, err := c.Get(ts.URL + "/compose/missing") require.NoError(t, err) defer resp2.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusNotFound, resp2.StatusCode) } func TestComposeAPIList_JSON(t *testing.T) { srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret") injectComposeSnapshot(t, srv, t.TempDir()) ts := newHTTPTestServer(t, srv) req, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose", nil) req.SetBasicAuth("alice", "s3cret") resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck require.Equal(t, http.StatusOK, resp.StatusCode) var snap compose.Snapshot require.NoError(t, json.NewDecoder(resp.Body).Decode(&snap)) require.Len(t, snap.Projects, 1) assert.Equal(t, "rsmon", snap.Projects[0].Name) } func TestComposeAPIDetail_JSONAndNotFound(t *testing.T) { srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret") injectComposeSnapshot(t, srv, t.TempDir()) ts := newHTTPTestServer(t, srv) req, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose/rsmon", nil) req.SetBasicAuth("alice", "s3cret") resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck require.Equal(t, http.StatusOK, resp.StatusCode) var p compose.ProjectSummary require.NoError(t, json.NewDecoder(resp.Body).Decode(&p)) assert.Equal(t, "rsmon", p.Name) req2, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose/missing", nil) req2.SetBasicAuth("alice", "s3cret") resp2, err := http.DefaultClient.Do(req2) require.NoError(t, err) defer resp2.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusNotFound, resp2.StatusCode) } func TestComposeProjectAction_RequiresCSRF(t *testing.T) { srv := newComposeTestServer(t, &stubRunner{id: "w-1"}) injectComposeSnapshot(t, srv, t.TempDir()) ts := newHTTPTestServer(t, srv) c, _ := loginAsFirstRun(t, ts.URL, srv) form := url.Values{} form.Set("csrf_token", "") // missing token req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/restart", 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, "POST without CSRF must be 403") } func TestComposeProjectAction_HTMLRedirectAndAudit(t *testing.T) { srv := newComposeTestServer(t, &stubRunner{id: "w-1"}) injectComposeSnapshot(t, srv, t.TempDir()) stubDockerOK(t) ts := newHTTPTestServer(t, srv) c, _ := loginAsFirstRun(t, ts.URL, srv) // Pull the CSRF token out of the detail page's action forms. detail, err := c.Get(ts.URL + "/compose/rsmon") require.NoError(t, err) csrf := extractCSRFToken(t, mustBody(t, detail)) form := url.Values{} form.Set("csrf_token", csrf) req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/restart", 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.StatusSeeOther, resp.StatusCode) loc := resp.Header.Get("Location") assert.True(t, strings.HasPrefix(loc, "/compose/rsmon?ok=true"), "redirect location=%q", loc) assert.True(t, auditHasAction(t, srv, "compose_restart", "compose:rsmon"), "audit row for project restart expected") } func TestComposeProjectAction_APIBasicAuthAndAudit(t *testing.T) { srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret") injectComposeSnapshot(t, srv, t.TempDir()) stubDockerOK(t) ts := newHTTPTestServer(t, srv) req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/compose/rsmon/restart", nil) req.SetBasicAuth("alice", "s3cret") resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck require.Equal(t, http.StatusOK, resp.StatusCode) var res compose.ManagementResult require.NoError(t, json.NewDecoder(resp.Body).Decode(&res)) assert.True(t, res.OK) assert.Contains(t, res.Output, "stub:") assert.True(t, auditHasAction(t, srv, "compose_restart", "compose:rsmon"), "audit row for API restart expected") } func TestComposeProjectAction_FailureReportsNotOK(t *testing.T) { srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret") injectComposeSnapshot(t, srv, t.TempDir()) stubDockerFail(t) ts := newHTTPTestServer(t, srv) req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/compose/rsmon/stop", nil) req.SetBasicAuth("alice", "s3cret") resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck require.Equal(t, http.StatusOK, resp.StatusCode) var res compose.ManagementResult require.NoError(t, json.NewDecoder(resp.Body).Decode(&res)) assert.False(t, res.OK) assert.Contains(t, res.Output, "compose boom") } func TestComposeProjectAction_UnknownActionRejected(t *testing.T) { srv := newComposeTestServer(t, &stubRunner{id: "w-1"}) injectComposeSnapshot(t, srv, t.TempDir()) ts := newHTTPTestServer(t, srv) c, _ := loginAsFirstRun(t, ts.URL, srv) for _, action := range []string{"bogus", "logs"} { // logs is GET-only req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/"+action, strings.NewReader("")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := c.Do(req) require.NoError(t, err) assert.Equal(t, http.StatusNotFound, resp.StatusCode, "action %q should 404", action) resp.Body.Close() //nolint:errcheck } } func TestComposeProjectAction_DisabledReturns503(t *testing.T) { srv := newTestServer(t, &stubRunner{id: "w-1"}) // compose disabled injectComposeSnapshot(t, srv, t.TempDir()) ts := newHTTPTestServer(t, srv) c, _ := loginAsFirstRun(t, ts.URL, srv) req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/restart", strings.NewReader("")) 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.StatusServiceUnavailable, resp.StatusCode) } func TestComposeServiceAction_HTMLRedirectAndAudit(t *testing.T) { srv := newComposeTestServer(t, &stubRunner{id: "w-1"}) injectComposeSnapshot(t, srv, t.TempDir()) stubDockerOK(t) ts := newHTTPTestServer(t, srv) c, _ := loginAsFirstRun(t, ts.URL, srv) detail, err := c.Get(ts.URL + "/compose/rsmon") require.NoError(t, err) csrf := extractCSRFToken(t, mustBody(t, detail)) form := url.Values{} form.Set("csrf_token", csrf) req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/service/web/restart", 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.StatusSeeOther, resp.StatusCode) assert.True(t, strings.HasPrefix(resp.Header.Get("Location"), "/compose/rsmon")) assert.True(t, auditHasAction(t, srv, "compose_restart", "compose:rsmon/web"), "audit row for service restart expected") } func TestComposeServiceAction_UnknownActionRejected(t *testing.T) { srv := newComposeTestServer(t, &stubRunner{id: "w-1"}) injectComposeSnapshot(t, srv, t.TempDir()) ts := newHTTPTestServer(t, srv) c, _ := loginAsFirstRun(t, ts.URL, srv) req, _ := http.NewRequest(http.MethodPost, ts.URL+"/compose/rsmon/service/web/bogus", strings.NewReader("")) 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.StatusNotFound, resp.StatusCode) } func TestComposeLogs_HTMLPage(t *testing.T) { srv := newComposeTestServer(t, &stubRunner{id: "w-1"}) injectComposeSnapshot(t, srv, t.TempDir()) stubDockerOK(t) ts := newHTTPTestServer(t, srv) c, _ := loginAsFirstRun(t, ts.URL, srv) resp, err := c.Get(ts.URL + "/compose/rsmon/logs") require.NoError(t, err) body := mustBody(t, resp) assert.Contains(t, body, "Compose logs: rsmon") assert.Contains(t, body, "stub:") } func TestComposeLogs_API_JSONAndNotFound(t *testing.T) { srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret") injectComposeSnapshot(t, srv, t.TempDir()) stubDockerOK(t) ts := newHTTPTestServer(t, srv) req, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose/rsmon/logs", nil) req.SetBasicAuth("alice", "s3cret") resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck require.Equal(t, http.StatusOK, resp.StatusCode) var res compose.ManagementResult require.NoError(t, json.NewDecoder(resp.Body).Decode(&res)) assert.Contains(t, res.Output, "stub:") req2, _ := http.NewRequest(http.MethodGet, ts.URL+"/web/api/compose/missing/logs", nil) req2.SetBasicAuth("alice", "s3cret") resp2, err := http.DefaultClient.Do(req2) require.NoError(t, err) defer resp2.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusNotFound, resp2.StatusCode) } func TestValidProjectPostAction(t *testing.T) { for _, a := range []string{"up", "down", "stop", "restart", "pull"} { assert.True(t, validProjectPostAction(a), "%q should be POST-able", a) } assert.False(t, validProjectPostAction("logs"), "logs is GET-only") assert.False(t, validProjectPostAction("bogus"), "bogus is invalid") } func TestComposeAPIList_RequiresAuth(t *testing.T) { srv := newComposeTestServerBasicAuth(t, &stubRunner{id: "w-1"}, "alice", "s3cret") injectComposeSnapshot(t, srv, t.TempDir()) ts := newHTTPTestServer(t, srv) // No credentials: the basic-auth fast path returns 401. resp, err := http.Get(ts.URL + "/web/api/compose") require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) }