Files
portainer/api/http/handler/webhooks/webhook_container_automation_test.go
T
claude code agent b6ba9b46c7 feat(automation): inbound registry-push webhook for immediate auto-update (#32)
Adds POST /api/webhooks/container-automation/{token} which kicks a full
auto-update pass immediately, instead of waiting up to PollInterval (6h) for the
poll. Registry-agnostic 'general kick': the payload is not parsed; the pass
compares digests and updates only stale containers. Polling stays as a fallback.

- Service.TriggerUpdate(): a buffer-1 non-blocking kick channel + a worker
  goroutine on baseCtx that debounces ~10s (collapsing a multi-arch/multi-tag
  push burst into one pass). update() is split into runUpdatePass() bool, which
  returns false when the updateRunning CAS is not acquired; the worker backs off
  and re-runs so a kick landing during a scheduled pass is never lost. The poll
  timer keeps calling the same pass.
- Worker panics are recovered (poll parity — the poll path runs under the
  scheduler's cron.Recover), so a panic in the pass cannot crash the daemon from
  this bare goroutine. runUpdatePass also re-checks AutoUpdate.Enabled as
  defense-in-depth against a disable during the debounce window.
- Route via bouncer.PublicAccess (distinct from /webhooks/{token} by segment
  count); token compared with subtle.ConstantTimeCompare; empty/mismatched token
  -> 404, disabled auto-update -> 409, success -> 202. Token is server-generated
  (uuid) via RegenerateWebhookToken / ClearWebhookToken settings actions; never
  accepted from the client; excluded from /settings/public (present in admin GET
  for the UI).
- Frontend: an AutoUpdatePanel 'Update trigger webhook' section (readonly URL,
  copy, Generate/Regenerate, Clear, registry-setup hint).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 06:05:21 +03:00

105 lines
3.4 KiB
Go

package webhooks
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/portainer/portainer/api/datastore"
"github.com/gorilla/mux"
"github.com/stretchr/testify/require"
)
// fakeTrigger records how many times the inbound webhook asked for an update pass.
type fakeTrigger struct{ calls int }
func (f *fakeTrigger) TriggerUpdate() { f.calls++ }
// setupWebhookAutomationHandler builds a webhooks.Handler backed by a test store
// whose auto-update settings carry the given token and enabled flag, plus a fake
// trigger to observe the kick.
func setupWebhookAutomationHandler(t *testing.T, token string, enabled bool) (*Handler, *fakeTrigger) {
t.Helper()
_, store := datastore.MustNewTestStore(t, true, false)
settings, err := store.Settings().Settings()
require.NoError(t, err)
settings.ContainerAutomation.AutoUpdate.WebhookToken = token
settings.ContainerAutomation.AutoUpdate.Enabled = enabled
require.NoError(t, store.Settings().UpdateSettings(settings))
trigger := &fakeTrigger{}
handler := &Handler{DataStore: store, ContainerAutomationService: trigger}
return handler, trigger
}
// callWebhook invokes the handler with the given request-path token wired as the
// mux route variable, mirroring the router configuration.
func callWebhook(handler *Handler, requestToken string) (*httptest.ResponseRecorder, int) {
req := httptest.NewRequest(http.MethodPost, "/webhooks/container-automation/"+requestToken, nil)
req = mux.SetURLVars(req, map[string]string{"token": requestToken})
rr := httptest.NewRecorder()
herr := handler.webhookContainerAutomation(rr, req)
if herr != nil {
return rr, herr.StatusCode
}
return rr, rr.Code
}
func TestWebhookContainerAutomation(t *testing.T) {
const token = "11111111-2222-3333-4444-555555555555"
t.Run("valid token on enabled auto-update triggers a pass and returns 202", func(t *testing.T) {
handler, trigger := setupWebhookAutomationHandler(t, token, true)
rr, status := callWebhook(handler, token)
require.Equal(t, http.StatusAccepted, status)
require.Equal(t, 1, trigger.calls, "a valid kick must trigger exactly one pass")
require.Empty(t, rr.Body.String(), "202 response has no body")
})
t.Run("wrong token returns 404 and does not trigger", func(t *testing.T) {
handler, trigger := setupWebhookAutomationHandler(t, token, true)
_, status := callWebhook(handler, "wrong-token")
require.Equal(t, http.StatusNotFound, status)
require.Zero(t, trigger.calls)
})
t.Run("empty request token returns 404", func(t *testing.T) {
handler, trigger := setupWebhookAutomationHandler(t, token, true)
_, status := callWebhook(handler, "")
require.Equal(t, http.StatusNotFound, status)
require.Zero(t, trigger.calls)
})
t.Run("empty configured token cannot be matched (endpoint disabled)", func(t *testing.T) {
// No token configured: even an empty request token must not match, or anyone
// could trigger a pass.
handler, trigger := setupWebhookAutomationHandler(t, "", true)
_, status := callWebhook(handler, "")
require.Equal(t, http.StatusNotFound, status)
require.Zero(t, trigger.calls)
})
t.Run("valid token but auto-update disabled returns 409", func(t *testing.T) {
handler, trigger := setupWebhookAutomationHandler(t, token, false)
_, status := callWebhook(handler, token)
require.Equal(t, http.StatusConflict, status)
require.Zero(t, trigger.calls, "a disabled endpoint must not trigger a pass")
})
}