feat: agent-capability gate for coupled features — typed StatusError + Supports probe/cache + netstorage add gate (option-1)

- agentapi: non-2xx GETs now surface as typed *StatusError (same text); features.go
  adds Feature/SupportState/SupportCache (route probe, TTL 5m, Yes/No cached,
  Unknown never cached or refused) + Client.Supports
- web: handleNetStorageAdd refuses up front (412, code agent_outdated, Hungarian
  message) when the agent predates /netstorage/verify-status (= pre-0.81 add
  semantics); gate runs BEFORE the single-flight claim; SupportUnknown passes
  through to the existing agent-error paths
- netAddSupport page-render helper lands here; its template consumer follows
- tests: T1 gate refusal (job never starts, slot free), T2 unchanged happy path +
  warm-cache negative assertion, T3 indeterminate never 'too old', T4
  classification incl. the string-match trap, T6 TTL, wire-level 404-typing;
  red-proofs RP1-RP4 run and reverted

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-11 13:45:49 +02:00
parent 5d06ecf374
commit d347dc48d2
6 changed files with 511 additions and 1 deletions
@@ -0,0 +1,164 @@
package agentapi
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// fakeProber scripts the capability probe (the SupportProber seam) and counts calls.
type fakeProber struct {
err error
calls int
}
func (f *fakeProber) NetVerifyStatus(context.Context) (NetVerifyStatus, error) {
f.calls++
return NetVerifyStatus{Phase: "none"}, f.err
}
// --- T4: probe classification — ONLY a typed 404 means "too old" -------------------------------
// Companion red-proof (the classification trap): mutate classifySupportErr to string-match
// "HTTP 404" → the "plain error with 404 text" case classifies No → FAIL. A second mutant treating
// ANY error as SupportNo fails every Unknown case here (and T3 in web).
func TestSupports_Classification(t *testing.T) {
cases := []struct {
name string
err error
want SupportState
}{
{"2xx (nil error)", nil, SupportYes},
{"typed 404", &StatusError{Path: "/netstorage/verify-status", Code: http.StatusNotFound}, SupportNo},
{"typed 404 wrapped", fmt.Errorf("probe: %w", &StatusError{Path: "/x", Code: 404}), SupportNo},
{"typed 401", &StatusError{Path: "/x", Code: http.StatusUnauthorized}, SupportUnknown},
{"typed 500", &StatusError{Path: "/x", Code: http.StatusInternalServerError}, SupportUnknown},
{"typed 502", &StatusError{Path: "/x", Code: http.StatusBadGateway}, SupportUnknown},
{"connection refused", errors.New("dial tcp 10.0.0.9:8443: connect: connection refused"), SupportUnknown},
{"timeout", context.DeadlineExceeded, SupportUnknown},
// The string-matching trap: the OLD untyped error text carries "HTTP 404" but is NOT a
// *StatusError — a classifier that matches the message would wrongly say "too old".
{"plain error with 404 text", errors.New("agentapi: GET /netstorage/verify-status: HTTP 404"), SupportUnknown},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sc := &SupportCache{}
got := sc.Supports(context.Background(), &fakeProber{err: tc.err}, FeatureNetstorageVerify)
if got != tc.want {
t.Errorf("Supports(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}
// Unknown feature names must never refuse (a table gap fails open).
func TestSupports_UnknownFeature(t *testing.T) {
sc := &SupportCache{}
p := &fakeProber{}
if got := sc.Supports(context.Background(), p, Feature("no_such_feature")); got != SupportUnknown {
t.Errorf("unknown feature = %v, want SupportUnknown", got)
}
if p.calls != 0 {
t.Errorf("unknown feature must not probe (calls=%d)", p.calls)
}
}
// --- T6: cache TTL — both polarities cached; Unknown NEVER cached ------------------------------
// Companion red-proof: drop the TTL expiry check (treat every entry as fresh) → the
// "re-probes after TTL" assertion fails. Dropping the cache entirely → the warm-cache negative
// assertion (calls stays 1) fails (T2's red-proof shape).
func TestSupports_CacheTTL(t *testing.T) {
t.Run("positive cached, re-probes after TTL", func(t *testing.T) {
now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
sc := &SupportCache{now: func() time.Time { return now }}
p := &fakeProber{} // nil err → Yes
if got := sc.Supports(context.Background(), p, FeatureNetstorageVerify); got != SupportYes {
t.Fatalf("first = %v, want Yes", got)
}
if got := sc.Supports(context.Background(), p, FeatureNetstorageVerify); got != SupportYes {
t.Fatalf("warm = %v, want Yes", got)
}
if p.calls != 1 { // the NEGATIVE assertion: a warm cache must NOT re-probe
t.Errorf("probe calls on a warm cache = %d, want 1", p.calls)
}
now = now.Add(supportTTL + time.Second)
if got := sc.Supports(context.Background(), p, FeatureNetstorageVerify); got != SupportYes {
t.Fatalf("post-TTL = %v, want Yes", got)
}
if p.calls != 2 {
t.Errorf("probe calls after TTL expiry = %d, want 2 (must re-fire)", p.calls)
}
})
t.Run("negative cached too", func(t *testing.T) {
now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
sc := &SupportCache{now: func() time.Time { return now }}
p := &fakeProber{err: &StatusError{Path: "/x", Code: 404}}
for i := 0; i < 2; i++ {
if got := sc.Supports(context.Background(), p, FeatureNetstorageVerify); got != SupportNo {
t.Fatalf("call %d = %v, want No", i, got)
}
}
if p.calls != 1 {
t.Errorf("negative verdict not cached (calls=%d, want 1)", p.calls)
}
})
t.Run("unknown never cached", func(t *testing.T) {
sc := &SupportCache{}
p := &fakeProber{err: errors.New("connection refused")}
for i := 0; i < 2; i++ {
if got := sc.Supports(context.Background(), p, FeatureNetstorageVerify); got != SupportUnknown {
t.Fatalf("call %d = %v, want Unknown", i, got)
}
}
if p.calls != 2 {
t.Errorf("Unknown must re-probe every call (calls=%d, want 2)", p.calls)
}
})
}
// --- 1.1 wire-level: a real HTTP 404 through Client.get IS the typed StatusError ----------------
// This pins the verify-first finding: an agent without the route (≤0.80's plain mux 404) reaches
// classifySupportErr as *StatusError{404}, end-to-end through the pinned-TLS client.
func TestClient_404IsTypedAndSupportsSaysNo(t *testing.T) {
mux := http.NewServeMux() // NO /netstorage/verify-status route — the ≤0.80 shape
srv := httptest.NewTLSServer(mux)
defer srv.Close()
fp := sha256.Sum256(srv.Certificate().Raw)
c, err := New(strings.TrimPrefix(srv.URL, "https://"), "test-token", hex.EncodeToString(fp[:]))
if err != nil {
t.Fatalf("New: %v", err)
}
_, verr := c.NetVerifyStatus(context.Background())
var se *StatusError
if !errors.As(verr, &se) || se.Code != http.StatusNotFound {
t.Fatalf("404 must surface as *StatusError{404}, got %T: %v", verr, verr)
}
if got := c.Supports(context.Background(), FeatureNetstorageVerify); got != SupportNo {
t.Errorf("Supports on a routeless agent = %v, want SupportNo", got)
}
}
// The supported shape: the route answers the envelope → Supports says Yes.
func TestClient_SupportsYesOnLiveRoute(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/netstorage/verify-status", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"data":{"phase":"none"}}`)) // the no-job envelope
})
srv := httptest.NewTLSServer(mux)
defer srv.Close()
fp := sha256.Sum256(srv.Certificate().Raw)
c, err := New(strings.TrimPrefix(srv.URL, "https://"), "test-token", hex.EncodeToString(fp[:]))
if err != nil {
t.Fatalf("New: %v", err)
}
if got := c.Supports(context.Background(), FeatureNetstorageVerify); got != SupportYes {
t.Errorf("Supports on a live route = %v, want SupportYes", got)
}
}