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