package osupdate import ( "context" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "os" "strings" "testing" "gitea.dooplex.hu/admin/felhom-agent/internal/reconcile" "gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs" ) // bundleWrapper plays felhom-os-apply for mode "bundle": it records the plan and the bundle file's bytes AT CALL TIME // (the executor deletes the file afterwards), and answers with rep. type bundleWrapper struct { t *testing.T rep string plans []map[string]any bodies [][]byte } func (b *bundleWrapper) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) { if name != WrapperPath || len(args) != 2 || args[0] != "--plan" { b.t.Fatalf("unexpected command %s %v", name, args) } raw, err := os.ReadFile(args[1]) if err != nil { b.t.Fatal(err) } var plan map[string]any _ = json.Unmarshal(raw, &plan) b.plans = append(b.plans, plan) body, _ := os.ReadFile(plan["bundle"].(string)) b.bodies = append(b.bodies, body) return []byte("OSAPPLY-REPORT " + b.rep + "\n"), nil, nil } func serveBundle(t *testing.T, body []byte) (*httptest.Server, string) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/felhom-agent/0.143.0/"+BundleFileName { http.NotFound(w, r) return } _, _ = w.Write(body) })) t.Cleanup(srv.Close) sum := sha256.Sum256(body) return srv, hex.EncodeToString(sum[:]) } func bundleExec(t *testing.T, w *bundleWrapper, srvURL string) (ConfigUpdateExecutor, *bool) { l, _ := newLeg(t, &fakeWrapper{t: t}, nil) l.Runner = w called := false return ConfigUpdateExecutor{Leg: l, URLTemplate: srvURL + "/felhom-agent/{version}/felhom-agent", AfterInstall: func(context.Context) { called = true }}, &called } func signedCtx() context.Context { return signedjobs.WithSignedOp(context.Background(), &reconcile.SignedOp{Blob: []byte(`{"op":"agent_config_update"}`), Sig: []byte("SIG")}) } func params(v, sha string) json.RawMessage { p, _ := json.Marshal(ConfigUpdateParams{AgentVersion: v, BundleSHA256: sha}) return p } // The courier hands the wrapper the bundle bytes it downloaded and the RAW signed envelope (the wrapper verifies both // itself), then runs the capability probe. Red-proof: drop "signed" from the plan → the plan check below fails. func TestConfigUpdate_PassesBundleAndEnvelopeToTheWrapper(t *testing.T) { body := []byte(`{"format":1,"agent_version":"0.143.0","files":[]}`) srv, sha := serveBundle(t, body) w := &bundleWrapper{t: t, rep: `{"mode":"bundle","bundle":{"agent_version":"0.143.0","written":["/etc/sudoers.d/felhom-agent"]}}`} e, called := bundleExec(t, w, srv.URL) if err := e.Execute(signedCtx(), OpConfigUpdate, params("0.143.0", sha)); err != nil { t.Fatal(err) } p := w.plans[0] sg, _ := p["signed"].(map[string]any) if p["mode"] != "bundle" || p["layer"] != "host" || sg == nil || sg["sig"] != "SIG" || sg["blob_b64"] != base64.StdEncoding.EncodeToString([]byte(`{"op":"agent_config_update"}`)) { t.Fatalf("plan = %v", p) } if string(w.bodies[0]) != string(body) || !strings.HasSuffix(p["bundle"].(string), "/bundle-0.143.0.json") { t.Fatalf("the wrapper got %q at %v", w.bodies[0], p["bundle"]) } if !*called { t.Fatal("the capability probe must run after an install") } if _, err := os.Stat(p["bundle"].(string)); !os.IsNotExist(err) { t.Fatal("the downloaded bundle must be removed after the call") } } func TestConfigUpdate_WrongShaNeverReachesTheWrapper(t *testing.T) { srv, _ := serveBundle(t, []byte("tampered")) w := &bundleWrapper{t: t} e, called := bundleExec(t, w, srv.URL) err := e.Execute(signedCtx(), OpConfigUpdate, params("0.143.0", strings.Repeat("a", 64))) if err == nil || len(w.plans) != 0 || *called { t.Fatalf("err=%v plans=%d", err, len(w.plans)) } } func TestConfigUpdate_RefusedAndFailedAreErrors(t *testing.T) { for _, rep := range []string{`{"refused":{"code":"R17","reason":"trust root"}}`, `{"failed":{"rc":3},"bundle":{"rolled_back":["/x"]}}`} { srv, sha := serveBundle(t, []byte("{}")) w := &bundleWrapper{t: t, rep: rep} e, called := bundleExec(t, w, srv.URL) if err := e.Execute(signedCtx(), OpConfigUpdate, params("0.143.0", sha)); err == nil || *called { t.Fatalf("%s: err=%v called=%v", rep, err, *called) } } } func TestConfigUpdate_GuardsBeforeAnyDownload(t *testing.T) { w := &bundleWrapper{t: t} e, _ := bundleExec(t, w, "http://127.0.0.1:1") if err := e.Execute(signedCtx(), "agent_update", nil); !errors.Is(err, signedjobs.ErrNoExecutor) { t.Fatalf("another op must pass through the chain: %v", err) } if err := e.Execute(context.Background(), OpConfigUpdate, params("0.143.0", strings.Repeat("a", 64))); err == nil { t.Fatal("no envelope must refuse") } for _, p := range []json.RawMessage{params("0.143", strings.Repeat("a", 64)), params("0.143.0", "ABC"), json.RawMessage(`nope`)} { if err := e.Execute(signedCtx(), OpConfigUpdate, p); err == nil { t.Fatalf("bad params %s must refuse", p) } } if len(w.plans) != 0 { t.Fatal("no wrapper call on a refusal") } } func TestBundleURL(t *testing.T) { u, err := BundleURL("https://gitea.dooplex.hu/api/packages/admin/generic/felhom-agent/{version}/felhom-agent", "0.143.0") if err != nil || u != "https://gitea.dooplex.hu/api/packages/admin/generic/felhom-agent/0.143.0/felhom-config-bundle.json" { t.Fatalf("%s %v", u, err) } if _, err := BundleURL("https://example/felhom-agent-{version}.bin", "0.143.0"); err == nil { t.Fatal("an underivable template must refuse") } } func (b *bundleWrapper) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) { return b.Run(ctx, name, args...) }