246 lines
9.4 KiB
Go
246 lines
9.4 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 = ""
|
|
}
|
|
name := seg[5] // the agent's file is named after its package …
|
|
if name == pkgGolden {
|
|
name = fileGolden // … the golden's is not (R-1011: the hub now asks for exactly that file)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`[{"name":"` + name + `","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)
|
|
}
|
|
}
|