package web import ( "context" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "strconv" "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/model" "github.com/shcizo/package-updater/internal/protocol" ) const ( driftDesired = "sha256:1111111111111111111111111111111111111111111111111111111111111111" driftActual = "sha256:2222222222222222222222222222222222222222222222222222222222222222" ) type acceptCall struct { id int64 actualRef, actualDigest, desiredRef string } func (f *fakeStore) AcceptRunning(_ context.Context, id int64, actualRef, actualDigest, desired string) error { f.mu.Lock() defer f.mu.Unlock() f.acceptCalls = append(f.acceptCalls, acceptCall{id, actualRef, actualDigest, desired}) return f.acceptErr } func (f *fakeStore) MoveCandidates(context.Context) ([]model.Move, error) { return f.moves, nil } func (f *fakeStore) MergeSources(context.Context, int64) ([]model.Service, error) { return f.sources, nil } func (f *fakeStore) MergeService(_ context.Context, from, to int64) error { f.mu.Lock() defer f.mu.Unlock() f.merged = append(f.merged, [2]int64{from, to}) return f.mergeErr } func (f *fakeJobs) Restorable(model.Service) error { return f.restorable } func (f *fakeJobs) RestoreDesired(_ context.Context, serviceID int64, by string) (model.Job, error) { f.mu.Lock() defer f.mu.Unlock() f.requestedBy = by f.restored = append(f.restored, serviceID) return f.job, f.err } // driftService makes the first fixture service a drifted one: updated to // 1.4.2 at driftDesired, now running 1.4.2 at driftActual. func driftService(store *fakeStore) { store.services[0].DesiredRef = "gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftDesired store.services[0].ActualDigest = driftActual } func postTo(t *testing.T, srv *httptest.Server, path string, form url.Values) (*http.Response, string) { t.Helper() form.Set("csrf_token", "csrf") resp, err := noRedirectClient().PostForm(srv.URL+path, form) require.NoError(t, err) defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) return resp, string(body) } func TestRestoreDesired(t *testing.T) { tests := []struct { name string job model.Job err error path string wantCode int wantLocation string wantBody string }{ {name: "started", job: model.Job{ID: 42}, path: "/services/1/restore", wantCode: http.StatusSeeOther, wantLocation: "/jobs/42"}, {name: "already running", job: model.Job{ID: 41}, err: model.ErrJobActive, path: "/services/1/restore", wantCode: http.StatusSeeOther, wantLocation: "/jobs/41"}, {name: "refused", err: &model.NotUpdatableError{Reason: model.ReasonNotDrifted}, path: "/services/1/restore", wantCode: http.StatusConflict, wantBody: "Cannot restore: runs its desired version."}, {name: "unknown service", err: model.ErrNotFound, path: "/services/1/restore", wantCode: http.StatusNotFound}, {name: "invalid id", path: "/services/x/restore", wantCode: http.StatusBadRequest}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { srv, _, _, _, jobs := newTestEnv(t) jobs.job, jobs.err = tt.job, tt.err resp, body := postTo(t, srv, tt.path, url.Values{}) assert.Equal(t, tt.wantCode, resp.StatusCode) assert.Equal(t, tt.wantLocation, resp.Header.Get("Location")) assert.Contains(t, body, tt.wantBody) if tt.wantCode != http.StatusBadRequest { assert.Equal(t, "alice", jobs.triggeredBy()) } }) } } func TestAcceptRunning_WritesRunningRefAndPublishes(t *testing.T) { srv, store, _, broker, _ := newTestEnv(t) driftService(store) ch, cancel := broker.Subscribe() defer cancel() resp, _ := postTo(t, srv, "/services/1/accept", url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftActual}}) assert.Equal(t, http.StatusSeeOther, resp.StatusCode) assert.Equal(t, "/services/1", resp.Header.Get("Location")) assert.Equal(t, []acceptCall{{ id: 1, actualRef: "gitea.shcizo.se/shcizo/slusk:1.4.2", actualDigest: driftActual, desiredRef: "gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftActual, }}, store.acceptCalls, "the value written comes from the row, not the form") select { case topic := <-ch: assert.Equal(t, events.Services, topic) case <-time.After(time.Second): t.Fatal("no services signal") } } func TestAcceptRunning_ReturnToOverview(t *testing.T) { srv, store, _, _, _ := newTestEnv(t) driftService(store) resp, _ := postTo(t, srv, "/services/1/accept", url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftActual}, "return": {"overview"}}) assert.Equal(t, http.StatusSeeOther, resp.StatusCode) assert.Equal(t, "/", resp.Header.Get("Location")) } func TestAcceptRunning_Refusals(t *testing.T) { tests := []struct { name string path string form url.Values notDrifted bool removed bool acceptErr error wantCode int wantBody string }{ {name: "missing actual", path: "/services/1/accept", form: url.Values{}, wantCode: http.StatusBadRequest}, {name: "stale actual", path: "/services/1/accept", form: url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.3.0"}}, wantCode: http.StatusConflict, wantBody: "The running version changed; check it again."}, {name: "same ref, other digest", path: "/services/1/accept", form: url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftDesired}}, wantCode: http.StatusConflict, wantBody: "The running version changed; check it again."}, {name: "not drifted", path: "/services/1/accept", form: url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftActual}}, notDrifted: true, wantCode: http.StatusConflict, wantBody: "Cannot accept: the service runs its desired version."}, {name: "changed under our feet", path: "/services/1/accept", form: url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftActual}}, acceptErr: model.ErrConflict, wantCode: http.StatusConflict, wantBody: "The running version changed; check it again."}, {name: "unknown service", path: "/services/9/accept", form: url.Values{"actual": {"x"}}, wantCode: http.StatusNotFound}, {name: "removed", path: "/services/1/accept", form: url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftActual}}, removed: true, wantCode: http.StatusNotFound}, {name: "removed meanwhile", path: "/services/1/accept", form: url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftActual}}, acceptErr: model.ErrNotFound, wantCode: http.StatusNotFound}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { srv, store, _, _, _ := newTestEnv(t) if !tt.notDrifted { driftService(store) } if tt.removed { removedAt := time.Now() store.services[0].RemovedAt = &removedAt } store.acceptErr = tt.acceptErr resp, body := postTo(t, srv, tt.path, tt.form) assert.Equal(t, tt.wantCode, resp.StatusCode) assert.Contains(t, body, tt.wantBody) if tt.acceptErr == nil { assert.Empty(t, store.acceptCalls) } }) } } func TestMergeService_RedirectsToTheSurvivorAndPublishes(t *testing.T) { srv, store, _, broker, _ := newTestEnv(t) ch, cancel := broker.Subscribe() defer cancel() resp, _ := postTo(t, srv, "/services/1/merge", url.Values{"from": {"17"}}) assert.Equal(t, http.StatusSeeOther, resp.StatusCode) assert.Equal(t, "/services/17", resp.Header.Get("Location")) assert.Equal(t, [][2]int64{{17, 1}}, store.merged) var got []events.Topic for len(got) < 2 { select { case topic := <-ch: got = append(got, topic) case <-time.After(time.Second): t.Fatalf("signals so far: %v", got) } } assert.ElementsMatch(t, []events.Topic{events.Services, events.Jobs}, got) } func TestMergeService_Refusals(t *testing.T) { tests := []struct { name string id, from string err error wantCode int wantBody string }{ {name: "invalid from", id: "1", from: "x", wantCode: http.StatusBadRequest}, {name: "empty from", id: "1", from: "", wantCode: http.StatusBadRequest}, {name: "refused", id: "1", from: "17", err: &model.MergeRefusedError{Reason: model.ReasonMergeLiveOld}, wantCode: http.StatusConflict, wantBody: "Cannot merge: the old service still exists."}, {name: "job active", id: "1", from: "17", err: model.ErrJobActive, wantCode: http.StatusConflict, wantBody: "Cannot merge: an update is already in progress for this service."}, {name: "agent revoked", id: "1", from: "17", err: model.ErrRevoked, wantCode: http.StatusConflict, wantBody: "Cannot merge:"}, {name: "not found", id: "1", from: "17", err: model.ErrNotFound, wantCode: http.StatusNotFound}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { srv, store, _, _, _ := newTestEnv(t) store.mergeErr = tt.err resp, body := postTo(t, srv, "/services/"+tt.id+"/merge", url.Values{"from": {tt.from}}) assert.Equal(t, tt.wantCode, resp.StatusCode) assert.Contains(t, body, tt.wantBody) assert.Empty(t, resp.Header.Get("Location")) }) } } // serviceDetail's data is what the service template reads. func TestServiceDetail_DriftAndMoveData(t *testing.T) { removed := time.Now() live := model.Service{ID: 1, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", ActualRef: "slusk:1"} gone := model.Service{ID: 2, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", ActualRef: "slusk:1", RemovedAt: &removed} swarm := model.Service{ID: 3, Kind: protocol.KindSwarm, SwarmService: "gitea", ActualRef: "gitea:1"} container := model.Service{ID: 4, Kind: protocol.KindContainer, ContainerName: "adhoc", ActualRef: "redis:7"} self := model.Service{ID: 5, Kind: protocol.KindCompose, ComposeProject: "pu", ComposeService: "agent", ActualRef: "pu:1", Self: true} move := model.Move{FromID: 2, ToID: 1, FromAgent: "old", ToAgent: "new"} sources := []model.Service{gone} tests := []struct { name string id int64 restorable error wantBlocked string wantFrom *model.Move wantTo *model.Move wantSources []model.Service }{ {name: "live row gets the suggestion and merge choices", id: 1, wantFrom: &move, wantSources: sources}, {name: "removed row gets where it went", id: 2, wantTo: &move}, {name: "swarm service can merge, no suggestion", id: 3, wantSources: sources}, {name: "container cannot merge", id: 4}, {name: "self cannot merge", id: 5}, {name: "restore refusal is passed on", id: 1, restorable: &model.NotUpdatableError{Reason: model.ReasonContainer}, wantBlocked: model.ReasonContainer, wantFrom: &move, wantSources: sources}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { store := &fakeStore{services: []model.Service{live, gone, swarm, container, self}, moves: []model.Move{move}, sources: sources} jobs := &fakeJobs{restorable: tt.restorable} s, err := New(store, &fakeHub{}, jobs, events.NewBroker(), Config{}, slog.New(slog.NewTextHandler(io.Discard, nil))) require.NoError(t, err) req := httptest.NewRequest(http.MethodGet, "/services/"+strconv.FormatInt(tt.id, 10), nil) data, err := s.serviceDetail(req, tt.id) require.NoError(t, err) assert.Equal(t, tt.wantBlocked, data.RestoreBlocked) assert.Equal(t, tt.wantFrom, data.MoveFrom) assert.Equal(t, tt.wantTo, data.MoveTo) assert.Equal(t, tt.wantSources, data.MergeSources) }) } }