package selfupdate import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "io" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "sync" "testing" "gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs" ) // fakeWrapper records the verbs the executor/manager shell out, and returns a configurable error. type fakeWrapper struct { mu sync.Mutex calls [][]string err error } func (f *fakeWrapper) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) { f.mu.Lock() defer f.mu.Unlock() f.calls = append(f.calls, append([]string{name}, args...)) return []byte("ok"), nil, f.err } func (f *fakeWrapper) applyCalls() [][]string { f.mu.Lock() defer f.mu.Unlock() var out [][]string for _, c := range f.calls { if len(c) >= 2 && c[1] == "apply" { out = append(out, c) } } return out } func sha256Of(b []byte) string { h := sha256.Sum256(b) return hex.EncodeToString(h[:]) } // artifactServer serves `body` at /…/{version}/felhom-agent. func artifactServer(t *testing.T, body []byte) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(body) })) } func newExec(t *testing.T, srv *httptest.Server, wrap WrapperRunner) (*Executor, string) { t.Helper() stateDir := t.TempDir() e := NewExecutor(Config{ URLTemplate: srv.URL + "/{version}/felhom-agent", StateDir: stateDir, Runner: wrap, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), }) return e, stateDir } func updateParamsJSON(t *testing.T, version, sha string) json.RawMessage { t.Helper() b, _ := json.Marshal(map[string]string{"version": version, "sha256": sha}) return b } // Scenario A (executor half): a good download whose sha matches the signed value is staged and // handed to `apply` with the EXACT staged path + sha. func TestExecutor_HappyPath(t *testing.T) { body := []byte("#!/bin/sh\necho v0.70.1\n") srv := artifactServer(t, body) defer srv.Close() wrap := &fakeWrapper{} e, stateDir := newExec(t, srv, wrap) sha := sha256Of(body) if err := e.Execute(context.Background(), "agent_update", updateParamsJSON(t, "0.70.1", sha)); err != nil { t.Fatalf("execute: %v", err) } staged := filepath.Join(stateDir, "selfupdate", "felhom-agent-0.70.1") got, err := os.ReadFile(staged) if err != nil || sha256Of(got) != sha { t.Fatalf("staged binary missing/mismatch: %v", err) } calls := wrap.applyCalls() if len(calls) != 1 { t.Fatalf("apply invoked %d times, want 1 (%v)", len(calls), wrap.calls) } if calls[0][2] != staged || calls[0][3] != sha { t.Errorf("apply args = %v, want [.. apply %s %s]", calls[0], staged, sha) } } // Scenario C2 + its companion: a download whose sha != the signed value is REFUSED, nothing is // handed to apply, and the staged file is removed. The companion (dropping the Go-side verify) is // structural: the sha check IS the code under test — if it were removed, this bad binary would // reach the apply call (asserted here: applyCalls == 0). func TestExecutor_ShaMismatchRefused(t *testing.T) { body := []byte("the REAL published bytes") srv := artifactServer(t, body) defer srv.Close() wrap := &fakeWrapper{} e, stateDir := newExec(t, srv, wrap) wrongSha := sha256Of([]byte("what the operator signed for a DIFFERENT binary")) err := e.Execute(context.Background(), "agent_update", updateParamsJSON(t, "0.70.1", wrongSha)) if err == nil { t.Fatal("expected sha-mismatch refusal, got nil") } if len(wrap.applyCalls()) != 0 { t.Error("a sha-mismatched binary REACHED the apply call — the verify gate leaked") } if _, statErr := os.Stat(filepath.Join(stateDir, "selfupdate", "felhom-agent-0.70.1")); !os.IsNotExist(statErr) { t.Error("staged file was left behind after a sha mismatch") } } // Bad params / non-semver version / non-hex sha are refused before any download or apply. func TestExecutor_BadParamsRefused(t *testing.T) { wrap := &fakeWrapper{} e, _ := newExec(t, artifactServer(t, []byte("x")), wrap) for name, p := range map[string]json.RawMessage{ "non-semver version": updateParamsJSON(t, "latest", sha256Of([]byte("x"))), "non-hex sha": updateParamsJSON(t, "0.70.1", "NOTHEX"), "bad json": json.RawMessage(`{`), } { if err := e.Execute(context.Background(), "agent_update", p); err == nil { t.Errorf("%s: expected refusal", name) } } if len(wrap.calls) != 0 { t.Errorf("wrapper invoked on bad params: %v", wrap.calls) } } // A non-owned op class returns ErrNoExecutor (the chain contract) — never touches anything. func TestExecutor_NotOurOp(t *testing.T) { e, _ := newExec(t, artifactServer(t, []byte("x")), &fakeWrapper{}) if err := e.Execute(context.Background(), "storage_wipe", json.RawMessage(`{}`)); !errors.Is(err, signedjobs.ErrNoExecutor) { t.Errorf("err = %v, want ErrNoExecutor", err) } } // A wrapper apply failure surfaces (the executor reports it — the runner then logs + clears). func TestExecutor_WrapperFailureSurfaces(t *testing.T) { body := []byte("good bytes") srv := artifactServer(t, body) defer srv.Close() wrap := &fakeWrapper{err: errors.New("wrapper refused: sha mismatch")} e, _ := newExec(t, srv, wrap) if err := e.Execute(context.Background(), "agent_update", updateParamsJSON(t, "0.70.1", sha256Of(body))); err == nil { t.Fatal("wrapper failure must surface") } }