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 } 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 } 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() agentID := int64(2) 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", 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}, }, agents: []model.Agent{ {ID: 2, Name: "node-2", Hostname: "node-2"}, {ID: 3, Name: "node-3"}, {ID: 4, Name: "old", RevokedAt: ptr(time.Now())}, }, } hub := &fakeHub{online: map[int64]bool{2: true}} broker := events.NewBroker() jobs := &fakeJobs{} s, err := New(store, hub, jobs, broker, Config{ AgentURL: "wss://10.0.0.5:8090/agent", MasterKey: "sha256:abc", AgentImage: "gitea.shcizo.se/shcizo/package-updater:latest", PollInterval: pollInterval, }, 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, "boom", Error: "pull failed", OutputTruncated: true, CreatedAt: time.Now()}} code, body := get(t, srv, "/jobs/42") assert.Equal(t, http.StatusOK, code) assert.Contains(t, body, "Pulling web") assert.Contains(t, body, "<b>boom", "output is escaped") assert.Contains(t, body, "pull failed") assert.Contains(t, body, "Earlier output truncated") assert.Contains(t, body, `hx-trigger="sse:job-42"`) } func TestJobFragment_RereadsFromStore(t *testing.T) { srv, store, _, _, _ := newTestEnv(t) store.jobs = []model.Job{{ID: 42, ServiceID: 1, Status: model.JobRunning, Output: "step 1", CreatedAt: time.Now()}} code, body := get(t, srv, "/fragments/jobs/42") assert.Equal(t, http.StatusOK, code) assert.NotContains(t, body, "`, "auto services toggle to manual: "+path) assert.Contains(t, body, `disabled>Make auto `+model.ReasonSelf+``, "blocked manual services get a disabled button: "+path) } } func TestServicePage_ShowsAvailableAndModeForm(t *testing.T) { srv, store, _, _, _ := newTestEnv(t) at := time.Date(2026, 10, 12, 12, 30, 0, 0, time.UTC) store.services[0].AvailableRef = "gitea.shcizo.se/shcizo/slusk:1.5.0" store.services[0].AvailableSource = "webhook" store.services[0].AvailableAt = &at _, body := get(t, srv, "/services/1") assert.Contains(t, body, "gitea.shcizo.se/shcizo/slusk:1.5.0") assert.Contains(t, body, "via webhook, 2026-10-12 12:30") assert.Contains(t, body, `action="/services/1/mode"`) assert.Contains(t, body, ``) } func TestServicePage_AutoBlockedDisablesToggle(t *testing.T) { srv, _, _, _, jobs := newTestEnv(t) jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonContainer} _, body := get(t, srv, "/services/1") assert.Contains(t, body, "Auto unavailable: "+model.ReasonContainer+".") }