package agentapi import ( "context" "net/http" "net/http/httptest" "strings" "testing" ) // Campaign F2 evidence gap: the agent's 403 refusal body carries the informative reason // (agent disks.go handleDiskDecommission — "…decommission refused (role: X)"), but the old // c.post discarded any non-2xx body, so operators saw a bare // "agentapi: POST /disks/decommission: HTTP 403". These tests pin the reason surfacing. func refusalStub(t *testing.T) (*httptest.Server, string) { t.Helper() mux := http.NewServeMux() mux.HandleFunc("POST /disks/decommission", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`{"ok":false,"error":"mount is system/backup-protected — decommission refused (role: system)"}`)) }) mux.HandleFunc("POST /disks/eject", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`{"ok":false,"error":"mount is system/backup-protected — eject refused (role: backup)"}`)) }) s := httptest.NewTLSServer(mux) return s, strings.TrimPrefix(s.URL, "https://") } // T-D1: Decommission surfaces the agent's refusal reason, not a bare HTTP code. // RED-PROOF: on the pre-fix c.post shape the error is exactly // "agentapi: POST /disks/decommission: HTTP 403" → the Contains assertion FAILS. func TestDecommission_RefusalReasonSurfaced(t *testing.T) { s, ep := refusalStub(t) defer s.Close() c := clientFor(t, s, ep) _, err := c.Decommission(context.Background(), "/mnt/sys_drive") if err == nil { t.Fatal("expected the 403 refusal to be an error") } if !strings.Contains(err.Error(), "(role: system)") { t.Fatalf("agent refusal reason discarded — operator sees only: %v", err) } if !strings.Contains(err.Error(), "HTTP 403") { t.Fatalf("HTTP status lost from the error: %v", err) } } // T-D2: same surfacing for EjectDisk. func TestEjectDisk_RefusalReasonSurfaced(t *testing.T) { s, ep := refusalStub(t) defer s.Close() c := clientFor(t, s, ep) _, err := c.EjectDisk(context.Background(), "/mnt/felhom-flash") if err == nil { t.Fatal("expected the 403 refusal to be an error") } if !strings.Contains(err.Error(), "(role: backup)") { t.Fatalf("agent refusal reason discarded — operator sees only: %v", err) } } // T-D3: the success path is unchanged — a 200 envelope still decodes into the result struct. // (EjectDisk success is covered by TestEject_Dependents; this pins Decommission.) func TestDecommission_SuccessUnchanged(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("POST /disks/decommission", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":8200,"decommissioned":"/mnt/bulk","dependent_guests":[8200]}}`)) }) s := httptest.NewTLSServer(mux) defer s.Close() c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://")) out, err := c.Decommission(context.Background(), "/mnt/bulk") if err != nil { t.Fatal(err) } if out.Decommissioned != "/mnt/bulk" || len(out.DependentGuests) != 1 { t.Fatalf("success payload mis-decoded: %+v", out) } } // A 2xx envelope with ok:false (business refusal without an HTTP error) must also carry the reason. func TestDecommission_OkFalseBusinessRefusal(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("POST /disks/decommission", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"ok":false,"error":"drive is busy: unmount blocked by open files"}`)) }) s := httptest.NewTLSServer(mux) defer s.Close() c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://")) _, err := c.Decommission(context.Background(), "/mnt/bulk") if err == nil || !strings.Contains(err.Error(), "unmount blocked by open files") { t.Fatalf("ok:false reason not surfaced: %v", err) } }