Vendored static assets (sha256): e209dda5c8235479f3166defc7750e1dbcd5a5c1808b7792fc2e6733768fb447 htmx.min.js (htmx.org@2.0.4) 83eca6fa0611fe2b0bf1700b424b88b5eced38ef448ef9760a2ea08fbc875611 htmx-ext-sse.js (htmx-ext-sse@2.2.2)
257 lines
8.2 KiB
Go
257 lines
8.2 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) 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, "<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)
|
|
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, _ := newTestServer(t)
|
|
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")
|
|
}
|
|
|
|
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)
|
|
}
|