Files
portainer/api/containerautomation/webhook_test.go
T
agent_coder 492d3d01b0 feat(#19): separate webhook per automation mechanism (update vs heal)
Split the single container-automation webhook URL into two independently
optional URLs — UpdateWebhookURL (fired on update/rollback/update-failed) and
HealWebhookURL (fired on auto-heal restart). The notifier routes each event to
its mechanism's URL by kind; an empty URL silences only that mechanism, so a
user can enable notifications for updates without heal (or vice-versa).

Settings gain both fields (each validated http/https, {{message}} allowed), the
NotificationPanel exposes two labeled inputs, and the golden migration output is
updated. Delivery path (goroutine/recover/timeout, {{message}} GET vs POST,
per-container stack message format) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:47:25 +03:00

438 lines
14 KiB
Go

package containerautomation
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
)
// newTestWebhookNotifier builds an initialized test datastore, sets both the
// update and heal webhook URLs to the same value (so the notifier fires for
// every event kind), and returns a webhookNotifier bound to it. Use
// newTestWebhookNotifierSplit to configure the two URLs independently.
func newTestWebhookNotifier(t *testing.T, webhookURL string) (webhookNotifier, *datastore.Store) {
t.Helper()
return newTestWebhookNotifierSplit(t, webhookURL, webhookURL)
}
// newTestWebhookNotifierSplit builds an initialized test datastore with the
// auto-update and auto-heal webhook URLs set independently, and returns a
// webhookNotifier bound to it.
func newTestWebhookNotifierSplit(t *testing.T, updateURL, healURL string) (webhookNotifier, *datastore.Store) {
t.Helper()
_, store := datastore.MustNewTestStore(t, true, false)
settings, err := store.Settings().Settings()
if err != nil {
t.Fatalf("read settings: %v", err)
}
settings.ContainerAutomation.Notification.UpdateWebhookURL = updateURL
settings.ContainerAutomation.Notification.HealWebhookURL = healURL
if err := store.Settings().UpdateSettings(settings); err != nil {
t.Fatalf("update settings: %v", err)
}
return newWebhookNotifier(store), store
}
func createEndpoint(t *testing.T, store *datastore.Store, id int, name string) {
t.Helper()
if err := store.Endpoint().Create(&portainer.Endpoint{ID: portainer.EndpointID(id), Name: name}); err != nil {
t.Fatalf("create endpoint: %v", err)
}
}
func createStack(t *testing.T, store *datastore.Store, id int, name string) {
t.Helper()
if err := store.Stack().Create(&portainer.Stack{ID: portainer.StackID(id), Name: name}); err != nil {
t.Fatalf("create stack: %v", err)
}
}
// TestWebhookNotifierGETPlaceholder verifies the placeholder is replaced with the
// URL-encoded message and the URL is fetched with GET.
func TestWebhookNotifierGETPlaceholder(t *testing.T) {
reqs := make(chan *http.Request, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqs <- r
}))
defer srv.Close()
n, store := newTestWebhookNotifier(t, srv.URL+"/hook?msg="+webhookMessagePlaceholder)
createEndpoint(t, store, 1, "prod")
n.Notify(Event{Kind: EventHealRestarted, EndpointID: 1, ContainerName: "nginx"})
select {
case r := <-reqs:
if r.Method != http.MethodGet {
t.Errorf("method = %s, want GET", r.Method)
}
got := r.URL.Query().Get("msg")
want := "Environment | prod\nContainer [nginx]\nAuto-heal: restarted unhealthy container"
if got != want {
t.Errorf("decoded msg = %q, want %q", got, want)
}
// The raw query must be URL-encoded: no literal spaces/newlines on the wire.
if strings.ContainsAny(r.URL.RawQuery, " \n") {
t.Errorf("raw query is not URL-encoded: %q", r.URL.RawQuery)
}
case <-time.After(2 * time.Second):
t.Fatal("webhook GET was not received")
}
}
// TestWebhookNotifierPOSTFallback verifies that a URL without the placeholder is
// POSTed with the plain-text message as the body.
func TestWebhookNotifierPOSTFallback(t *testing.T) {
type captured struct {
method string
body string
}
ch := make(chan captured, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
ch <- captured{method: r.Method, body: string(b)}
}))
defer srv.Close()
n, store := newTestWebhookNotifier(t, srv.URL+"/hook")
createEndpoint(t, store, 2, "staging")
n.Notify(Event{Kind: EventHealRestarted, EndpointID: 2, ContainerName: "api"})
select {
case c := <-ch:
if c.method != http.MethodPost {
t.Errorf("method = %s, want POST", c.method)
}
want := "Environment | staging\nContainer [api]\nAuto-heal: restarted unhealthy container"
if c.body != want {
t.Errorf("body = %q, want %q", c.body, want)
}
case <-time.After(2 * time.Second):
t.Fatal("webhook POST was not received")
}
}
// TestWebhookNotifierEmptyURLNoCall verifies no HTTP call is made when the URL is
// empty.
func TestWebhookNotifierEmptyURLNoCall(t *testing.T) {
called := make(chan struct{}, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called <- struct{}{}
}))
defer srv.Close()
n, _ := newTestWebhookNotifier(t, "")
n.Notify(Event{Kind: EventHealRestarted, EndpointID: 1, ContainerName: "x"})
select {
case <-called:
t.Fatal("webhook should not be called when the URL is empty")
case <-time.After(300 * time.Millisecond):
// No call, as expected.
}
}
// waitForRequest returns the first request seen on ch, or fails after a short
// grace period.
func waitForRequest(t *testing.T, ch <-chan *http.Request, what string) *http.Request {
t.Helper()
select {
case r := <-ch:
return r
case <-time.After(2 * time.Second):
t.Fatalf("%s was not received", what)
return nil
}
}
// expectNoRequest asserts nothing arrives on ch within a short grace period.
func expectNoRequest(t *testing.T, ch <-chan *http.Request, what string) {
t.Helper()
select {
case <-ch:
t.Fatalf("%s should not have been called", what)
case <-time.After(300 * time.Millisecond):
// No call, as expected.
}
}
// TestWebhookNotifierUpdateEventRoutesToUpdateURL verifies an update-family event
// dispatches to the auto-update URL only; the heal URL is set but never called.
func TestWebhookNotifierUpdateEventRoutesToUpdateURL(t *testing.T) {
updateReqs := make(chan *http.Request, 1)
updateSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
updateReqs <- r
}))
defer updateSrv.Close()
healReqs := make(chan *http.Request, 1)
healSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
healReqs <- r
}))
defer healSrv.Close()
n, store := newTestWebhookNotifierSplit(t, updateSrv.URL+"/update", healSrv.URL+"/heal")
createEndpoint(t, store, 1, "prod")
for _, kind := range []EventKind{EventUpdated, EventRollback, EventUpdateFailed} {
n.Notify(Event{Kind: kind, EndpointID: 1, ContainerName: "c"})
r := waitForRequest(t, updateReqs, "update webhook for "+string(kind))
if r.URL.Path != "/update" {
t.Errorf("kind %s hit %q, want /update", kind, r.URL.Path)
}
}
expectNoRequest(t, healReqs, "heal webhook")
}
// TestWebhookNotifierHealEventRoutesToHealURL verifies a heal event dispatches to
// the auto-heal URL only; the update URL is set but never called.
func TestWebhookNotifierHealEventRoutesToHealURL(t *testing.T) {
updateReqs := make(chan *http.Request, 1)
updateSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
updateReqs <- r
}))
defer updateSrv.Close()
healReqs := make(chan *http.Request, 1)
healSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
healReqs <- r
}))
defer healSrv.Close()
n, store := newTestWebhookNotifierSplit(t, updateSrv.URL+"/update", healSrv.URL+"/heal")
createEndpoint(t, store, 1, "prod")
n.Notify(Event{Kind: EventHealRestarted, EndpointID: 1, ContainerName: "nginx"})
r := waitForRequest(t, healReqs, "heal webhook")
if r.URL.Path != "/heal" {
t.Errorf("heal event hit %q, want /heal", r.URL.Path)
}
expectNoRequest(t, updateReqs, "update webhook")
}
// TestWebhookNotifierEmptyUpdateURLSkipsUpdateOnly verifies that an empty
// auto-update URL suppresses update-family events while heal still fires.
func TestWebhookNotifierEmptyUpdateURLSkipsUpdateOnly(t *testing.T) {
healReqs := make(chan *http.Request, 1)
healSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
healReqs <- r
}))
defer healSrv.Close()
n, store := newTestWebhookNotifierSplit(t, "", healSrv.URL+"/heal")
createEndpoint(t, store, 1, "prod")
// Update-family event: no URL configured, so nothing is delivered.
n.Notify(Event{Kind: EventUpdated, EndpointID: 1, ContainerName: "c"})
expectNoRequest(t, healReqs, "heal webhook on an update event")
// Heal event: the heal URL is set, so it still fires.
n.Notify(Event{Kind: EventHealRestarted, EndpointID: 1, ContainerName: "nginx"})
waitForRequest(t, healReqs, "heal webhook")
}
// TestWebhookNotifierEmptyHealURLSkipsHealOnly verifies that an empty auto-heal
// URL suppresses heal events while update-family events still fire.
func TestWebhookNotifierEmptyHealURLSkipsHealOnly(t *testing.T) {
updateReqs := make(chan *http.Request, 1)
updateSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
updateReqs <- r
}))
defer updateSrv.Close()
n, store := newTestWebhookNotifierSplit(t, updateSrv.URL+"/update", "")
createEndpoint(t, store, 1, "prod")
// Heal event: no URL configured, so nothing is delivered.
n.Notify(Event{Kind: EventHealRestarted, EndpointID: 1, ContainerName: "nginx"})
expectNoRequest(t, updateReqs, "update webhook on a heal event")
// Update event: the update URL is set, so it still fires.
n.Notify(Event{Kind: EventUpdated, EndpointID: 1, ContainerName: "c"})
waitForRequest(t, updateReqs, "update webhook")
}
// TestWebhookNotifierFailingEndpointDoesNotBlock verifies that a broken endpoint
// neither blocks the caller nor panics.
func TestWebhookNotifierFailingEndpointDoesNotBlock(t *testing.T) {
// Start then immediately close a server so its address refuses connections.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
deadURL := srv.URL
srv.Close()
n, store := newTestWebhookNotifier(t, deadURL+"/hook?msg="+webhookMessagePlaceholder)
createEndpoint(t, store, 1, "prod")
done := make(chan struct{})
go func() {
n.Notify(Event{Kind: EventUpdated, EndpointID: 1, ContainerName: "c"})
close(done)
}()
select {
case <-done:
// Notify returned promptly despite the failing endpoint.
case <-time.After(1 * time.Second):
t.Fatal("Notify blocked on a failing endpoint")
}
// Give the background delivery goroutine time to hit the error path; it must
// log-and-return, never panic.
time.Sleep(200 * time.Millisecond)
}
// TestFormatMessageStandaloneUpdate covers the maintainer's update format for a
// standalone container, with the old->new short digests.
func TestFormatMessageStandaloneUpdate(t *testing.T) {
n, store := newTestWebhookNotifier(t, "unused")
createEndpoint(t, store, 1, "nebula.lc")
settings, _ := store.Settings().Settings()
msg := n.formatMessage(settings, Event{
Kind: EventUpdated, EndpointID: 1, ContainerName: "esphome",
OldDigest: "sha256:59b94983c73aabcd", NewDigest: "sha256:2231ca5d676dabcd",
})
want := "Environment | nebula.lc\nContainer [esphome]\nUpdate [esphome]: 59b94983c73a → 2231ca5d676d"
if msg != want {
t.Errorf("got:\n%q\nwant:\n%q", msg, want)
}
}
// TestFormatMessageStackUpdate covers a stack-scoped update (no per-container
// digests): the context line is the stack name.
func TestFormatMessageStackUpdate(t *testing.T) {
n, store := newTestWebhookNotifier(t, "unused")
createEndpoint(t, store, 1, "nebula.lc")
createStack(t, store, 7, "cache-demo")
settings, _ := store.Settings().Settings()
msg := n.formatMessage(settings, Event{
Kind: EventUpdated, EndpointID: 1, StackID: 7,
})
want := "Environment | nebula.lc\nStack [cache-demo]\nUpdate [cache-demo]: image updated"
if msg != want {
t.Errorf("got:\n%q\nwant:\n%q", msg, want)
}
}
// TestFormatMessageStackMemberUpdate covers the per-container update of a
// stack-member container: the context line is the compose stack name (from
// StackName, no Stack().Read), the action line names the container with its
// old->new digests. This is the maintainer's target output.
func TestFormatMessageStackMemberUpdate(t *testing.T) {
n, store := newTestWebhookNotifier(t, "unused")
createEndpoint(t, store, 1, "nebula.lc")
settings, _ := store.Settings().Settings()
msg := n.formatMessage(settings, Event{
Kind: EventUpdated, EndpointID: 1, StackID: 7, StackName: "cache-demo",
ContainerName: "esphome",
OldDigest: "sha256:59b94983c73aabcd", NewDigest: "sha256:2231ca5d676dabcd",
})
want := "Environment | nebula.lc\nStack [cache-demo]\nUpdate [esphome]: 59b94983c73a → 2231ca5d676d"
if msg != want {
t.Errorf("got:\n%q\nwant:\n%q", msg, want)
}
}
// TestFormatMessageStackMemberUpdateNoNewDigest covers the best-effort fallback:
// when the post-redeploy new image id could not be recovered, the message still
// carries the stack and container and degrades the action line to "image updated"
// rather than blocking delivery.
func TestFormatMessageStackMemberUpdateNoNewDigest(t *testing.T) {
n, store := newTestWebhookNotifier(t, "unused")
createEndpoint(t, store, 1, "nebula.lc")
settings, _ := store.Settings().Settings()
msg := n.formatMessage(settings, Event{
Kind: EventUpdated, EndpointID: 1, StackID: 7, StackName: "cache-demo",
ContainerName: "esphome", OldDigest: "sha256:59b94983c73aabcd",
})
want := "Environment | nebula.lc\nStack [cache-demo]\nUpdate [esphome]: image updated"
if msg != want {
t.Errorf("got:\n%q\nwant:\n%q", msg, want)
}
}
// TestFormatMessageAutoHeal covers the auto-heal message design.
func TestFormatMessageAutoHeal(t *testing.T) {
n, store := newTestWebhookNotifier(t, "unused")
createEndpoint(t, store, 3, "prod")
settings, _ := store.Settings().Settings()
msg := n.formatMessage(settings, Event{
Kind: EventHealRestarted, EndpointID: 3, ContainerName: "nginx",
})
want := "Environment | prod\nContainer [nginx]\nAuto-heal: restarted unhealthy container"
if msg != want {
t.Errorf("got:\n%q\nwant:\n%q", msg, want)
}
}
// TestFormatMessageUnknownEndpoint verifies the "#<id>" fallback when the
// endpoint cannot be resolved.
func TestFormatMessageUnknownEndpoint(t *testing.T) {
n, store := newTestWebhookNotifier(t, "unused")
settings, _ := store.Settings().Settings()
msg := n.formatMessage(settings, Event{
Kind: EventHealRestarted, EndpointID: 99, ContainerName: "ghost",
})
want := "Environment | #99\nContainer [ghost]\nAuto-heal: restarted unhealthy container"
if msg != want {
t.Errorf("got:\n%q\nwant:\n%q", msg, want)
}
}
// TestShortDigest covers digest short-forming.
func TestShortDigest(t *testing.T) {
cases := map[string]string{
"sha256:59b94983c73a1122334455": "59b94983c73a",
"59b94983c73a1122334455": "59b94983c73a",
"short": "short",
"": "",
}
for in, want := range cases {
if got := shortDigest(in); got != want {
t.Errorf("shortDigest(%q) = %q, want %q", in, got, want)
}
}
}