Files
package-updater/internal/master/web/versions_test.go

222 lines
8.2 KiB
Go

package web
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/shcizo/package-updater/internal/master/model"
)
const sluskRepo = "gitea.shcizo.se/shcizo/slusk"
func TestServicePage_VersionPicker(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
store.services[0].ImageRepo = sluskRepo
store.services[0].ActualDigest = "sha256:cur"
first := time.Date(2026, 10, 12, 9, 5, 0, 0, time.UTC)
store.versions = []model.ImageVersion{
{ID: 31, Repo: sluskRepo, Tag: "1.5.0", Digest: "sha256:" + strings.Repeat("a", 40), Source: "poll", FirstSeenAt: first},
{ID: 32, Repo: sluskRepo, Tag: "1.4.2", Digest: "sha256:cur", Source: "running", FirstSeenAt: first},
{ID: 33, Repo: sluskRepo, Digest: "sha256:" + strings.Repeat("b", 40), Source: "running", FirstSeenAt: first},
{ID: 34, Repo: sluskRepo, Tag: "main", Source: "webhook", FirstSeenAt: first},
{ID: 40, Repo: "other/repo", Tag: "x", Source: "poll", FirstSeenAt: first},
}
_, body := get(t, srv, "/services/1")
for _, id := range []string{"31", "32", "33", "34"} {
assert.Contains(t, body, `<input type="hidden" name="version" value="`+id+`">`, id)
}
assert.NotContains(t, body, `name="version" value="40"`, "versions of other repos are not listed")
assert.Equal(t, 4, strings.Count(body, `name="version"`))
assert.Contains(t, body, `name="csrf_token" value="csrf"`)
assert.Contains(t, body, `title="`+sluskRepo+`:1.5.0@sha256:`+strings.Repeat("a", 40)+`"`, "full ref as title")
assert.Contains(t, body, sluskRepo+":1.5.0@sha256:"+strings.Repeat("a", 12), "short ref as text")
assert.Equal(t, 1, strings.Count(body, `<span class="badge">running</span>`), "only the running digest is marked")
assert.Equal(t, 1, strings.Count(body, "the service will follow no tag afterwards"), "only the tagless row")
assert.Contains(t, body, "2026-10-12 09:05")
assert.NotContains(t, body, "auto mode", "no auto warning for a manual service")
}
func TestServicePage_VersionPickerShowsAtMostTenRows(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
store.services[0].ImageRepo = sluskRepo
for i := int64(1); i <= 12; i++ {
store.versions = append(store.versions, model.ImageVersion{ID: i, Repo: sluskRepo, Tag: "t", Digest: "sha256:d", Source: "poll"})
}
_, body := get(t, srv, "/services/1")
assert.Equal(t, 10, strings.Count(body, `name="version"`))
}
func TestServicePage_VersionPickerWarnsForAutoServices(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
store.services[0].UpdateMode = "auto"
_, body := get(t, srv, "/services/1")
assert.Contains(t, body, "This service is in auto mode")
}
func TestServicePage_VersionPickerHiddenWhenServiceCannotBeUpdated(t *testing.T) {
for _, reason := range []string{model.ReasonContainer, model.ReasonRemoved, model.ReasonBuilt} {
t.Run(reason, func(t *testing.T) {
srv, store, _, _, jobs := newTestEnv(t)
store.services[0].ImageRepo = sluskRepo
store.versions = []model.ImageVersion{{ID: 31, Repo: sluskRepo, Tag: "1.5.0", Source: "poll"}}
jobs.autoErr = &model.NotUpdatableError{Reason: reason}
_, body := get(t, srv, "/services/1")
assert.Contains(t, body, "<h2>Versions</h2>")
assert.Contains(t, body, "1.5.0", "the history is still shown")
assert.NotContains(t, body, `name="version"`)
})
}
}
func TestServicePage_VersionPickerOffersVersionsToAServiceThatFollowsNoTag(t *testing.T) {
srv, store, _, _, jobs := newTestEnv(t)
store.services[0].ImageRepo = sluskRepo
store.versions = []model.ImageVersion{{ID: 31, Repo: sluskRepo, Tag: "1.5.0", Digest: "sha256:a", Source: "poll"}}
jobs.updatable = &model.NotUpdatableError{Reason: model.ReasonNoTag} // autoErr stays nil: an explicit ref is fine
_, body := get(t, srv, "/services/1")
assert.Contains(t, body, `name="version" value="31"`)
assert.NotContains(t, body, `<button type="submit">Update</button>`, "the plain Update button stays hidden")
}
func TestServicePage_PollingRow(t *testing.T) {
tests := []struct {
name string
edit func(*model.Service)
want string
failed bool
}{
{"polled", func(s *model.Service) { s.ActualDigest = "sha256:cur" }, "every 6h", false},
{"poll error", func(s *model.Service) { s.ActualDigest = "sha256:cur"; s.LastPollError = "head x: 401 <b>" }, "last check failed: head x: 401 &lt;b&gt;", true},
{"built", func(s *model.Service) { s.ActualDigest = "sha256:cur"; s.Built = true }, "not polled: built from source", false},
{"no digest", func(s *model.Service) {}, "not polled: no registry digest", false},
{"no tag", func(s *model.Service) { s.ActualRef = "r@sha256:a"; s.ActualDigest = "sha256:a" }, "not polled: follows no tag", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv, store, _, _, _ := newTestEnvPolling(t, 6*time.Hour)
tt.edit(&store.services[0])
_, body := get(t, srv, "/services/1")
assert.Contains(t, body, "<th>Polling</th><td")
assert.Contains(t, body, tt.want)
assert.Equal(t, tt.failed, strings.Contains(body, `<td class="error">`+tt.want))
})
}
}
func TestShortDuration(t *testing.T) {
tests := []struct {
d time.Duration
want string
}{
{6 * time.Hour, "6h"},
{90 * time.Minute, "1h30m"},
{30 * time.Minute, "30m"},
{90 * time.Second, "1m30s"},
{45 * time.Second, "45s"},
{6*time.Hour + 5*time.Second, "6h0m5s"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
assert.Equal(t, tt.want, shortDuration(tt.d))
})
}
}
func postVersion(t *testing.T, srv *httptest.Server, version string) (*http.Response, string) {
t.Helper()
resp, err := noRedirectClient().PostForm(srv.URL+"/services/1/update", url.Values{"csrf_token": {"csrf"}, "version": {version}})
require.NoError(t, err)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return resp, string(body)
}
func TestUpdateService_VersionStartsThatVersion(t *testing.T) {
srv, _, _, _, jobs := newTestEnv(t)
jobs.job = model.Job{ID: 42}
resp, _ := postVersion(t, srv, "31")
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
assert.Equal(t, "/jobs/42", resp.Header.Get("Location"))
assert.Equal(t, []int64{31}, jobs.triggered)
assert.Equal(t, []int64{1}, jobs.triggerSvc)
assert.Equal(t, "alice", jobs.triggeredBy())
assert.Zero(t, jobs.plainCalls)
}
func TestUpdateService_WithoutVersionTriggersTheFollowedTag(t *testing.T) {
srv, _, _, _, jobs := newTestEnv(t)
jobs.job = model.Job{ID: 42}
resp, _ := postVersion(t, srv, "")
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
assert.Equal(t, 1, jobs.plainCalls)
assert.Empty(t, jobs.triggered)
}
func TestUpdateService_VersionErrors(t *testing.T) {
tests := []struct {
name string
version string
err error
wantCode int
wantBody string
wantJob string // Location header
}{
{"not a number", "abc", nil, http.StatusBadRequest, "invalid version", ""},
{"a ref instead of an id", "repo:evil@sha256:x", nil, http.StatusBadRequest, "invalid version", ""},
{"unknown version or other repo", "77", model.ErrNotFound, http.StatusNotFound, "not found", ""},
{"not updatable", "31", &model.NotUpdatableError{Reason: model.ReasonBuilt}, http.StatusConflict, "Cannot update: " + model.ReasonBuilt + ".", ""},
{"job already active", "31", model.ErrJobActive, http.StatusSeeOther, "", "/jobs/41"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv, _, _, _, jobs := newTestEnv(t)
jobs.err, jobs.job = tt.err, model.Job{ID: 41}
resp, body := postVersion(t, srv, tt.version)
assert.Equal(t, tt.wantCode, resp.StatusCode)
assert.Contains(t, body, tt.wantBody)
assert.Equal(t, tt.wantJob, resp.Header.Get("Location"))
if tt.wantCode == http.StatusBadRequest {
assert.Empty(t, jobs.triggered, "no job for a malformed version")
}
})
}
}
func TestOverview_ShowsFollowsNoTagAndPollFailedBadges(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
store.services[0].LastPollError = `head x: "boom" <script>`
for _, path := range []string{"/", "/fragments/services"} {
_, body := get(t, srv, path)
assert.Equal(t, 1, strings.Count(body, "Follows no tag"), "only the digest-only Swarm service: "+path)
assert.Contains(t, body, `class="tag f-err" title="head x: &#34;boom&#34; &lt;script&gt;">Poll failed</span>`, path)
assert.Equal(t, 1, strings.Count(body, "Poll failed"), path)
}
}