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/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 agents []model.Agent created map[string]string // name → token hash revokedID int64 } func (f *fakeStore) ListServices(context.Context) ([]model.Service, error) { return f.services, 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) error { f.mu.Lock() defer f.mu.Unlock() f.revokedID = id return 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 } 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() agentID := int64(2) store := &fakeStore{ created: map[string]string{"node-2": "x"}, services: []model.Service{ {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"}, {Kind: protocol.KindSwarm, SwarmService: "jellyfin_jellyfin", ActualRef: "lscr.io/linuxserver/jellyfin@sha256:0f42497a69fa0441bfd5f9d6bba8694f2a656ec984e571d0ac04c6dd91250039", Status: "degraded", UpdateMode: "manual"}, {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() s, err := New(store, hub, broker, Config{ AgentURL: "wss://10.0.0.5:8090/agent", MasterKey: "sha256:abc", AgentImage: "gitea.shcizo.se/shcizo/package-updater:latest", }, slog.New(slog.NewTextHandler(io.Discard, nil))) require.NoError(t, err) mux := http.NewServeMux() s.Register(mux, func(h http.Handler) http.Handler { return h }) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) return srv, store, hub, broker } 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, "