Все проверки выполнены успешно
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.
281 строка
9.6 KiB
Go
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{}
|
|
)
|