package webhook import ( "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/shcizo/package-updater/internal/master/events" "github.com/shcizo/package-updater/internal/master/jobs" "github.com/shcizo/package-updater/internal/master/model" "github.com/shcizo/package-updater/internal/protocol" ) const token = "s3cret" type availCall struct { id int64 a model.Available } type fakeStore struct { mu sync.Mutex services []model.Service // returned for any repo that equals their ImageRepo jobs map[int64]model.Job avail []availCall repos []string } func (f *fakeStore) ServicesByRepo(_ context.Context, repo string) ([]model.Service, error) { f.mu.Lock() defer f.mu.Unlock() f.repos = append(f.repos, repo) var out []model.Service for _, s := range f.services { if s.ImageRepo == repo { out = append(out, s) } } return out, nil } func (f *fakeStore) SetAvailable(_ context.Context, id int64, a model.Available) error { f.mu.Lock() defer f.mu.Unlock() f.avail = append(f.avail, availCall{id, a}) return nil } func (f *fakeStore) GetJob(_ context.Context, id int64) (model.Job, error) { f.mu.Lock() defer f.mu.Unlock() return f.jobs[id], nil } func (f *fakeStore) setJob(j model.Job) { f.mu.Lock() defer f.mu.Unlock() f.jobs[j.ID] = j } type startCall struct { svc model.Service req jobs.StartRequest ctx context.Context } // fakeJobs hands out job IDs and applies per-service behaviour. type fakeJobs struct { mu sync.Mutex store *fakeStore calls []startCall errFor map[int64]error // returned together with errJob[id] if set jobFor map[int64]model.Job // status of the job returned by Start next int64 } func (f *fakeJobs) Start(ctx context.Context, svc model.Service, req jobs.StartRequest) (model.Job, error) { f.mu.Lock() defer f.mu.Unlock() f.calls = append(f.calls, startCall{svc, req, ctx}) if err, ok := f.errFor[svc.ID]; ok { return f.jobFor[svc.ID], err } f.next++ j := model.Job{ID: f.next, ServiceID: svc.ID, Status: model.JobQueued, Trigger: req.Trigger, ToRef: req.Ref} if o, ok := f.jobFor[svc.ID]; ok { o.ID = j.ID j = o } f.store.mu.Lock() f.store.jobs[j.ID] = j f.store.mu.Unlock() return j, nil } func (f *fakeJobs) startCalls() []startCall { f.mu.Lock() defer f.mu.Unlock() return append([]startCall(nil), f.calls...) } type env struct { store *fakeStore jobs *fakeJobs broker *events.Broker h *Handler } func newEnv(t *testing.T, services ...model.Service) *env { t.Helper() st := &fakeStore{services: services, jobs: map[int64]model.Job{}} fj := &fakeJobs{store: st, errFor: map[int64]error{}, jobFor: map[int64]model.Job{}} b := events.NewBroker() h := New(st, fj, b, Config{Token: token, Timeout: 2 * time.Second, PublicURL: "https://updater.example.com"}, slog.New(slog.NewTextHandler(io.Discard, nil))) h.now = func() time.Time { return time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC) } return &env{store: st, jobs: fj, broker: b, h: h} } func (e *env) post(body string, headers ...string) *httptest.ResponseRecorder { return e.postCtx(context.Background(), body, headers...) } func (e *env) postCtx(ctx context.Context, body string, headers ...string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(body)).WithContext(ctx) req.Header.Set("Authorization", "Bearer "+token) for i := 0; i+1 < len(headers); i += 2 { req.Header.Set(headers[i], headers[i+1]) } rec := httptest.NewRecorder() e.h.ServeHTTP(rec, req) return rec } func decode(t *testing.T, rec *httptest.ResponseRecorder) response { t.Helper() var r response require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &r), rec.Body.String()) return r } func compose(id int64, mode, actualRef string) model.Service { return model.Service{ID: id, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web" + string(rune('a'+id)), ImageRepo: "gitea.shcizo.se/shcizo/slusk", ActualRef: actualRef, UpdateMode: mode} } const slusk = "gitea.shcizo.se/shcizo/slusk" func TestAuth(t *testing.T) { tests := []struct { name string serverKey string header string wantStatus int wantBody string }{ {"missing header", token, "", 401, `{"error":"unauthorized"}`}, {"wrong scheme", token, "Basic " + token, 401, `{"error":"unauthorized"}`}, {"wrong token", token, "Bearer nope", 401, `{"error":"unauthorized"}`}, {"empty bearer", token, "Bearer ", 401, `{"error":"unauthorized"}`}, {"key unset", "", "Bearer " + token, 503, `{"error":"webhook disabled: UPDATER_API_KEY not set"}`}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { e := newEnv(t, compose(1, model.ModeManual, slusk+":1")) e.h.cfg.Token = tt.serverKey req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(`{"image":"`+slusk+`"}`)) if tt.header != "" { req.Header.Set("Authorization", tt.header) } rec := httptest.NewRecorder() e.h.ServeHTTP(rec, req) assert.Equal(t, tt.wantStatus, rec.Code) assert.JSONEq(t, tt.wantBody, rec.Body.String()) assert.Empty(t, e.store.repos, "nothing may be looked up before auth") }) } } func TestBadRequest(t *testing.T) { tests := []struct { name, body, wantError string }{ {"invalid JSON", `{`, "invalid JSON body"}, {"empty body", ``, "invalid JSON body"}, {"empty image", `{"image":""}`, "image is required"}, {"missing image", `{"tag":"v1"}`, "image is required"}, {"oversized", `{"image":"` + strings.Repeat("a", 70<<10) + `"}`, "invalid JSON body"}, {"tag with digest", `{"image":"` + slusk + `","tag":"v1@sha256:abc"}`, "invalid tag"}, {"tag with space", `{"image":"` + slusk + `","tag":"v 1"}`, "invalid tag"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { e := newEnv(t, compose(1, model.ModeAuto, slusk+":1")) rec := e.post(tt.body) assert.Equal(t, 400, rec.Code) assert.JSONEq(t, `{"error":"`+tt.wantError+`"}`, rec.Body.String()) assert.Empty(t, e.jobs.startCalls()) }) } } func TestNoMatch(t *testing.T) { e := newEnv(t, compose(1, model.ModeAuto, slusk+":1")) for _, image := range []string{"gitea.shcizo.se/shcizo/other", "gitea.shcizo.se/shcizo/Slusk"} { rec := e.post(`{"image":"` + image + `"}`) assert.Equal(t, 404, rec.Code, image) assert.Contains(t, rec.Body.String(), "no service uses image "+image) } assert.Empty(t, e.jobs.startCalls()) assert.Empty(t, e.store.avail) } func TestImageIsNormalisedBeforeMatching(t *testing.T) { svc := compose(1, model.ModeManual, slusk+":1") local := compose(2, model.ModeManual, "localhost:5000/foo:v1") local.ImageRepo = "localhost:5000/foo" for _, tt := range []struct{ image, wantRepo string }{ {slusk, slusk}, {slusk + ":2.0", slusk}, {slusk + "@sha256:abc", slusk}, {slusk + ":2.0@sha256:abc", slusk}, {"localhost:5000/foo:v1", "localhost:5000/foo"}, } { e := newEnv(t, svc, local) rec := e.post(`{"image":"` + tt.image + `"}`) assert.Equal(t, 200, rec.Code, tt.image) assert.Equal(t, []string{tt.wantRepo}, e.store.repos, tt.image) assert.Equal(t, 1, decode(t, rec).Matched, tt.image) } } func TestManualServiceIsMarkedNotUpdated(t *testing.T) { e := newEnv(t, compose(1, model.ModeManual, slusk+":1.4.2@sha256:old")) sub, cancel := e.broker.Subscribe() defer cancel() rec := e.post(`{"image":"` + slusk + `:ignored-in-ref","tag":"1.5.0"}`) assert.Equal(t, 200, rec.Code) assert.Empty(t, e.jobs.startCalls()) require.Len(t, e.store.avail, 1) assert.Equal(t, int64(1), e.store.avail[0].id) assert.Equal(t, model.Available{Ref: slusk + ":1.5.0", Source: model.SourceWebhook, At: e.h.now()}, e.store.avail[0].a) resp := decode(t, rec) assert.Equal(t, 1, resp.Matched) require.Len(t, resp.Results, 1) r := resp.Results[0] assert.Equal(t, statusMarked, r.Status) assert.Equal(t, "slusk/webb", r.Service) assert.Equal(t, "compose", r.Kind) assert.Equal(t, "manual", r.Mode) assert.Equal(t, slusk+":1.5.0", r.ToRef) assert.Zero(t, r.JobID) assert.NotEmpty(t, resp.RequestID) assert.Equal(t, resp.RequestID, rec.Header().Get("X-Request-ID")) assert.Equal(t, events.Services, <-sub) } func TestRequestIDIsEchoed(t *testing.T) { e := newEnv(t, compose(1, model.ModeManual, slusk+":1")) rec := e.post(`{"image":"`+slusk+`"}`, "X-Request-ID", "ci-123") assert.Equal(t, "ci-123", decode(t, rec).RequestID) assert.Equal(t, "ci-123", rec.Header().Get("X-Request-ID")) } func TestAvailableRefWithoutTag(t *testing.T) { tests := []struct { name string actualRef string wantRef string // "" means SetAvailable must not be called }{ {"followed tag", slusk + ":1.4.2@sha256:old", slusk + ":1.4.2"}, {"bare image follows latest", slusk, slusk + ":latest"}, {"digest only", slusk + "@sha256:old", ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { e := newEnv(t, compose(1, model.ModeManual, tt.actualRef)) rec := e.post(`{"image":"` + slusk + `"}`) assert.Equal(t, 200, rec.Code) r := decode(t, rec).Results[0] if tt.wantRef == "" { assert.Empty(t, e.store.avail) assert.Equal(t, statusRefused, r.Status) assert.Equal(t, "no tag given and the service follows no tag", r.Error) return } assert.Equal(t, statusMarked, r.Status) require.Len(t, e.store.avail, 1) assert.Equal(t, tt.wantRef, e.store.avail[0].a.Ref) }) } } func TestAutoDigestOnlyServiceWithoutTagIsRefused(t *testing.T) { svc := compose(1, model.ModeAuto, slusk+"@sha256:old") e := newEnv(t, svc) rec := e.post(`{"image":"` + slusk + `"}`) assert.Equal(t, 200, rec.Code) assert.Empty(t, e.store.avail) assert.Empty(t, e.jobs.startCalls(), "no target ref: nothing to start") r := decode(t, rec).Results[0] assert.Equal(t, statusRefused, r.Status) assert.Equal(t, "no tag given and the service follows no tag", r.Error) } func TestValidTags(t *testing.T) { for _, tag := range []string{"latest", "1.2.3-rc.1", "v1_2", strings.Repeat("a", 128)} { e := newEnv(t, compose(1, model.ModeManual, slusk+":1")) rec := e.post(`{"image":"` + slusk + `","tag":"` + tag + `"}`) assert.Equal(t, 200, rec.Code, tag) require.Len(t, e.store.avail, 1, tag) assert.Equal(t, slusk+":"+tag, e.store.avail[0].a.Ref) } e := newEnv(t, compose(1, model.ModeManual, slusk+":1")) rec := e.post(`{"image":"` + slusk + `","tag":"` + strings.Repeat("a", 129) + `"}`) assert.Equal(t, 400, rec.Code) } func TestUnsafeRequestIDIsReplaced(t *testing.T) { for _, id := range []string{"has space", "bad/slash", strings.Repeat("a", 129)} { e := newEnv(t, compose(1, model.ModeManual, slusk+":1")) rec := e.post(`{"image":"`+slusk+`"}`, "X-Request-ID", id) got := rec.Header().Get("X-Request-ID") assert.NotEqual(t, id, got) assert.Regexp(t, `^[0-9a-f]{32}$`, got) } e := newEnv(t, compose(1, model.ModeManual, slusk+":1")) rec := e.post(`{"image":"`+slusk+`"}`, "X-Request-ID", strings.Repeat("a", 128)) assert.Equal(t, strings.Repeat("a", 128), rec.Header().Get("X-Request-ID")) } func TestJobFinishedJustBeforeTimeoutIsNotReportedAsTimeout(t *testing.T) { e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2")) e.h.cfg.Timeout = 200 * time.Millisecond e.h.pollEvery = time.Hour // only the final read after the deadline can see the result go func() { assert.Eventually(t, func() bool { return len(e.jobs.startCalls()) == 1 }, time.Second, time.Millisecond) e.store.setJob(model.Job{ID: 1, Status: model.JobSucceeded}) // no Publish }() rec := e.post(`{"image":"` + slusk + `"}`) assert.Equal(t, 200, rec.Code) assert.Equal(t, statusSucceeded, decode(t, rec).Results[0].Status) } func TestAutoJobTargetIsBuiltFromServiceRepo(t *testing.T) { e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2@sha256:old")) e.jobs.jobFor[1] = model.Job{Status: model.JobSucceeded} rec := e.post(`{"image":"` + slusk + `:other","tag":"2.0.0"}`) assert.Equal(t, 200, rec.Code) calls := e.jobs.startCalls() require.Len(t, calls, 1) assert.Equal(t, slusk+":2.0.0", calls[0].req.Ref) assert.Equal(t, model.TriggerWebhook, calls[0].req.Trigger) assert.Empty(t, calls[0].req.RequestedBy) require.Len(t, e.store.avail, 1) assert.Equal(t, slusk+":2.0.0", e.store.avail[0].a.Ref) } func TestAutoJobSucceeded(t *testing.T) { e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2")) go func() { assert.Eventually(t, func() bool { return len(e.jobs.startCalls()) == 1 }, time.Second, time.Millisecond) e.store.setJob(model.Job{ID: 1, Status: model.JobSucceeded}) e.broker.Publish(events.JobTopic(1)) // progress topic is ignored e.broker.Publish(events.Jobs) }() rec := e.post(`{"image":"` + slusk + `","tag":"1.5.0"}`) assert.Equal(t, 200, rec.Code) r := decode(t, rec).Results[0] assert.Equal(t, statusSucceeded, r.Status) assert.Equal(t, int64(1), r.JobID) assert.Equal(t, "https://updater.example.com/jobs/1", r.JobURL) assert.Equal(t, "auto", r.Mode) } func TestAutoJobFailedIs500(t *testing.T) { e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2")) go func() { assert.Eventually(t, func() bool { return len(e.jobs.startCalls()) == 1 }, time.Second, time.Millisecond) e.store.setJob(model.Job{ID: 1, Status: model.JobFailed, Error: "pull access denied"}) e.broker.Publish(events.Jobs) }() rec := e.post(`{"image":"` + slusk + `"}`) assert.Equal(t, 500, rec.Code) r := decode(t, rec).Results[0] assert.Equal(t, statusFailed, r.Status) assert.Equal(t, "pull access denied", r.Error) } func TestJobFailedAtStartIsNotWaitedFor(t *testing.T) { e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2")) e.jobs.jobFor[1] = model.Job{Status: model.JobFailed, Error: "agent offline"} e.h.cfg.Timeout = time.Minute begin := time.Now() rec := e.post(`{"image":"` + slusk + `"}`) assert.Less(t, time.Since(begin), 5*time.Second) assert.Equal(t, 500, rec.Code) r := decode(t, rec).Results[0] assert.Equal(t, statusFailed, r.Status) assert.Equal(t, "agent offline", r.Error) assert.NotZero(t, r.JobID) } func TestTimeout(t *testing.T) { e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2")) e.h.cfg.Timeout = 50 * time.Millisecond rec := e.post(`{"image":"` + slusk + `"}`) assert.Equal(t, 500, rec.Code) r := decode(t, rec).Results[0] assert.Equal(t, statusTimeout, r.Status) assert.Equal(t, int64(1), r.JobID) assert.Equal(t, model.JobQueued, e.store.jobs[1].Status, "the master must not touch a timed-out job") } func TestDroppedSignalIsRecoveredByPolling(t *testing.T) { e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2")) e.h.pollEvery = 5 * time.Millisecond go func() { assert.Eventually(t, func() bool { return len(e.jobs.startCalls()) == 1 }, time.Second, time.Millisecond) e.store.setJob(model.Job{ID: 1, Status: model.JobSucceeded}) // no Publish }() rec := e.post(`{"image":"` + slusk + `"}`) assert.Equal(t, 200, rec.Code) assert.Equal(t, statusSucceeded, decode(t, rec).Results[0].Status) } func TestBusyIsNotAFailure(t *testing.T) { e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2")) e.jobs.errFor[1] = model.ErrJobActive e.jobs.jobFor[1] = model.Job{ID: 42, Status: model.JobRunning} e.h.cfg.Timeout = time.Minute rec := e.post(`{"image":"` + slusk + `"}`) assert.Equal(t, 200, rec.Code) r := decode(t, rec).Results[0] assert.Equal(t, statusBusy, r.Status) assert.Equal(t, int64(42), r.JobID) assert.Equal(t, "https://updater.example.com/jobs/42", r.JobURL) assert.NotEmpty(t, e.store.avail, "the available ref is still recorded") } func TestRefusedIsNotAFailure(t *testing.T) { e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2")) e.jobs.errFor[1] = &model.NotUpdatableError{Reason: model.ReasonSelf} rec := e.post(`{"image":"` + slusk + `","tag":"2"}`) assert.Equal(t, 200, rec.Code) r := decode(t, rec).Results[0] assert.Equal(t, statusRefused, r.Status) assert.Equal(t, model.ReasonSelf, r.Error) assert.Zero(t, r.JobID) assert.NotEmpty(t, e.store.avail) } func TestMixedAutoAndManual(t *testing.T) { e := newEnv(t, compose(1, model.ModeManual, slusk+":1"), compose(2, model.ModeAuto, slusk+":1"), compose(3, model.ModeAuto, slusk+":1")) e.jobs.jobFor[2] = model.Job{Status: model.JobSucceeded} e.jobs.jobFor[3] = model.Job{Status: model.JobFailed, Error: "boom"} rec := e.post(`{"image":"` + slusk + `","tag":"2"}`) assert.Equal(t, 500, rec.Code) resp := decode(t, rec) assert.Equal(t, 3, resp.Matched) got := []string{resp.Results[0].Status, resp.Results[1].Status, resp.Results[2].Status} assert.Equal(t, []string{statusMarked, statusSucceeded, statusFailed}, got) assert.Len(t, e.store.avail, 3) } func TestClientCancelStopsWaitingButNotStarting(t *testing.T) { e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2")) e.h.cfg.Timeout = time.Minute ctx, cancel := context.WithCancel(context.Background()) cancel() // the client is already gone when the handler runs begin := time.Now() rec := e.postCtx(ctx, `{"image":"`+slusk+`"}`) assert.Less(t, time.Since(begin), 5*time.Second) assert.Equal(t, statusTimeout, decode(t, rec).Results[0].Status) calls := e.jobs.startCalls() require.Len(t, calls, 1, "the job must still be started") assert.NoError(t, calls[0].ctx.Err(), "Start must run on a context the client cannot cancel") }