Files
worker/internal/webapp/handlers_updates_test.go
Gleb Tv e987f24903
Все проверки выполнены успешно
CI / test (push) Successful in 2m32s
Docker / Build and publish worker image (push) Successful in 18m17s
fix(worker): harden control-plane lifecycle
- 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.
2026-07-19 23:11:43 +03:00

281 строка
9.6 KiB
Go

package webapp
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// stubReleaseServer returns a httptest.Server whose handler serves
// the supplied tag_name as a GitHub-style JSON body. The close func
// is returned alongside so callers can defer shutdown.
func stubReleaseServer(t *testing.T, tagName string, status int) (*httptest.Server, func()) {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "application/json", r.Header.Get("Accept"))
if status != http.StatusOK {
w.WriteHeader(status)
return
}
w.Header().Set("Content-Type", "application/json")
body, _ := json.Marshal(map[string]string{"tag_name": tagName})
_, _ = w.Write(body)
}))
return ts, ts.Close
}
// TestReleasePoller_NoURLReturnsPlaceholder pins the
// no-URL-is-configured fallback. The handler must not hit the
// network when Config.ReleaseURL is empty.
func TestReleasePoller_NoURLReturnsPlaceholder(t *testing.T) {
p := &releasePoller{}
got := p.latest(context.Background(), nil)
assert.Equal(t, placeholderLatestVersion, got)
}
// TestReleasePoller_SuccessCaches verifies the happy path: the
// first call hits the URL, subsequent calls within the TTL come
// from the cache.
func TestReleasePoller_SuccessCaches(t *testing.T) {
ts, cleanup := stubReleaseServer(t, "v2.7.1", http.StatusOK)
defer cleanup()
p := &releasePoller{}
p.setURL(ts.URL)
got := p.latest(context.Background(), ts.Client())
assert.Equal(t, "v2.7.1", got)
// Second call: cache hit. Replace the upstream with one that
// would error; the cached value must still come back.
p.url = "http://127.0.0.1:1/never-reachable"
got = p.latest(context.Background(), ts.Client())
assert.Equal(t, "v2.7.1", got, "cached value must survive upstream failures inside the TTL window")
}
// TestReleasePoller_Non2xxReturnsPlaceholder ensures a 5xx upstream
// does not poison the cache (placeholder shown, cache untouched).
func TestReleasePoller_Non2xxReturnsPlaceholder(t *testing.T) {
ts, cleanup := stubReleaseServer(t, "ignored", http.StatusInternalServerError)
defer cleanup()
p := &releasePoller{}
p.setURL(ts.URL)
got := p.latest(context.Background(), ts.Client())
assert.Equal(t, placeholderLatestVersion, got)
// Confirm cache was not touched: a fresh request to a working
// upstream must produce the placeholder if the broken one was
// recorded. We use a different working upstream here.
ts2, cleanup2 := stubReleaseServer(t, "v9.9.9", http.StatusOK)
defer cleanup2()
p.setURL(ts2.URL)
got = p.latest(context.Background(), ts2.Client())
assert.Equal(t, "v9.9.9", got)
}
// TestReleasePoller_BadJSONReturnsPlaceholder verifies that a 200
// with a missing tag_name falls back to the placeholder.
func TestReleasePoller_BadJSONReturnsPlaceholder(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"name": "no tag here"}`))
}))
defer ts.Close()
p := &releasePoller{}
p.setURL(ts.URL)
got := p.latest(context.Background(), ts.Client())
assert.Equal(t, placeholderLatestVersion, got)
}
// TestReleasePoller_TimeoutReturnsPlaceholder confirms a slow
// upstream degrades to the placeholder without exceeding the
// per-call timeout budget.
func TestReleasePoller_TimeoutReturnsPlaceholder(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(2 * defaultReleasePollTimeout)
_, _ = io.WriteString(w, `{"tag_name":"too-late"}`)
}))
defer ts.Close()
p := &releasePoller{}
p.setURL(ts.URL)
start := time.Now()
got := p.latest(context.Background(), &http.Client{Timeout: defaultReleasePollTimeout})
elapsed := time.Since(start)
assert.Equal(t, placeholderLatestVersion, got)
assert.Less(t, elapsed, 2*defaultReleasePollTimeout,
"timeout must fire before the slow upstream replies")
}
// TestReleasePoller_TTLExpiry verifies that after releaseCacheTTL
// the poller re-fetches the URL. We can't wait an hour in a unit
// test, so we reset the cachedAt directly to a stale time and
// confirm the next call refetches.
func TestReleasePoller_TTLExpiry(t *testing.T) {
ts, cleanup := stubReleaseServer(t, "v3.0.0", http.StatusOK)
defer cleanup()
p := &releasePoller{}
p.setURL(ts.URL)
// Prime the cache.
got := p.latest(context.Background(), ts.Client())
require.Equal(t, "v3.0.0", got)
// Force the cachedAt into the past.
p.mu.Lock()
p.cachedAt = time.Now().Add(-2 * releaseCacheTTL)
p.mu.Unlock()
// Change the upstream to a new tag — must be observed.
ts2, cleanup2 := stubReleaseServer(t, "v3.0.1", http.StatusOK)
defer cleanup2()
p.setURL(ts2.URL)
got = p.latest(context.Background(), ts2.Client())
assert.Equal(t, "v3.0.1", got, "stale cache must not block a fresh fetch after setURL reset")
}
// TestUpdatesPage_ShowsPlaceholder verifies that without
// Config.ReleaseURL the page renders the placeholder string in
// the "Latest known" cell.
func TestUpdatesPage_ShowsPlaceholder(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
require.Empty(t, srv.cfg.ReleaseURL, "test fixture must not pre-set ReleaseURL")
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
clearRequiresChange(t, srv)
resp, err := c.Get(ts.URL + "/updates")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
page := string(body)
assert.Contains(t, page, placeholderLatestVersion,
"placeholder version must appear when no release URL is configured")
assert.Contains(t, page, "WORKER_RELEASE_URL",
"placeholder copy must mention the env var that turns on real polls")
}
// TestUpdatesPage_RunsPollWithReleaseURL verifies that with
// Config.ReleaseURL set, the page renders the tag_name from the
// upstream release server.
func TestUpdatesPage_RunsPollWithReleaseURL(t *testing.T) {
ts, cleanup := stubReleaseServer(t, "v9.9.9", http.StatusOK)
defer cleanup()
srv := newTestServer(t, &stubRunner{id: "w-1"})
srv.cfg.ReleaseURL = ts.URL
srv.releasePoller.setURL(ts.URL)
hts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, hts.URL, srv)
clearRequiresChange(t, srv)
resp, err := c.Get(hts.URL + "/updates")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "v9.9.9",
"page must surface the polled tag_name when WORKER_RELEASE_URL is configured")
}
// TestChecksPage_RunNowDisabledButton verifies that the "Run now"
// button is rendered and disabled, with the tooltip explaining the
// gating.
func TestChecksPage_RunNowDisabledButton(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 + "/checks")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
page := string(body)
assert.Contains(t, page, "Run now",
"the Run now button must appear on the checks page")
assert.Contains(t, page, "disabled",
"the Run now button must be disabled in Phase 1")
assert.Contains(t, page, "worker-notifier-mvp",
"tooltip must cite the worker-notifier MVP plan that owns the hint protocol")
}
// TestNotificationsPage_ResendDisabledButton mirrors the checks
// page test for the resend button on /notifications.
func TestNotificationsPage_ResendDisabledButton(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1", notifs: []NotificationRow{
{Kind: "email", Channel: "smtp", Subject: "selfcheck", Body: "main API down", OK: true, At: time.Now()},
{JobID: "delegated-job", Method: "sms", Status: "permanent", DurationMs: 12, At: time.Now()},
}})
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
clearRequiresChange(t, srv)
resp, err := c.Get(ts.URL + "/notifications")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
page := string(body)
assert.Contains(t, page, "Resend")
assert.Contains(t, page, "disabled")
assert.Contains(t, page, "worker-notifier-mvp")
assert.Contains(t, page, "selfcheck")
assert.Contains(t, page, "delegated-job")
assert.Contains(t, page, "sms")
assert.Contains(t, page, "permanent")
assert.Contains(t, page, "12 ms")
}
// TestAppsPage_ReferencesInventoryPlan ensures the copy on
// /apps points operators at the deploymentd-driven inventory
// surface that this PR's plan docs describe.
func TestAppsPage_ReferencesInventoryPlan(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 + "/apps")
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
require.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
page := string(body)
assert.Contains(t, page, "deploymentd",
"apps page must mention deploymentd so operators know where Docker Compose discoveries land")
assert.Contains(t, page, "inventory-management.md",
"apps page must cite the inventory-management plan doc")
}
// _ = url.Values and strings.Builder keep imports used if the file
// shrinks in future refactors; they document the surface without
// affecting compilation.
var (
_ = url.Values{}
_ = strings.Builder{}
)