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. pollInterval is what the service
// page shows as the polling interval; it defaults to zero.
func newTestEnv(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: append(pollInterval, 0)[0],
}, 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+".")
}