package web import ( "bufio" "context" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "regexp" "strings" "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/shcizo/package-updater/internal/agenttoken" "github.com/shcizo/package-updater/internal/master/events" "github.com/shcizo/package-updater/internal/master/model" "github.com/shcizo/package-updater/internal/master/webauth" "github.com/shcizo/package-updater/internal/protocol" ) // fakeStore is written from the HTTP handler goroutine and read by the test // goroutine, so every access goes through mu. type fakeStore struct { mu sync.Mutex services []model.Service jobs []model.Job agents []model.Agent created map[string]string // name → token hash revokedID int64 revokeFailed []int64 // job IDs RevokeAgent reports as failed versions []model.ImageVersion acceptCalls []acceptCall acceptErr error moves []model.Move sources []model.Service merged [][2]int64 // from, to mergeErr error } func (f *fakeStore) RecentVersions(_ context.Context, repo string, limit int) ([]model.ImageVersion, error) { var out []model.ImageVersion for _, v := range f.versions { if v.Repo == repo && len(out) < limit { out = append(out, v) } } return out, nil } func (f *fakeStore) ListServices(context.Context) ([]model.Service, error) { return f.services, nil } func (f *fakeStore) GetService(_ context.Context, id int64) (model.Service, error) { for _, s := range f.services { if s.ID == id { return s, nil } } return model.Service{}, model.ErrNotFound } func (f *fakeStore) SetUpdateMode(_ context.Context, id int64, mode string) error { f.mu.Lock() defer f.mu.Unlock() for i := range f.services { if f.services[i].ID == id && f.services[i].RemovedAt == nil { f.services[i].UpdateMode = mode return nil } } return model.ErrNotFound } func (f *fakeStore) modeOf(id int64) string { f.mu.Lock() defer f.mu.Unlock() for _, s := range f.services { if s.ID == id { return s.UpdateMode } } return "" } func (f *fakeStore) GetJob(_ context.Context, id int64) (model.Job, error) { for _, j := range f.jobs { if j.ID == id { return j, nil } } return model.Job{}, model.ErrNotFound } func (f *fakeStore) ListJobs(_ context.Context, flt model.JobFilter) ([]model.Job, error) { var out []model.Job for _, j := range f.jobs { if flt.ServiceID == 0 || j.ServiceID == flt.ServiceID { out = append(out, j) } } return out, nil } func (f *fakeStore) ListAgents(context.Context) ([]model.Agent, error) { return f.agents, nil } func (f *fakeStore) CreateAgent(_ context.Context, name, hash string) (model.Agent, error) { f.mu.Lock() defer f.mu.Unlock() if _, taken := f.created[name]; taken { return model.Agent{}, model.ErrConflict } f.created[name] = hash return model.Agent{ID: 9, Name: name}, nil } func (f *fakeStore) RevokeAgent(_ context.Context, id int64, _ time.Time) ([]int64, error) { f.mu.Lock() defer f.mu.Unlock() f.revokedID = id return f.revokeFailed, nil } func (f *fakeStore) createdHash(name string) string { f.mu.Lock() defer f.mu.Unlock() return f.created[name] } func (f *fakeStore) revoked() int64 { f.mu.Lock() defer f.mu.Unlock() return f.revokedID } // fakeJobs returns scripted results and records who asked for the update. type fakeJobs struct { updatable error autoErr error job model.Job err error mu sync.Mutex requestedBy string triggered []int64 // version IDs passed to TriggerVersion triggerSvc []int64 // service IDs passed to TriggerVersion plainCalls int // Trigger calls restorable error // returned by Restorable restored []int64 // service IDs passed to RestoreDesired } func (f *fakeJobs) Updatable(model.Service) error { return f.updatable } func (f *fakeJobs) AutoAllowed(model.Service) error { return f.autoErr } func (f *fakeJobs) Trigger(_ context.Context, _ int64, by string) (model.Job, error) { f.mu.Lock() defer f.mu.Unlock() f.requestedBy = by f.plainCalls++ return f.job, f.err } func (f *fakeJobs) TriggerVersion(_ context.Context, serviceID, versionID int64, by string) (model.Job, error) { f.mu.Lock() defer f.mu.Unlock() f.requestedBy = by f.triggerSvc = append(f.triggerSvc, serviceID) f.triggered = append(f.triggered, versionID) return f.job, f.err } func (f *fakeJobs) triggeredBy() string { f.mu.Lock() defer f.mu.Unlock() return f.requestedBy } type fakeHub struct { mu sync.Mutex online map[int64]bool disconnected int64 } func (h *fakeHub) Online(id int64) bool { h.mu.Lock() defer h.mu.Unlock() return h.online[id] } func (h *fakeHub) Disconnect(id int64) { h.mu.Lock() defer h.mu.Unlock() h.disconnected = id } func (h *fakeHub) lastDisconnected() int64 { h.mu.Lock() defer h.mu.Unlock() return h.disconnected } func newTestServer(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker) { t.Helper() srv, store, hub, broker, _ := newTestEnv(t) return srv, store, hub, broker } // newTestEnv starts the UI on fake storage, with polling off. func newTestEnv(t *testing.T) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) { t.Helper() return newTestEnvPolling(t, 0) } // newTestEnvPolling is newTestEnv with pollInterval as the polling interval // the service page shows. func newTestEnvPolling(t *testing.T, pollInterval time.Duration) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) { t.Helper() return newTestEnvConfig(t, func(c *Config) { c.PollInterval = pollInterval }) } // newTestEnvVersion is newTestEnv with version as the master's build version. func newTestEnvVersion(t *testing.T, version string) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) { t.Helper() return newTestEnvConfig(t, func(c *Config) { c.Version = version }) } // newTestEnvConfig is newTestEnv with edit applied to the UI's Config. func newTestEnvConfig(t *testing.T, edit func(*Config)) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) { t.Helper() agentID, offlineAgentID := int64(2), int64(3) store := &fakeStore{ created: map[string]string{"node-2": "x"}, services: []model.Service{ {ID: 1, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", AgentID: &agentID, AgentName: "node-2", ActualRef: "gitea.shcizo.se/shcizo/slusk:1.4.2", Status: "running", UpdateMode: "manual"}, {ID: 2, Kind: protocol.KindSwarm, SwarmService: "jellyfin_jellyfin", SwarmClusterID: "c1abcdefgh", ActualRef: "lscr.io/linuxserver/jellyfin@sha256:0f42497a69fa0441bfd5f9d6bba8694f2a656ec984e571d0ac04c6dd91250039", Status: "degraded", UpdateMode: "manual"}, {ID: 3, Kind: protocol.KindContainer, ContainerName: "updater-agent", AgentID: &agentID, AgentName: "node-2", ActualRef: "pu:1", Status: "running", UpdateMode: "manual", Self: true}, {ID: 4, Kind: protocol.KindCompose, ComposeProject: "media", ComposeService: "radarr", AgentID: &offlineAgentID, AgentName: "node-3", ActualRef: "lscr.io/linuxserver/radarr:latest", Status: "running", UpdateMode: "manual"}, }, agents: []model.Agent{ {ID: 2, Name: "node-2", Hostname: "node-2.lan", AgentVersion: "v1.4.0", SwarmManager: true, SwarmClusterID: "c1abcdefgh", LastSeenAt: ptr(time.Now().UTC())}, {ID: 3, Name: "node-3", Hostname: "node-3.lan", AgentVersion: "v1.3.2", LastSeenAt: ptr(time.Date(2026, 10, 12, 13, 51, 0, 0, time.UTC))}, {ID: 4, Name: "old", RevokedAt: ptr(time.Now())}, {ID: 5, Name: "fresh"}, }, } hub := &fakeHub{online: map[int64]bool{2: true}} broker := events.NewBroker() jobs := &fakeJobs{} cfg := Config{ AgentURL: "wss://10.0.0.5:8090/agent", MasterKey: "sha256:abc", AgentImage: "gitea.shcizo.se/shcizo/package-updater:latest", } edit(&cfg) s, err := New(store, hub, jobs, broker, cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) require.NoError(t, err) mux := http.NewServeMux() s.Register(mux, func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := webauth.WithSession(r.Context(), model.Session{Subject: "alice", CSRFToken: "csrf"}) h.ServeHTTP(w, r.WithContext(ctx)) }) }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) return srv, store, hub, broker, jobs } func ptr[T any](v T) *T { return &v } func get(t *testing.T, srv *httptest.Server, path string) (int, string) { t.Helper() resp, err := http.Get(srv.URL + path) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) return resp.StatusCode, string(body) } func noRedirectClient() *http.Client { return &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} } func TestOverview_GroupsServicesPerMachineAndSwarm(t *testing.T) { srv, _, _, _ := newTestServer(t) code, body := get(t, srv, "/") assert.Equal(t, http.StatusOK, code) assert.Contains(t, body, "Swarm", "one cluster is just called Swarm") assert.Contains(t, body, `title="c1abcdefgh"`, "the full cluster id is the hover text") assert.Contains(t, body, "managers node-2") assert.Contains(t, body, ">node-2") assert.Contains(t, body, `web`) assert.Contains(t, body, `slusk`) assert.Contains(t, body, "jellyfin_jellyfin", "Swarm service names are shown as they are") assert.Contains(t, body, "sha256:0f42497a69fa…", "digests are shortened") assert.Contains(t, body, `This agent`) assert.NotContains(t, body, "Managed outside") assert.Less(t, strings.Index(body, ">Swarm"), strings.Index(body, ">node-2"), "a cluster is followed by its managers' machines") assert.Contains(t, body, `class="sub" data-machine="agent-2"`, "the machine filter lists the manager under the cluster") } func TestOverview_OfflineGroupShowsSince(t *testing.T) { srv, store, hub, _, _ := newTestEnv(t) _, body := get(t, srv, "/") assert.Regexp(t, `Agent offline since · showing last known state`, body) assert.NotContains(t, body, "No manager online", "the cluster has a connected manager") hub.mu.Lock() hub.online[2] = false hub.mu.Unlock() _, body = get(t, srv, "/") since := store.agents[0].LastSeenAt.Format("2006-01-02T15:04:05Z07:00") assert.Contains(t, body, `No manager online since