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 // "//" packages map[string]bool // "/" 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 "/"; 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//generic///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: //raw/tag// 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//generic/// 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) } }