Files
package-updater/internal/master/web/server_test.go
T

280 lines
9.1 KiB
Go

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) GetService(context.Context, int64) (model.Service, error) {
return model.Service{}, model.ErrNotFound
}
func (f *fakeStore) GetJob(context.Context, int64) (model.Job, error) {
return model.Job{}, model.ErrNotFound
}
func (f *fakeStore) ListJobs(context.Context, model.JobFilter) ([]model.Job, error) { return nil, 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 fakeJobs struct{}
func (fakeJobs) Updatable(model.Service) error { return nil }
func (fakeJobs) Trigger(context.Context, int64, string) (model.Job, error) {
return model.Job{}, model.ErrNotFound
}
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, fakeJobs{}, 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, "<html")
assert.Contains(t, body, "node-2")
assert.Contains(t, body, "Swarm")
assert.Contains(t, body, "slusk/web")
assert.Contains(t, body, "jellyfin_jellyfin")
assert.Contains(t, body, "sha256:0f42497a69fa…", "digests are shortened")
assert.Contains(t, body, "managed outside")
assert.Less(t, strings.Index(body, "node-2"), strings.Index(body, "Swarm"), "machines before Swarm")
}
func TestServicesFragment_IsNotAFullPage(t *testing.T) {
srv, _, _, _ := newTestServer(t)
code, body := get(t, srv, "/fragments/services")
assert.Equal(t, http.StatusOK, code)
assert.NotContains(t, body, "<html")
assert.Contains(t, body, "slusk/web")
}
func TestAgentsPage_ShowsConnectionState(t *testing.T) {
srv, _, _, _ := newTestServer(t)
_, body := get(t, srv, "/agents")
assert.Regexp(t, regexp.MustCompile(`node-2[\s\S]*online`), body)
assert.Regexp(t, regexp.MustCompile(`node-3[\s\S]*offline`), body)
assert.Regexp(t, regexp.MustCompile(`old[\s\S]*revoked`), body)
}
func TestCreateAgent_ShowsSnippetOnceAndStoresHash(t *testing.T) {
srv, store, _, _ := newTestServer(t)
resp, err := http.PostForm(srv.URL+"/agents", url.Values{"name": {"node-4"}})
require.NoError(t, err)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
require.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "no-store", resp.Header.Get("Cache-Control"), "the one-time token must not be cached")
token := regexp.MustCompile(`pua_[A-Za-z0-9_-]{43}`).FindString(string(body))
require.NotEmpty(t, token)
assert.Equal(t, agenttoken.Hash(token), store.createdHash("node-4"), "only the hash is stored")
assert.Contains(t, string(body), "MASTER_KEY: sha256:abc")
assert.Contains(t, string(body), "MASTER_URL: wss://10.0.0.5:8090/agent")
assert.Contains(t, string(body), "shown only once")
}
func TestCreateAgent_Validation(t *testing.T) {
srv, _, _, _ := newTestServer(t)
cases := []struct {
name, input string
wantCode int
wantText string
}{
{"duplicate", "node-2", http.StatusConflict, "already exists"},
{"invalid characters", "Node 2!", http.StatusBadRequest, "lowercase letters, digits and dashes"},
{"empty", " ", http.StatusBadRequest, "lowercase letters, digits and dashes"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
resp, err := http.PostForm(srv.URL+"/agents", url.Values{"name": {c.input}})
require.NoError(t, err)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, c.wantCode, resp.StatusCode)
assert.Contains(t, string(body), c.wantText)
})
}
}
func TestRevokeAgent_RevokesAndDisconnects(t *testing.T) {
srv, store, hub, broker := newTestServer(t)
ch, cancel := broker.Subscribe()
defer cancel()
resp, err := noRedirectClient().Post(srv.URL+"/agents/2/revoke", "application/x-www-form-urlencoded", nil)
require.NoError(t, err)
_ = resp.Body.Close()
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
assert.Equal(t, "/agents", resp.Header.Get("Location"))
assert.Equal(t, int64(2), store.revoked())
assert.Equal(t, int64(2), hub.lastDisconnected(), "a revoked agent must be cut off immediately")
select {
case topic := <-ch:
assert.Equal(t, events.Services, topic, "the overview must drop the revoked agent's services")
default:
t.Fatal("revoke published no services event")
}
}
func TestRevokeAgent_BadID(t *testing.T) {
srv, _, _, _ := newTestServer(t)
resp, err := noRedirectClient().Post(srv.URL+"/agents/abc/revoke", "application/x-www-form-urlencoded", nil)
require.NoError(t, err)
_ = resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
func TestEvents_StreamsTopics(t *testing.T) {
srv, _, _, broker := newTestServer(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/events", nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type"))
assert.Equal(t, "no", resp.Header.Get("X-Accel-Buffering"), "nginx must not buffer the stream")
reader := bufio.NewReader(resp.Body)
line, err := reader.ReadString('\n') // ": connected"
require.NoError(t, err)
require.True(t, strings.HasPrefix(line, ":"))
broker.Publish(events.Services)
for {
line, err = reader.ReadString('\n')
require.NoError(t, err)
if strings.HasPrefix(line, "event:") {
break
}
}
assert.Equal(t, "event: services\n", line)
}