Clear available_* only when the deployed ref equals it, drop the unused poll source, stop leaking internal errors to CI, use the request-scoped logger, validate X-Request-ID, re-read jobs once after the timeout, report refused when no target ref exists, and tidy UI, deploy and docs.
644 lines
22 KiB
Go
644 lines
22 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/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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
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
|
|
}
|
|
|
|
func newTestEnv(t *testing.T) (*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",
|
|
}, 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, "<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)
|
|
store.revokeFailed = []int64{5, 6}
|
|
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")
|
|
var got []events.Topic
|
|
for len(got) < 4 {
|
|
select {
|
|
case topic := <-ch:
|
|
got = append(got, topic)
|
|
default:
|
|
t.Fatalf("revoke published %v, want services, jobs and one topic per failed job", got)
|
|
}
|
|
}
|
|
assert.ElementsMatch(t, []events.Topic{events.Services, events.Jobs, events.JobTopic(5), events.JobTopic(6)}, got,
|
|
"the overview drops the agent's services; job lists and open job pages show the failures")
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func TestAgentSnippet(t *testing.T) {
|
|
snippet := agentSnippet(Config{AgentURL: "wss://m/agent", MasterKey: "sha256:abc", AgentImage: "img:1"}, "tok")
|
|
assert.NotContains(t, snippet, "STACKS_ROOT")
|
|
assert.Contains(t, snippet, "UPDATE_TIMEOUT: 5m")
|
|
assert.Contains(t, snippet, " # DOCKER_CONFIG: /docker-config\n")
|
|
assert.Contains(t, snippet, " # - /path/to/stacks:/path/to/stacks # mount each stacks directory at the same path, read-write\n")
|
|
assert.Contains(t, snippet, "AGENT_TOKEN: tok")
|
|
}
|
|
|
|
func TestOverview_LinksToServicePage(t *testing.T) {
|
|
srv, _, _, _ := newTestServer(t)
|
|
_, body := get(t, srv, "/")
|
|
assert.Contains(t, body, `href="/services/1"`)
|
|
assert.Contains(t, body, `href="/jobs"`)
|
|
}
|
|
|
|
func TestServicePage_ShowsUpdateButton(t *testing.T) {
|
|
srv, store, _, _, _ := newTestEnv(t)
|
|
store.services[0].ConfigFiles = "/srv/slusk/compose.yaml,/srv/slusk/compose.override.yaml"
|
|
store.jobs = []model.Job{{ID: 7, ServiceID: 1, Status: model.JobSucceeded, FromRef: "a:1", ToRef: "a:1", RequestedBy: "alice", CreatedAt: time.Now()}}
|
|
code, body := get(t, srv, "/services/1")
|
|
assert.Equal(t, http.StatusOK, code)
|
|
assert.Contains(t, body, `action="/services/1/update"`)
|
|
assert.Contains(t, body, `name="csrf_token" value="csrf"`)
|
|
assert.Contains(t, body, "/srv/slusk/compose.override.yaml")
|
|
assert.Contains(t, body, `href="/jobs/7"`)
|
|
assert.Contains(t, body, "sse:jobs")
|
|
}
|
|
|
|
func TestServicePage_DisabledReason(t *testing.T) {
|
|
for _, reason := range []string{model.ReasonSelf, model.ReasonContainer, model.ReasonNoTag, model.ReasonRemoved, model.ReasonBuilt} {
|
|
t.Run(reason, func(t *testing.T) {
|
|
srv, _, _, _, jobs := newTestEnv(t)
|
|
jobs.updatable = &model.NotUpdatableError{Reason: reason}
|
|
code, body := get(t, srv, "/services/1")
|
|
assert.Equal(t, http.StatusOK, code)
|
|
assert.Contains(t, body, reason)
|
|
assert.NotContains(t, body, `action="/services/1/update"`)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServicePage_NotFoundAndBadID(t *testing.T) {
|
|
srv, _, _, _ := newTestServer(t)
|
|
code, _ := get(t, srv, "/services/99")
|
|
assert.Equal(t, http.StatusNotFound, code)
|
|
code, _ = get(t, srv, "/services/abc")
|
|
assert.Equal(t, http.StatusBadRequest, code)
|
|
}
|
|
|
|
func postUpdate(t *testing.T, srv *httptest.Server) (*http.Response, string) {
|
|
t.Helper()
|
|
resp, err := noRedirectClient().PostForm(srv.URL+"/services/1/update", url.Values{"csrf_token": {"csrf"}})
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return resp, string(body)
|
|
}
|
|
|
|
func TestUpdate_RedirectsToJob(t *testing.T) {
|
|
srv, _, _, _, jobs := newTestEnv(t)
|
|
jobs.job = model.Job{ID: 42}
|
|
resp, _ := postUpdate(t, srv)
|
|
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
|
assert.Equal(t, "/jobs/42", resp.Header.Get("Location"))
|
|
assert.Equal(t, "alice", jobs.triggeredBy())
|
|
}
|
|
|
|
func TestUpdate_ActiveJobRedirectsToExisting(t *testing.T) {
|
|
srv, _, _, _, jobs := newTestEnv(t)
|
|
jobs.job, jobs.err = model.Job{ID: 41}, model.ErrJobActive
|
|
resp, _ := postUpdate(t, srv)
|
|
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
|
assert.Equal(t, "/jobs/41", resp.Header.Get("Location"))
|
|
}
|
|
|
|
func TestUpdate_NotUpdatableShows409(t *testing.T) {
|
|
srv, _, _, _, jobs := newTestEnv(t)
|
|
jobs.err = &model.NotUpdatableError{Reason: model.ReasonNoTag}
|
|
jobs.updatable = jobs.err
|
|
resp, body := postUpdate(t, srv)
|
|
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
|
assert.Contains(t, body, model.ReasonNoTag)
|
|
}
|
|
|
|
func TestUpdate_UnknownService404(t *testing.T) {
|
|
srv, _, _, _, jobs := newTestEnv(t)
|
|
jobs.err = model.ErrNotFound
|
|
resp, _ := postUpdate(t, srv)
|
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
|
}
|
|
|
|
func TestServiceJobsFragment_IsNotAFullPage(t *testing.T) {
|
|
srv, store, _, _, _ := newTestEnv(t)
|
|
store.jobs = []model.Job{{ID: 7, ServiceID: 1, Status: model.JobFailed, CreatedAt: time.Now()}}
|
|
code, body := get(t, srv, "/fragments/services/1/jobs")
|
|
assert.Equal(t, http.StatusOK, code)
|
|
assert.NotContains(t, body, "<html")
|
|
assert.Contains(t, body, "/jobs/7")
|
|
}
|
|
|
|
func TestJobsPage_ListsJobs(t *testing.T) {
|
|
srv, store, _, _, _ := newTestEnv(t)
|
|
started, finished := time.Now(), time.Now().Add(42*time.Second)
|
|
store.jobs = []model.Job{
|
|
{ID: 7, ServiceID: 1, ServiceName: "slusk/web", Status: model.JobSucceeded, FromRef: "a:1", ToRef: "a:1@sha256:abc", RequestedBy: "alice", CreatedAt: started, StartedAt: &started, FinishedAt: &finished},
|
|
{ID: 8, ServiceID: 2, ServiceName: "jellyfin_jellyfin", Status: model.JobFailed, CreatedAt: started},
|
|
}
|
|
for _, path := range []string{"/jobs", "/fragments/jobs"} {
|
|
code, body := get(t, srv, path)
|
|
assert.Equal(t, http.StatusOK, code)
|
|
assert.Contains(t, body, `href="/jobs/7"`)
|
|
assert.Contains(t, body, `href="/services/2"`)
|
|
assert.Contains(t, body, "succeeded")
|
|
assert.Contains(t, body, "failed")
|
|
assert.Contains(t, body, "42s")
|
|
assert.Equal(t, path == "/jobs", strings.Contains(body, "<html"))
|
|
}
|
|
}
|
|
|
|
func TestJobPage_ShowsOutputAndError(t *testing.T) {
|
|
srv, store, _, _, _ := newTestEnv(t)
|
|
store.jobs = []model.Job{{ID: 42, ServiceID: 1, ServiceName: "slusk/web", Status: model.JobFailed,
|
|
Output: "Pulling web\n<b>boom</b>", 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, "<html")
|
|
assert.Contains(t, body, "step 1")
|
|
|
|
store.jobs[0].Output = "step 1\nstep 2"
|
|
_, body = get(t, srv, "/fragments/jobs/42")
|
|
assert.Contains(t, body, "step 2")
|
|
}
|
|
|
|
func TestJobFragment_PollsOnlyWhileActive(t *testing.T) {
|
|
srv, store, _, _, _ := newTestEnv(t)
|
|
store.jobs = []model.Job{
|
|
{ID: 42, ServiceID: 1, Status: model.JobRunning, CreatedAt: time.Now()},
|
|
{ID: 43, ServiceID: 1, Status: model.JobSucceeded, CreatedAt: time.Now()},
|
|
}
|
|
_, body := get(t, srv, "/fragments/jobs/42")
|
|
assert.Contains(t, body, `hx-trigger="sse:job-42, every 2s"`)
|
|
_, body = get(t, srv, "/fragments/jobs/43")
|
|
assert.Contains(t, body, `hx-trigger="sse:job-43"`)
|
|
assert.NotContains(t, body, "every 2s")
|
|
}
|
|
|
|
func TestJob_NotFound(t *testing.T) {
|
|
srv, _, _, _ := newTestServer(t)
|
|
for _, path := range []string{"/jobs/99", "/fragments/jobs/99"} {
|
|
code, _ := get(t, srv, path)
|
|
assert.Equal(t, http.StatusNotFound, code)
|
|
}
|
|
}
|
|
|
|
func postMode(t *testing.T, srv *httptest.Server, id string, form url.Values) (*http.Response, string) {
|
|
t.Helper()
|
|
form.Set("csrf_token", "csrf")
|
|
resp, err := noRedirectClient().PostForm(srv.URL+"/services/"+id+"/mode", form)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return resp, string(body)
|
|
}
|
|
|
|
func TestSetMode_AutoRedirectsToServiceAndPublishes(t *testing.T) {
|
|
srv, store, _, broker, _ := newTestEnv(t)
|
|
sub, cancel := broker.Subscribe()
|
|
defer cancel()
|
|
|
|
resp, _ := postMode(t, srv, "1", url.Values{"mode": {"auto"}})
|
|
|
|
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
|
assert.Equal(t, "/services/1", resp.Header.Get("Location"))
|
|
assert.Equal(t, "auto", store.modeOf(1))
|
|
assert.Equal(t, events.Services, <-sub)
|
|
}
|
|
|
|
func TestSetMode_ReturnToOverview(t *testing.T) {
|
|
srv, store, _, _, _ := newTestEnv(t)
|
|
resp, _ := postMode(t, srv, "1", url.Values{"mode": {"auto"}, "return": {"overview"}})
|
|
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
|
assert.Equal(t, "/", resp.Header.Get("Location"))
|
|
assert.Equal(t, "auto", store.modeOf(1))
|
|
}
|
|
|
|
func TestSetMode_AutoRefusedShows409(t *testing.T) {
|
|
srv, store, _, _, jobs := newTestEnv(t)
|
|
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonBuilt}
|
|
|
|
resp, body := postMode(t, srv, "1", url.Values{"mode": {"auto"}})
|
|
|
|
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
|
assert.Contains(t, body, "Cannot enable auto: "+model.ReasonBuilt+".")
|
|
assert.Equal(t, "manual", store.modeOf(1))
|
|
}
|
|
|
|
func TestSetMode_ManualAlwaysAllowed(t *testing.T) {
|
|
srv, store, _, _, jobs := newTestEnv(t)
|
|
store.services[0].UpdateMode = "auto"
|
|
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonBuilt}
|
|
|
|
resp, _ := postMode(t, srv, "1", url.Values{"mode": {"manual"}})
|
|
|
|
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
|
|
assert.Equal(t, "manual", store.modeOf(1))
|
|
}
|
|
|
|
func TestSetMode_InvalidModeAndUnknownService(t *testing.T) {
|
|
srv, store, _, _, _ := newTestEnv(t)
|
|
store.services = append(store.services, model.Service{ID: 5, Kind: protocol.KindCompose, UpdateMode: "manual", RemovedAt: ptr(time.Now())})
|
|
|
|
resp, _ := postMode(t, srv, "1", url.Values{"mode": {"yolo"}})
|
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
resp, _ = postMode(t, srv, "1", url.Values{})
|
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
resp, _ = postMode(t, srv, "99", url.Values{"mode": {"auto"}})
|
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
|
resp, _ = postMode(t, srv, "99", url.Values{"mode": {"manual"}})
|
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
|
resp, _ = postMode(t, srv, "5", url.Values{"mode": {"auto"}})
|
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
|
resp, _ = postMode(t, srv, "abc", url.Values{"mode": {"auto"}})
|
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
assert.Equal(t, "manual", store.modeOf(1))
|
|
}
|
|
|
|
func TestOverview_ShowsModeToggleAndAvailableBadge(t *testing.T) {
|
|
srv, store, _, _, jobs := newTestEnv(t)
|
|
store.services[0].AvailableRef = "gitea.shcizo.se/shcizo/slusk:1.5.0"
|
|
store.services[1].UpdateMode = "auto"
|
|
jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonSelf}
|
|
|
|
for _, path := range []string{"/", "/fragments/services"} {
|
|
_, body := get(t, srv, path)
|
|
assert.Contains(t, body, `action="/services/1/mode"`, path)
|
|
assert.Contains(t, body, `name="csrf_token" value="csrf"`, path)
|
|
assert.Contains(t, body, `name="return" value="overview"`, path)
|
|
assert.Contains(t, body, `class="badge badge-update" title="gitea.shcizo.se/shcizo/slusk:1.5.0"`, path)
|
|
assert.Contains(t, body, `<input type="hidden" name="mode" value="manual">`, "auto services toggle to manual: "+path)
|
|
assert.Contains(t, body, `disabled>Make auto</button> <span class="muted">`+model.ReasonSelf+`</span>`, "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, `<input type="hidden" name="mode" value="auto">`)
|
|
}
|
|
|
|
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+".")
|
|
}
|