Files
felhom.eu/hub/internal/web/artifact_installability_test.go
T
admin b55fc17d82 hub v0.102.0 — refuse to vouch a version that cannot be installed (R-273)
The guard owed since Friday morning. Agent v0.128.0 was published as a package
and never git-tagged; it was vouched here; and because felhom-host-install.sh
fetches an agent's configs from raw/tag/v<version>/configs/, every fresh install
and reinstall died at step 5 of 8, as root, on a virgin machine, for most of a
day. handleSetArtifacts is the sole UI path to SetArtifactManifest, so the check
belongs here and nowhere else.

TWO LEGS, because both failed inside two days: the TAG (missing, R-273) and the
PACKAGE (pruned from under a still-tagged version, R-287). Either alone catches
one of them.

It asserts configs/felhom-mkfs-guarded.sh -- the FIRST of the installer's sixteen
fetch_raw calls and literally the file whose 404 broke Friday. A test pins the
constant, because probing a path that merely exists is how it stayed invisible.
The golden gets the package leg only: it has no config tree, so a tag probe would
assert something the installer never does.

"Could not verify" refuses too, with its own message. No override -- the registry
is the operator's own server, so if it is unreachable the vouch can wait.

ORDERING IS LOAD-BEARING AND A FAILING TEST FOUND IT. The probes run before
resolveArtifactSHA, whose flash conflates "missing", "unreachable" and "bad sha".
Probing first means an unreachable registry is reported as unreachable.

Five scenarios each naming the wrong outcome; three red-proofs, mutations asserted
applied and reverted. With the tag check removed, scenario A reports artifacts_set
-- Friday's exact defect returns.
2026-08-09 19:13:45 +02:00

242 lines
9.2 KiB
Go

