Files
felhom-controller/controller/internal/agentapi/refusal_test.go
T
admin c997d79246 feat(agentapi): surface agent disk-op refusal reasons; v0.101.0 + CHANGELOG/REPORT
EjectDisk/Decommission switched from c.post (drops non-2xx body) to
postWithStatus + shared refusalError, so the agent's informative 403 body
("…decommission refused (role: X)") reaches the operator instead of a bare
"HTTP 403" (campaign F2 evidence gap). Generic post + other callers untouched.
Tests T-D1/T-D2/T-D3 + ok:false case; T-D1 red-proof shows the pre-fix bare
"HTTP 403". Bundles the v0.101.0 CHANGELOG entry (this + the F3 sync deadline).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-06 14:11:44 +02:00

101 lines
3.7 KiB
Go

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)
}
}