diff --git a/CLAUDE.md b/CLAUDE.md index 0408594338..021bf844fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -762,11 +762,18 @@ therefore has exactly three routing shapes, mirroring the entire.io BFF: when the skips left no cell to query), never substituted with a ready mirror. -Token rule: identity tokens are **per-jurisdiction, not per-cell**. Multi-cell -callers must build one `auth.CellClientFactory` -(`NewEntireAPICellClientFactory`) per operation — it resolves the login -subject once and mints at most one token per jurisdiction. `fanOutCells` does -this automatically; do not call `NewEntireAPICellClient` in a loop. +Token rule: cell clients carry the **login JWT itself**, not a minted +identity token — the per-jurisdiction exchange they used to do was removed in +`3df7ea461` ("Use login JWTs directly for cell auth"), so `CellTarget`'s +jurisdiction now selects only the *host*, never an audience. Multi-cell +callers must still build one `auth.CellClientFactory` +(`NewEntireAPICellClientFactory`) per operation — it resolves and refreshes +the login subject once instead of per cell. `fanOutCells` does this +automatically; do not call `NewEntireAPICellClient` in a loop. The minting +path survives only as `auth.JurisdictionToken`, behind +`entire auth token --jurisdiction` (a scripting helper for the cells that DO +require `aud=`); do not infer from it what the CLI's own +data-plane requests carry. ### Session Strategy (`cmd/entire/cli/strategy/`) diff --git a/cmd/entire/cli/cell_fanout.go b/cmd/entire/cli/cell_fanout.go index 482fbd5e8e..f0d45a00ab 100644 --- a/cmd/entire/cli/cell_fanout.go +++ b/cmd/entire/cli/cell_fanout.go @@ -382,9 +382,11 @@ type cellClientBuilder interface { ClientFor(ctx context.Context, target *auth.CellTarget) (*api.Client, error) } -// newCellClientBuilder builds the per-operation cell client factory: the -// subject is resolved once and identity tokens are minted once per -// jurisdiction, however many cells the fan-out touches. Swapped in tests. +// newCellClientBuilder builds the per-operation cell client factory: the login +// subject is resolved (and refreshed) once, however many cells the fan-out +// touches, and every cell client carries that login JWT directly — the +// per-jurisdiction identity-token exchange this used to do was removed in +// 3df7ea461. Swapped in tests. var newCellClientBuilder = func(ctx context.Context, insecureHTTP bool) (cellClientBuilder, error) { return auth.NewEntireAPICellClientFactory(ctx, insecureHTTP) } diff --git a/cmd/entire/cli/search/search.go b/cmd/entire/cli/search/search.go index 0822272940..0dace164a4 100644 --- a/cmd/entire/cli/search/search.go +++ b/cmd/entire/cli/search/search.go @@ -21,8 +21,8 @@ const apiTimeout = 30 * time.Second // v4ServicePath is the per-repo v4 query-serve route exposed by the entire-api // cell gateway. It takes repo=. The BFF (entire.io /api/v1/search) -// forwards to this same path; the CLI dials the cell directly with a -// jurisdictional identity token, skipping the BFF hop. +// forwards to this same path; the CLI dials the cell directly with the +// caller's login JWT, skipping the BFF hop. const v4ServicePath = "/api/v1/semantic-search/search/v1/search" // ErrCellUnavailable reports that a cell's gateway does not expose the @@ -64,6 +64,18 @@ type MalformedResponseError struct { func (e *MalformedResponseError) Error() string { return e.Message } +// ErrCellUnauthorized reports that a cell answered the semantic-search route +// but refused the caller's bearer (HTTP 401, or 403 for a credential the +// service verified and then declined). The login is not the suspect it looks +// like: cell clients carry the login JWT itself (3df7ea461), so the very same +// bearer is the one code search accepts at the same host — a rejection here is +// this service declining a credential, not a session the user can refresh. +// Distinct from ErrCellUnavailable because the two demand opposite things of +// the user: wait for the query-serve rollout versus report a rejection that +// will not age out. Never report one as the other (entireio/cli#2121, +// entirehq/entire-search#196). +var ErrCellUnauthorized = errors.New("semantic search rejected the caller's credentials in this cell") + // WildcardQuery is the query string used when only filters are provided (no search terms). const WildcardQuery = "*" @@ -734,8 +746,8 @@ func AppendUnique(existing []string, values ...string) []string { } // CellV4 performs a v4 query-serve search against a single entire-api -// cell, via the pre-authenticated client (bearer = jurisdictional identity -// token; host = the cell). repoIDs are repo ULIDs to scope to (the v4 route is +// cell, via the pre-authenticated client (bearer = the caller's login JWT, +// which is what cell clients carry since 3df7ea461; host = the cell). repoIDs are repo ULIDs to scope to (the v4 route is // per-repo and keys on ULIDs, not owner/name slugs); an empty repoIDs means // "every repo the caller can access in this cell" — query-serve fans out across // those namespaces itself. The cross-cell fan-out and merge live in the cli @@ -787,6 +799,15 @@ func CellV4(ctx context.Context, client *api.Client, cfg Config, repoIDs []strin } return nil, ErrCellUnavailable } + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + // Add the sentinel WITHOUT dropping the HTTPStatusError + // parseSearchResponse would have returned: its status is what outcome + // telemetry classifies as auth (ENT-1938), and its wording is what + // callers and tests pin. Two %w verbs keep both matchable — errors.Is + // for the sentinel, errors.As for the status. + _, statusErr := parseSearchResponse(resp.StatusCode, body) + return nil, fmt.Errorf("%w: %w", ErrCellUnauthorized, statusErr) + } return parseSearchResponse(resp.StatusCode, body) } diff --git a/cmd/entire/cli/search/search_test.go b/cmd/entire/cli/search/search_test.go index 0faa61fcc9..4768b1b21d 100644 --- a/cmd/entire/cli/search/search_test.go +++ b/cmd/entire/cli/search/search_test.go @@ -133,30 +133,89 @@ func TestParseGitHubRemote_EntireMirror(t *testing.T) { // -- Search() tests -- +// TestCellV4_Unauthorized covers the credential-rejection classification: a +// cell that answers the route but refuses the token is ErrCellUnauthorized, so +// the fan-out can say so instead of leaking a bare "search service error +// (401): Unauthorized" (entireio/cli#2121). 403 joins it — the service +// verifies the token and then declines it — while the status and the service's +// own message stay in the text — and the typed status stays in the chain — for +// debug logs and outcome telemetry. +func TestCellV4_Unauthorized(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + status int + body string + want string + }{ + {name: "401 with json error", status: http.StatusUnauthorized, body: `{"error":"Unauthorized"}`, want: "search service error (401): Unauthorized"}, + {name: "403 with json error", status: http.StatusForbidden, body: `{"error":"Forbidden"}`, want: "search service error (403): Forbidden"}, + {name: "401 with raw body", status: http.StatusUnauthorized, body: "authorization required", want: "search service returned 401: authorization required"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.status) + w.Write([]byte(tc.body)) //nolint:errcheck // test helper response + })) + defer srv.Close() + + _, err := CellV4(context.Background(), api.NewClientWithBaseURL("tok", srv.URL), Config{Query: "q"}, nil) + if !errors.Is(err, ErrCellUnauthorized) { + t.Fatalf("error = %v, want ErrCellUnauthorized", err) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("error = %q, want it to contain %q", err.Error(), tc.want) + } + // A rejected credential must never read as a cell that lacks the + // route: the two need opposite responses from the user. + if errors.Is(err, ErrCellUnavailable) { + t.Error("ErrCellUnauthorized must not also match ErrCellUnavailable") + } + // The typed status must survive alongside the sentinel — outcome + // telemetry classifies auth from it (ENT-1938). + var statusErr *HTTPStatusError + if !errors.As(err, &statusErr) { + t.Fatal("want an *HTTPStatusError in the chain") + } + if statusErr.StatusCode != tc.status { + t.Errorf("StatusCode = %d, want %d", statusErr.StatusCode, tc.status) + } + }) + } +} + +// TestCellV4_ErrorJSON pins the generic non-2xx wording, which every status +// outside the classified ones (404 route/repo, 401/403 credentials) still +// gets. func TestCellV4_ErrorJSON(t *testing.T) { t.Parallel() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusUnauthorized) - json.NewEncoder(w).Encode(map[string]string{"error": "Invalid token"}) //nolint:errcheck // test helper response + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "Search failed"}) //nolint:errcheck // test helper response })) defer srv.Close() _, err := CellV4(context.Background(), api.NewClientWithBaseURL("tok", srv.URL), Config{Query: "q"}, nil) if err == nil { - t.Fatal("expected error for 401") + t.Fatal("expected error for 500") + } + if got := err.Error(); got != "search service error (500): Search failed" { + t.Errorf("error = %q, want 'search service error (500): Search failed'", got) } - if got := err.Error(); got != "search service error (401): Invalid token" { - t.Errorf("error = %q, want 'search service error (401): Invalid token'", got) + if errors.Is(err, ErrCellUnauthorized) { + t.Error("a 500 must not classify as a credential rejection") } // Outcome telemetry classifies by status code, so the error must be typed. var statusErr *HTTPStatusError if !errors.As(err, &statusErr) { t.Fatalf("error is %T, want *HTTPStatusError", err) } - if statusErr.StatusCode != http.StatusUnauthorized { - t.Errorf("StatusCode = %d, want %d", statusErr.StatusCode, http.StatusUnauthorized) + if statusErr.StatusCode != http.StatusInternalServerError { + t.Errorf("StatusCode = %d, want %d", statusErr.StatusCode, http.StatusInternalServerError) } } diff --git a/cmd/entire/cli/search_telemetry.go b/cmd/entire/cli/search_telemetry.go index 9d30594df3..9e533927ad 100644 --- a/cmd/entire/cli/search_telemetry.go +++ b/cmd/entire/cli/search_telemetry.go @@ -24,6 +24,12 @@ func classifySearchError(err error) string { return telemetry.SearchErrClassAuth case errors.Is(err, auth.ErrNoCellForJurisdiction): return telemetry.SearchErrClassCellSkip + case errors.Is(err, search.ErrCellUnauthorized): + // A cell that answered and refused the bearer. The 401/403 + // HTTPStatusError is still in the chain (and would classify the same + // way), but keep the sentinel case: it fixes the class even for a + // rejection that arrives without one. + return telemetry.SearchErrClassAuth case errors.Is(err, search.ErrCellUnavailable), errors.Is(err, errNoRegionAvailable): return telemetry.SearchErrClassRegionUnavailable case errors.Is(err, search.ErrRepoFilterUnmatched), errors.Is(err, errNoRepoAvailable): diff --git a/cmd/entire/cli/search_telemetry_test.go b/cmd/entire/cli/search_telemetry_test.go index 6a9dbb18d0..b4d97054de 100644 --- a/cmd/entire/cli/search_telemetry_test.go +++ b/cmd/entire/cli/search_telemetry_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/url" + "strings" "testing" "github.com/entireio/cli/cmd/entire/cli/api" @@ -78,7 +79,7 @@ func TestClassifySemanticCells_SkipCausesClassify(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - pages, _, lastErr := classifySemanticCells(context.Background(), tc.results) + pages, _, _, lastErr := classifySemanticCells(context.Background(), tc.results) if len(pages) != 0 || lastErr == nil { t.Fatalf("pages = %d, lastErr = %v; want no pages and an error", len(pages), lastErr) } @@ -92,6 +93,35 @@ func TestClassifySemanticCells_SkipCausesClassify(t *testing.T) { } } +// Pins the same contract for a rejected credential: the user-facing message +// swaps in (naming the regions, no bare "search service error (401)"), while +// the chain still classifies as auth rather than falling through to "other" — +// which is what a plain fmt.Errorf here would have done. +func TestClassifySemanticCells_UnauthorizedClassifiesAsAuth(t *testing.T) { + t.Parallel() + cell := func(name string, err error) cellCallResult[*search.Response] { + return cellCallResult[*search.Response]{group: cellGroup{cell: name}, err: err} + } + unauthorized := fmt.Errorf("%w: %w", search.ErrCellUnauthorized, &search.HTTPStatusError{StatusCode: 401, Message: "search service error (401): Unauthorized"}) + + pages, _, unauth, lastErr := classifySemanticCells(context.Background(), []cellCallResult[*search.Response]{ + cell("aws-eu-central-1", unauthorized), + cell("aws-us-east-2", unauthorized), + }) + if len(pages) != 0 || lastErr == nil { + t.Fatalf("pages = %d, lastErr = %v; want no pages and an error", len(pages), lastErr) + } + if len(unauth) != 2 { + t.Errorf("unauthorized cells = %v, want both named", unauth) + } + if got := lastErr.Error(); !strings.Contains(got, "aws-eu-central-1") || strings.Contains(got, "search service error") { + t.Errorf("user-facing message = %q, want the regions named and the raw service error gone", got) + } + if got := classifySearchError(lastErr); got != telemetry.SearchErrClassAuth { + t.Errorf("classifySearchError = %q, want %q", got, telemetry.SearchErrClassAuth) + } +} + // hintError must swap the user-facing message without truncating the typed // chain telemetry classifies from. func TestHintErrorPreservesMessageAndChain(t *testing.T) { diff --git a/cmd/entire/cli/search_v4.go b/cmd/entire/cli/search_v4.go index 49e4435e22..65654ed1b8 100644 --- a/cmd/entire/cli/search_v4.go +++ b/cmd/entire/cli/search_v4.go @@ -63,9 +63,10 @@ func loginHintErr(err error) error { // path. Control-plane discovery (the repo index, per-slug repo lookups, the // cluster catalog) is stable for the life of one command, so it is resolved // once and reused across TUI re-searches and pagination instead of paying -// several network round trips per keystroke-search. Identity tokens are NOT -// cached here — fanOutCells mints them per search (at most one per -// jurisdiction), which keeps expiry handling in the auth layer. +// several network round trips per keystroke-search. The bearer is NOT cached +// here — fanOutCells resolves it per search (cell clients carry the login +// JWT, refreshed by the auth layer, since 3df7ea461), which keeps expiry +// handling where it belongs. type semanticSearchV4Session struct { insecureHTTP bool @@ -383,15 +384,26 @@ type semanticCellPage struct { // it can't serve the search yet: its gateway has no query-serve route, or the // cluster catalog doesn't expose the placement's jurisdiction at all (a cell // mid-onboarding). Neither is worth warning the user about on every search. -func classifySemanticCells(ctx context.Context, results []cellCallResult[*search.Response]) (pages []semanticCellPage, failed []string, lastErr error) { +// +// Cells that refused the caller's bearer are returned in `unauthorized` as +// well as in `failed`: they are hard failures like any other for coverage +// accounting, but they carry their own message and must never be folded into +// the region or repo explanations, neither of which is true of a rejected +// credential. +func classifySemanticCells(ctx context.Context, results []cellCallResult[*search.Response]) (pages []semanticCellPage, failed, unauthorized []string, lastErr error) { var skipped, unmatched []string var skipErrs []error var unmatchedErr error + var unauthorizedErrs []error for _, r := range results { switch { case errors.Is(r.err, search.ErrCellUnavailable), errors.Is(r.err, auth.ErrNoCellForJurisdiction): skipped = append(skipped, r.group.label()) skipErrs = append(skipErrs, r.err) + case errors.Is(r.err, search.ErrCellUnauthorized): + unauthorized = append(unauthorized, r.group.label()) + failed = append(failed, r.group.label()) + unauthorizedErrs = append(unauthorizedErrs, r.err) case errors.Is(r.err, search.ErrRepoFilterUnmatched): // The cell answered; the repo filter just matched nothing there. // Quiet like a skip when another cell has results, but if NO cell @@ -413,8 +425,22 @@ func classifySemanticCells(ctx context.Context, results []cellCallResult[*search // the sentinel) — keep it visible here for diagnosis. logging.Debug(ctx, "semantic search: cells where the repo filter matched nothing", "unmatched_cells", unmatched, "error", unmatchedErr.Error()) } - if len(pages) == 0 && lastErr == nil { + if len(unauthorized) > 0 { + // The causes carry the status and the service's own message, which + // name the auth stage that refused — the one clue the headline error + // deliberately drops. + logging.Debug(ctx, "semantic search: cells that rejected the caller's bearer", "unauthorized_cells", unauthorized, "error", errors.Join(unauthorizedErrs...).Error()) + } + if len(pages) == 0 { + // Priority when nothing answered, most specific first. A rejected + // credential outranks even a transport/5xx failure elsewhere: it is + // the one outcome that is certain to repeat, and neither the repo nor + // the region message would be true of it. switch { + case len(unauthorized) > 0: + lastErr = errCredentialsRejected(unauthorized, unauthorizedErrs) + case lastErr != nil: + // A real failure already explains the empty result. case len(unmatched) > 0: // Takes priority over the region message: a cell answering proves // its region serves semantic search. @@ -429,7 +455,7 @@ func classifySemanticCells(ctx context.Context, results []cellCallResult[*search lastErr = &hintError{msg: errNoRegionAvailable.Error(), errs: skipErrs} } } - return pages, failed, lastErr + return pages, failed, unauthorized, lastErr } // errNoRepoAvailable is returned when at least one cell answered but none @@ -442,6 +468,30 @@ var errNoRepoAvailable = errors.New("semantic search cannot search this repo yet // errNoRegionAvailable is returned when every queried cell lacks query-serve. var errNoRegionAvailable = errors.New("semantic search is not yet available in the region(s) hosting this search") +// errCredentialsRejected is returned when no cell answered and at least one +// refused the caller's bearer. There is no fix the user can apply — the index +// is server-side and the CLI has no local substitute — so the error buys them +// the only two things left: that retrying and re-authenticating are both dead +// ends (cell clients carry the login JWT, and the same one is working +// elsewhere, so the obvious reflex costs days — see entireio/cli#2121, where +// it did), and the command that turns the dead end into a report someone can +// act on. It deliberately does NOT offer 'entire search --code' as a +// consolation: code search answers a different question, and pointing a user +// looking for session history at file matches reads as a fix while being none. +func errCredentialsRejected(cells []string, causes []error) error { + // hintError, like the region path below: the per-cell causes carry + // search.ErrCellUnauthorized and the 401/403 HTTPStatusError, which is how + // outcome telemetry classifies this as auth rather than "other" + // (ENT-1938). All causes, not just the last, so a mixed fan-out + // classifies by the classifier's precedence instead of cell order. + // + // No "semantic search" prefix: mergeSemanticV4Responses already wraps with + // one, and the older sentinels here double it ("semantic search: semantic + // search is not yet available ..."). + msg := fmt.Sprintf("your credentials were rejected in %s — the search service refused a login that works everywhere else, so retrying and logging in again are both dead ends; 'entire doctor bundle' packages the details for a bug report", strings.Join(cells, ", ")) + return &hintError{msg: msg, errs: causes} +} + // tier0Row pairs a tier-0 result with its rank within the cell that returned // it — the selection order the mixed-capability fallback preserves (see // sortTier0Rows). @@ -670,7 +720,7 @@ func deriveSemanticCounts(merged []search.Result) (int, *search.TypeCounts) { // interleaving is meaningful. All-cells-failed is an error; a partial failure // is noted in Warnings (and the flags) and the surviving cells are merged. func mergeSemanticV4Responses(ctx context.Context, limit, page int, results []cellCallResult[*search.Response]) (*search.Response, error) { - pages, failed, lastErr := classifySemanticCells(ctx, results) + pages, failed, unauthorized, lastErr := classifySemanticCells(ctx, results) if len(pages) == 0 { if lastErr != nil { return nil, fmt.Errorf("semantic search: %w", lastErr) @@ -686,6 +736,12 @@ func mergeSemanticV4Responses(ctx context.Context, limit, page int, results []ce "succeeded", len(pages), "total", len(results), "failed_cells", failed) warnings = append(warnings, fmt.Sprintf("search failed in %d of %d regions; results may be incomplete", len(failed), len(pages)+len(failed))) } + if len(unauthorized) > 0 { + // The count above says coverage is incomplete; this says why and + // where, because a rejected credential is not a transient regional + // failure the next search will shake off. + warnings = append(warnings, fmt.Sprintf("credentials rejected in %s; those regions were not searched", strings.Join(unauthorized, ", "))) + } merged := rankSemanticResults(pages) merged = dedupSemanticResults(merged) diff --git a/cmd/entire/cli/search_v4_test.go b/cmd/entire/cli/search_v4_test.go index 3845e7beb2..2fd53867f8 100644 --- a/cmd/entire/cli/search_v4_test.go +++ b/cmd/entire/cli/search_v4_test.go @@ -164,6 +164,12 @@ func v4CellErr(err error) cellCallResult[*search.Response] { return cellCallResult[*search.Response]{err: err} } +// v4CellErrIn is v4CellErr for a named cell, so tests can assert which region +// a message blames (the zero cellGroup labels itself "home"). +func v4CellErrIn(cell string, err error) cellCallResult[*search.Response] { + return cellCallResult[*search.Response]{group: cellGroup{cell: cell}, err: err} +} + func v4ResultIDs(t *testing.T, results []search.Result) []string { t.Helper() ids := make([]string, len(results)) @@ -622,6 +628,89 @@ func TestMergeSemanticV4Responses_AllCellsUnavailable(t *testing.T) { } } +// TestMergeSemanticV4Responses_AllCellsUnauthorized covers the EU-jurisdiction +// failure that motivated the classification (entireio/cli#2121, +// entirehq/entire-search#196): every cell answers and refuses the token. The +// user must be told their credentials were rejected — not that their region +// lacks semantic search, which would be false and would age out on its own. +func TestMergeSemanticV4Responses_AllCellsUnauthorized(t *testing.T) { + t.Parallel() + + _, err := mergeSemanticV4Responses(context.Background(), 0, 0, []cellCallResult[*search.Response]{ + v4CellErrIn("aws-eu-central-1", fmt.Errorf("%w (HTTP 401): Unauthorized", search.ErrCellUnauthorized)), + v4CellErrIn("aws-us-east-2", fmt.Errorf("%w (HTTP 401): Unauthorized", search.ErrCellUnauthorized)), + }) + if err == nil { + t.Fatal("expected an error when every cell rejected the credentials") + } + got := err.Error() + for _, want := range []string{"credentials were rejected", "aws-eu-central-1", "aws-us-east-2", "doctor bundle"} { + if !strings.Contains(got, want) { + t.Errorf("error = %q, want it to contain %q", got, want) + } + } + if strings.Contains(got, "not yet available") { + t.Errorf("error = %q, must not blame regional availability for a rejected credential", got) + } + if strings.Contains(got, "--code") { + t.Errorf("error = %q, must not offer code search as a fix — it answers a different question", got) + } +} + +// TestMergeSemanticV4Responses_UnauthorizedOutranksOtherExplanations pins the +// priority when nothing answered: a rejected credential is the only outcome +// certain to repeat, so it wins over a transient cell failure, over a +// repo-filter miss elsewhere, and over an undeployed region. +func TestMergeSemanticV4Responses_UnauthorizedOutranksOtherExplanations(t *testing.T) { + t.Parallel() + + _, err := mergeSemanticV4Responses(context.Background(), 0, 0, []cellCallResult[*search.Response]{ + v4CellErrIn("aws-eu-central-1", fmt.Errorf("%w (HTTP 401): Unauthorized", search.ErrCellUnauthorized)), + v4CellErrIn("aws-us-east-2", errors.New("cell down")), + v4CellErrIn("aws-ap-southeast-2", fmt.Errorf("%w: repo not indexed", search.ErrRepoFilterUnmatched)), + v4CellErrIn("aws-eu-west-1", search.ErrCellUnavailable), + }) + if err == nil { + t.Fatal("expected an error when no cell answered") + } + if got := err.Error(); !strings.Contains(got, "credentials were rejected") { + t.Errorf("error = %q, want the credential rejection to outrank the other explanations", got) + } +} + +// TestMergeSemanticV4Responses_PartialUnauthorized covers one cell refusing +// the token while another answers: the results still come back, and the +// warnings say both that coverage is incomplete and which region rejected the +// credentials — a rejection is not a transient regional failure, so the +// generic count alone would understate it. +func TestMergeSemanticV4Responses_PartialUnauthorized(t *testing.T) { + t.Parallel() + + ok := &search.Response{Results: []search.Result{ + v4Ckpt("ok", 1, search.Meta{Score: 0.5}), + }, Total: 1} + + resp, err := mergeSemanticV4Responses(context.Background(), 0, 0, []cellCallResult[*search.Response]{ + v4CellErrIn("aws-eu-central-1", fmt.Errorf("%w (HTTP 401): Unauthorized", search.ErrCellUnauthorized)), + v4CellOK(ok), + }) + if err != nil { + t.Fatal(err) + } + if len(resp.Results) != 1 { + t.Errorf("results = %d, want the answering cell's 1 result", len(resp.Results)) + } + if !resp.Partial || !resp.CoverageIncomplete { + t.Errorf("partial=%v coverage_incomplete=%v, want both true when a cell rejected the credentials", resp.Partial, resp.CoverageIncomplete) + } + joined := strings.Join(resp.Warnings, "\n") + for _, want := range []string{"1 of 2 regions", "credentials rejected in aws-eu-central-1"} { + if !strings.Contains(joined, want) { + t.Errorf("warnings = %v, want one containing %q", resp.Warnings, want) + } + } +} + func TestMergeSemanticV4Responses_AllCellsFail(t *testing.T) { t.Parallel()