package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// The installability gate (R-273). On 2026-08-08 agent v0.128.0 was published as a package and
// never git-tagged; the hub vouched it, and every fresh install and reinstall died at step 5 of 8,
// as root, on a virgin machine, for most of a day. These tests pin the check at the moment of risk.
//
// Each scenario names the WRONG outcome it exists to prevent, because a test whose failure mode is
// unstated is a test nobody can weigh.
// fakeRegistry serves the two URLs the gate probes. Anything not explicitly present 404s, which is
// the shape a pruned package or an unpushed tag really has.
type fakeRegistry struct {
tagFiles map[string]bool // "<repo>/<tag>/<path>"
packages map[string]bool // "<pkg>/<version>"
status int // when non-zero, every request answers with this instead (E)
// downloadGone models R-287: the version's METADATA still answers while the file itself is
// gone. Keyed "<pkg>/<version>"; consulted only on the download route.
downloadGone map[string]bool
// shaless models a version whose metadata answers but carries no sha256.
shaless map[string]bool
hits int
}
func (f *fakeRegistry) start(t *testing.T) *gitea.Client {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.hits++
if f.status != 0 {
w.WriteHeader(f.status)
return
}
p := strings.TrimPrefix(r.URL.Path, "/")
// Package METADATA (api/v1/packages/<owner>/generic/<pkg>/<ver>/files) — what
// resolveArtifactSHA calls before the gate ever runs. Serving it keeps these tests about
// the installability gate rather than about sha resolution.
if strings.HasPrefix(p, "api/v1/packages/") && strings.HasSuffix(p, "/files") {
seg := strings.Split(p, "/")
if len(seg) >= 8 && f.packages[seg[5]+"/"+seg[6]] {
sha := strings.Repeat("c", 64)
if f.shaless[seg[5]+"/"+seg[6]] {
sha = ""
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"name":"` + seg[5] + `","sha256":"` + sha + `"}]`))
return
}
w.WriteHeader(http.StatusNotFound)
return
}
// raw/tag: <owner>/<repo>/raw/tag/<tag>/<path...>
if i := strings.Index(p, "/raw/tag/"); i >= 0 {
repo := p[:i]
repo = repo[strings.Index(repo, "/")+1:]
rest := p[i+len("/raw/tag/"):]
slash := strings.Index(rest, "/")
if slash > 0 && f.tagFiles[repo+"/"+rest[:slash]+"/"+rest[slash+1:]] {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
return
}
// packages: api/packages/<owner>/generic/<pkg>/<version>/<file>
if strings.HasPrefix(p, "api/packages/") {
seg := strings.Split(p, "/")
if len(seg) >= 6 && f.packages[seg[4]+"/"+seg[5]] && !f.downloadGone[seg[4]+"/"+seg[5]] {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNotFound)
}))
t.Cleanup(srv.Close)
return gitea.New(srv.URL, "admin", "", "")
}
// healthy is the registry as it should be: agent tagged + published, golden published.
func healthy() *fakeRegistry {
return &fakeRegistry{
tagFiles: map[string]bool{"felhom-agent/v0.128.0/configs/felhom-mkfs-guarded.sh": true},
packages: map[string]bool{"felhom-agent/0.128.0": true, "felhom-golden/0.210.0": true},
}
}
func saveArtifacts(t *testing.T, s *Server) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{
"agent_version": {"0.128.0"},
"golden_version": {"0.210.0"},
"agent_sha256": {strings.Repeat("a", 64)},
"golden_sha256": {strings.Repeat("b", 64)},
}
r := httptest.NewRequest(http.MethodPost, "/configuration/artifacts", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.handleSetArtifacts(w, r)
return w
}
func flashOf(w *httptest.ResponseRecorder) string {
loc := w.Header().Get("Location")
if i := strings.Index(loc, "flash="); i >= 0 {
return loc[i+len("flash="):]
}
return ""
}
func assertUnchanged(t *testing.T, st *store.Store, why string) {
t.Helper()
if m := st.GetArtifactManifest(); m.AgentVersion != "" || m.GoldenVersion != "" {
t.Fatalf("%s: manifest was WRITTEN anyway: %+v", why, m)
}
}
// A — the tag is missing and the package is there. THE ONE THAT MATTERS: this is 2026-08-09 exactly.
// WRONG OUTCOME: saved, and every install dies five steps in, as root, on a virgin machine.
func TestInstallability_A_TagMissing(t *testing.T) {
s, st := newTestServer(t)
fr := healthy()
fr.tagFiles = map[string]bool{} // published, never tagged
s.SetGiteaClient(fr.start(t))
w := saveArtifacts(t, s)
if got := flashOf(w); got != "artifact_tag_missing" {
t.Fatalf("flash = %q, want artifact_tag_missing — Friday's defect is not being caught", got)
}
assertUnchanged(t, st, "A")
}
// B — the tag is there and the package was pruned out from under it (R-287).
// WRONG OUTCOME: saved, and the installer 404s on the binary itself.
func TestInstallability_B_PackagePruned(t *testing.T) {
s, st := newTestServer(t)
fr := healthy()
// R-287's real shape: metadata still answers, the FILE does not. Modelled with a separate
// download map so the sha resolver succeeds and the gate's download leg is what convicts.
fr.downloadGone = map[string]bool{"felhom-agent/0.128.0": true}
s.SetGiteaClient(fr.start(t))
w := saveArtifacts(t, s)
if got := flashOf(w); got != "artifact_pkg_missing" {
t.Fatalf("flash = %q, want artifact_pkg_missing", got)
}
assertUnchanged(t, st, "B")
}
// C — both artifacts are installable, but the registry can produce no checksum for one of them.
// The manifest must not record a version with an empty sha: that is vouching bytes nobody verified.
//
// NOTE ON WHERE THIS IS ENFORCED, because the first draft of this test was wrong. With a Gitea
// client configured, resolveArtifactSHA fetches the sha AUTHORITATIVELY and ignores anything
// submitted — so a blank field in the form cannot produce a blank stored sha, and the scenario as
// originally written ("submit an empty sha") modelled nothing real. The genuine shape is Gitea
// answering with no sha256, and the EXISTING refusal owns it. This test pins the guarantee rather
// than the mechanism: whatever refuses, the manifest must be unchanged.
// WRONG OUTCOME: the hub vouches a checksum for bytes it never saw.
func TestInstallability_C_NoChecksum(t *testing.T) {
s, st := newTestServer(t)
fr := healthy()
fr.shaless = map[string]bool{"felhom-agent/0.128.0": true} // metadata answers, with no sha256
s.SetGiteaClient(fr.start(t))
w := saveArtifacts(t, s)
if got := flashOf(w); got == "artifacts_set" {
t.Fatalf("a version with no resolvable checksum SAVED — the hub vouched bytes nobody verified")
}
assertUnchanged(t, st, "C")
}
// D — everything correct. The gate must be invisible on a good approval.
// WRONG OUTCOME: a new obstacle in front of a correct operator action.
func TestInstallability_D_HappyPathStillSaves(t *testing.T) {
s, st := newTestServer(t)
s.SetGiteaClient(healthy().start(t))
w := saveArtifacts(t, s)
if got := flashOf(w); got != "artifacts_set" {
t.Fatalf("flash = %q, want artifacts_set — a correct approval was blocked", got)
}
m := st.GetArtifactManifest()
if m.AgentVersion != "0.128.0" || m.GoldenVersion != "0.210.0" {
t.Fatalf("manifest not saved on the happy path: %+v", m)
}
}
// E — the registry cannot answer. It must refuse, and it must say "could not verify" rather than
// "missing": those are different facts and the operator acts differently on each.
// WRONG OUTCOME: saved with a warning. A warning beside a success is read as a success.
func TestInstallability_E_RegistryUnreachable(t *testing.T) {
s, st := newTestServer(t)
fr := healthy()
fr.status = http.StatusBadGateway
s.SetGiteaClient(fr.start(t))
w := saveArtifacts(t, s)
got := flashOf(w)
if got == "artifacts_set" {
t.Fatalf("saved while the registry was unreachable — the warning-beside-a-success shape")
}
if got != "artifact_unverifiable" {
t.Fatalf("flash = %q, want artifact_unverifiable — an undetermined result must not be "+
"reported as a missing artifact", got)
}
assertUnchanged(t, st, "E")
}
// The gate must not fire when there is no registry client at all (the legacy manual path), or a
// hub with Gitea unconfigured could never vouch anything.
func TestInstallability_NoGiteaClient_StillSaves(t *testing.T) {
s, st := newTestServer(t)
w := saveArtifacts(t, s)
if got := flashOf(w); got != "artifacts_set" {
t.Fatalf("flash = %q, want artifacts_set with no Gitea client configured", got)
}
if st.GetArtifactManifest().AgentVersion != "0.128.0" {
t.Fatal("manifest not saved on the no-client path")
}
}
// The probe must assert the path the INSTALLER fetches. A gate that probes some other path that
// merely exists is how 2026-08-09 stayed invisible.
func TestInstallability_ProbesTheInstallersOwnPath(t *testing.T) {
if installerProbeConfig != "configs/felhom-mkfs-guarded.sh" {
t.Fatalf("probe path = %q; it must be the FIRST config felhom-host-install.sh fetches "+
"(step 5/8), which is the file whose 404 broke every install on 2026-08-09",
installerProbeConfig)
}
}