From 25f82fa151b66415bc7b9c1fa161e10059da99d8 Mon Sep 17 00:00:00 2001 From: Orion Delwaterman Date: Wed, 19 Aug 2026 14:53:01 -0400 Subject: [PATCH 1/3] Add whoami to client; update log redaction --- internal/client/auth/client.go | 68 ++++++------- internal/client/auth/client_test.go | 57 +++++++++++ internal/client/auth/logging_test.go | 25 ++--- internal/logging/http_body_redactor.go | 100 ++++++++++++++++++++ internal/logging/http_body_redactor_test.go | 56 +++++++++++ 5 files changed, 262 insertions(+), 44 deletions(-) create mode 100644 internal/logging/http_body_redactor.go create mode 100644 internal/logging/http_body_redactor_test.go diff --git a/internal/client/auth/client.go b/internal/client/auth/client.go index 575ce6a..d468e5c 100644 --- a/internal/client/auth/client.go +++ b/internal/client/auth/client.go @@ -14,6 +14,7 @@ import ( "github.com/rs/zerolog" "github.com/tollbit/cli/internal/errorsx/problemjson" + "github.com/tollbit/cli/internal/logging" "github.com/tollbit/cli/internal/tokens/agent" "github.com/tollbit/cli/internal/version" ) @@ -120,6 +121,12 @@ type ( Revoked bool `json:"revoked"` } + WhoAmIResponse struct { + AgentIdentifier string `json:"agent_identifier"` + OrganizationName *string `json:"organization_name,omitempty"` + PrimaryEmail *string `json:"primary_email,omitempty"` + } + WebBotAuth struct { Dir string `json:"dir"` Req bool `json:"req"` @@ -169,6 +176,14 @@ const ( grantTypeConsentAgentConfirmsIcons = "consent:agent_confirms_icons" ) +var authLogBodyRedactor = logging.NewHTTPBodyRedactor(logging.HTTPBodyRedactorConfig{ + FullyRedactedPaths: []string{"/agent/v1/whoami"}, + JSONFields: map[string]logging.JSONFieldRedactor{ + "token": logging.AbbreviateJSONSecret(6, 4), + "refresh_token": logging.AbbreviateJSONSecret(6, 4), + }, +}) + func New(cfg ClientConfig) (*Client, error) { baseURL := strings.TrimSpace(cfg.BaseURL) if baseURL == "" { @@ -419,6 +434,19 @@ func (c *Client) RevokeRefreshToken(ctx context.Context, refreshToken string) (R return out, nil } +func (c *Client) WhoAmI(ctx context.Context, token agent.Token) (WhoAmIResponse, error) { + if strings.TrimSpace(token.RawToken) == "" { + return WhoAmIResponse{}, errors.New("agent token is required") + } + + u := c.resolve("/agent/v1/whoami") + var out WhoAmIResponse + if err := c.doJSON(ctx, http.MethodGet, u.String(), nil, &out, withBearerToken(token)); err != nil { + return WhoAmIResponse{}, err + } + return out, nil +} + func withBearerToken(token agent.Token) requestOption { return func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+token.RawToken) @@ -515,13 +543,14 @@ func logRequest(ctx context.Context, req *http.Request, body []byte) { e = e.Str("authorization", redactSecret(token)) } if len(body) > 0 { - e = e.Str("request_body", redactLogBody(body)) + e = e.Str("request_body", authLogBodyRedactor.Redact(req.URL.Path, body)) } e.Msg("auth request") } func logResponse(ctx context.Context, method, rawURL string, reqBody []byte, statusCode int, status string, respBody []byte) { - loggedBody := redactLogBody(respBody) + path := requestPath(rawURL) + loggedBody := authLogBodyRedactor.Redact(path, respBody) zerolog.Ctx(ctx).Debug(). Str("method", method). Str("url", rawURL). @@ -538,43 +567,18 @@ func logResponse(ctx context.Context, method, rawURL string, reqBody []byte, sta Str("status", status). Str("response_body", loggedBody) if len(reqBody) > 0 { - e = e.Str("request_body", redactLogBody(reqBody)) + e = e.Str("request_body", authLogBodyRedactor.Redact(path, reqBody)) } e.Msg("auth response error") } } -func redactLogBody(body []byte) string { - s := strings.TrimSpace(string(body)) - if s == "" { - return "" - } - var m map[string]json.RawMessage - if err := json.Unmarshal(body, &m); err != nil { - return truncateLog(s) - } - for _, key := range []string{"token", "refresh_token"} { - if tok, ok := m[key]; ok { - raw := strings.TrimSpace(string(tok)) - redacted, err := json.Marshal(redactSecret(strings.Trim(raw, `"`))) - if err == nil { - m[key] = redacted - } - } - } - encoded, err := json.Marshal(m) +func requestPath(rawURL string) string { + u, err := url.Parse(rawURL) if err != nil { - return truncateLog(s) - } - return string(encoded) -} - -func truncateLog(s string) string { - const max = 2048 - if len(s) <= max { - return s + return rawURL } - return s[:max] + "..." + return u.Path } func redactSecret(value string) string { diff --git a/internal/client/auth/client_test.go b/internal/client/auth/client_test.go index 46e56b4..5bf49b6 100644 --- a/internal/client/auth/client_test.go +++ b/internal/client/auth/client_test.go @@ -240,6 +240,63 @@ func TestClientRefreshAgentToken(t *testing.T) { } } +func TestClientWhoAmI(t *testing.T) { + organizationName := "Example Org" + primaryEmail := "user@example.com" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.RequestURI() != "/agent/v1/whoami" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.RequestURI()) + } + if r.Header.Get("Authorization") != "Bearer agent-token" { + t.Fatalf("unexpected authorization header: %q", r.Header.Get("Authorization")) + } + if r.Header.Get("Content-Type") != "" { + t.Fatalf("expected no content type, got %q", r.Header.Get("Content-Type")) + } + _ = json.NewEncoder(w).Encode(WhoAmIResponse{ + AgentIdentifier: "agent-test", + OrganizationName: &organizationName, + PrimaryEmail: &primaryEmail, + }) + })) + defer srv.Close() + + c, err := New(ClientConfig{BaseURL: srv.URL}) + if err != nil { + t.Fatal(err) + } + resp, err := c.WhoAmI(context.Background(), agent.Token{RawToken: "agent-token"}) + if err != nil { + t.Fatal(err) + } + if resp.AgentIdentifier != "agent-test" || resp.OrganizationName == nil || *resp.OrganizationName != organizationName || resp.PrimaryEmail == nil || *resp.PrimaryEmail != primaryEmail { + t.Fatalf("unexpected response: %#v", resp) + } +} + +func TestClientWhoAmISurfacesProblemJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(map[string]any{ + "type": "about:blank", + "title": "Bad Gateway", + "status": http.StatusBadGateway, + "detail": "identity lookup failed", + }) + })) + defer srv.Close() + + c, err := New(ClientConfig{BaseURL: srv.URL}) + if err != nil { + t.Fatal(err) + } + _, err = c.WhoAmI(context.Background(), agent.Token{RawToken: "agent-token"}) + if err == nil || !strings.Contains(err.Error(), "identity lookup failed") { + t.Fatalf("expected problem JSON error, got %v", err) + } +} + func TestClientRevokesRefreshToken(t *testing.T) { var sawRequest bool srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/client/auth/logging_test.go b/internal/client/auth/logging_test.go index 10c43ba..b44b4fb 100644 --- a/internal/client/auth/logging_test.go +++ b/internal/client/auth/logging_test.go @@ -1,22 +1,23 @@ package auth import ( + "bytes" + "context" "strings" "testing" + + "github.com/rs/zerolog" ) -func TestRedactLogBodyRedactsTokenField(t *testing.T) { - got := redactLogBody([]byte(`{"token":"eyJhbGciOiJSUzI1NiJ9.abcdef","refresh_token":"agrt_secret_value","expires_in":3600}`)) - if got == "" { - t.Fatal("expected redacted body") - } - if strings.Contains(got, "eyJhbGciOi") { - t.Fatalf("expected token redacted, got %q", got) - } - if strings.Contains(got, "agrt_secret_value") { - t.Fatalf("expected refresh token redacted, got %q", got) +func TestLogResponseSuppressesWhoAmIBody(t *testing.T) { + var logs bytes.Buffer + logger := zerolog.New(&logs).Level(zerolog.DebugLevel) + ctx := logger.WithContext(context.Background()) + logResponse(ctx, "GET", "https://oauth.tollbit.com/agent/v1/whoami", nil, 200, "200 OK", []byte(`{"agent_identifier":"agent-test","organization_name":"Example Org","primary_email":"user@example.com"}`)) + if strings.Contains(logs.String(), "user@example.com") { + t.Fatalf("expected whoami email suppressed, got %q", logs.String()) } - if !strings.Contains(got, "expires_in") { - t.Fatalf("expected other fields preserved, got %q", got) + if !strings.Contains(logs.String(), "[REDACTED]") { + t.Fatalf("expected redacted response body, got %q", logs.String()) } } diff --git a/internal/logging/http_body_redactor.go b/internal/logging/http_body_redactor.go new file mode 100644 index 0000000..90f0f18 --- /dev/null +++ b/internal/logging/http_body_redactor.go @@ -0,0 +1,100 @@ +package logging + +import ( + "encoding/json" + "strings" +) + +const RedactedValue = "[REDACTED]" + +type ( + JSONFieldRedactor func(json.RawMessage) json.RawMessage + + HTTPBodyRedactorConfig struct { + FullyRedactedPaths []string + JSONFields map[string]JSONFieldRedactor + MaxLength int + } + + HTTPBodyRedactor struct { + fullyRedactedPaths map[string]struct{} + jsonFields map[string]JSONFieldRedactor + maxLength int + } +) + +func NewHTTPBodyRedactor(cfg HTTPBodyRedactorConfig) *HTTPBodyRedactor { + paths := make(map[string]struct{}, len(cfg.FullyRedactedPaths)) + for _, path := range cfg.FullyRedactedPaths { + paths[path] = struct{}{} + } + fields := make(map[string]JSONFieldRedactor, len(cfg.JSONFields)) + for field, redactor := range cfg.JSONFields { + fields[field] = redactor + } + maxLength := cfg.MaxLength + if maxLength <= 0 { + maxLength = 2048 + } + return &HTTPBodyRedactor{ + fullyRedactedPaths: paths, + jsonFields: fields, + maxLength: maxLength, + } +} + +func (r *HTTPBodyRedactor) Redact(path string, body []byte) string { + value := strings.TrimSpace(string(body)) + if value == "" { + return "" + } + if _, ok := r.fullyRedactedPaths[path]; ok { + return RedactedValue + } + + var object map[string]json.RawMessage + if err := json.Unmarshal(body, &object); err != nil { + if len(r.jsonFields) > 0 { + return RedactedValue + } + return r.truncate(value) + } + for field, redact := range r.jsonFields { + if raw, ok := object[field]; ok { + object[field] = redact(raw) + } + } + encoded, err := json.Marshal(object) + if err != nil { + return RedactedValue + } + return r.truncate(string(encoded)) +} + +func RedactJSONField(json.RawMessage) json.RawMessage { + return json.RawMessage(`"[REDACTED]"`) +} + +func AbbreviateJSONSecret(prefixLength, suffixLength int) JSONFieldRedactor { + return func(raw json.RawMessage) json.RawMessage { + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return RedactJSONField(raw) + } + if prefixLength < 0 || suffixLength < 0 || len(value) <= prefixLength+suffixLength { + return RedactJSONField(raw) + } + encoded, err := json.Marshal(value[:prefixLength] + "..." + value[len(value)-suffixLength:]) + if err != nil { + return RedactJSONField(raw) + } + return encoded + } +} + +func (r *HTTPBodyRedactor) truncate(value string) string { + if len(value) <= r.maxLength { + return value + } + return value[:r.maxLength] + "..." +} diff --git a/internal/logging/http_body_redactor_test.go b/internal/logging/http_body_redactor_test.go new file mode 100644 index 0000000..bb90f5a --- /dev/null +++ b/internal/logging/http_body_redactor_test.go @@ -0,0 +1,56 @@ +package logging + +import ( + "strings" + "testing" +) + +func TestHTTPBodyRedactorRedactsConfiguredFields(t *testing.T) { + redactor := NewHTTPBodyRedactor(HTTPBodyRedactorConfig{ + JSONFields: map[string]JSONFieldRedactor{ + "token": AbbreviateJSONSecret(6, 4), + "refresh_token": AbbreviateJSONSecret(6, 4), + }, + }) + + got := redactor.Redact("/agent/v1/tokens/identity", []byte(`{"token":"eyJhbGciOiJSUzI1NiJ9.abcdef","refresh_token":"agrt_secret_value","expires_in":3600}`)) + if strings.Contains(got, "eyJhbGciOi") || strings.Contains(got, "agrt_secret_value") { + t.Fatalf("expected secrets redacted, got %q", got) + } + if !strings.Contains(got, "expires_in") { + t.Fatalf("expected other fields preserved, got %q", got) + } +} + +func TestHTTPBodyRedactorRedactsConfiguredPath(t *testing.T) { + redactor := NewHTTPBodyRedactor(HTTPBodyRedactorConfig{ + FullyRedactedPaths: []string{"/agent/v1/whoami"}, + }) + + got := redactor.Redact("/agent/v1/whoami", []byte(`{"primary_email":"user@example.com"}`)) + if got != RedactedValue { + t.Fatalf("expected fully redacted body, got %q", got) + } +} + +func TestHTTPBodyRedactorFailsClosedForInvalidJSON(t *testing.T) { + redactor := NewHTTPBodyRedactor(HTTPBodyRedactorConfig{ + JSONFields: map[string]JSONFieldRedactor{"token": RedactJSONField}, + }) + + got := redactor.Redact("/agent/v1/tokens/identity", []byte(`{"token":"secret"`)) + if got != RedactedValue { + t.Fatalf("expected invalid JSON to be fully redacted, got %q", got) + } +} + +func TestHTTPBodyRedactorRedactsNonStringSecrets(t *testing.T) { + redactor := NewHTTPBodyRedactor(HTTPBodyRedactorConfig{ + JSONFields: map[string]JSONFieldRedactor{"token": AbbreviateJSONSecret(6, 4)}, + }) + + got := redactor.Redact("/agent/v1/tokens/identity", []byte(`{"token":{"nested":"secret"}}`)) + if strings.Contains(got, "nested") || !strings.Contains(got, RedactedValue) { + t.Fatalf("expected non-string secret to be fully redacted, got %q", got) + } +} From b3f1ffd52c1ad181ff05593eb2765781f3600824 Mon Sep 17 00:00:00 2001 From: Orion Delwaterman Date: Thu, 20 Aug 2026 09:51:23 -0400 Subject: [PATCH 2/3] Add status --- internal/cli/auth.go | 117 +++++++++--- internal/cli/cli_test.go | 207 +++++++++++++++++++++- internal/credentials/agenttoken/status.go | 8 +- 3 files changed, 303 insertions(+), 29 deletions(-) diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 04b95be..2edd22e 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -1,6 +1,7 @@ package cli import ( + "context" "errors" "fmt" "strings" @@ -38,8 +39,17 @@ type ( all bool force bool } + + resolvedOBOIdentity struct { + organizationName string + primaryEmail string + } ) +const whoAmITimeout = 3 * time.Second + +const identityResolutionWarning = "warning: could not resolve on-behalf-of names; showing token identifiers instead" + func NewAuthCommand(factory app.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "auth", @@ -248,25 +258,73 @@ func runAuthLogin(cmd *cobra.Command, factory app.Factory, opts authLoginOptions if err != nil { return RuntimeError(err) } - fmt.Fprintln(cmd.ErrOrStderr(), authorizedMessage(identity.Name, claims)) + resolvedIdentity, resolveErr := resolveOBOIdentity(ctx, app, token, claims) + fmt.Fprintln(cmd.ErrOrStderr(), authorizedMessage(identity.Name, claims, resolvedIdentity)) + if resolveErr != nil { + fmt.Fprintln(cmd.ErrOrStderr(), identityResolutionWarning) + } return nil } -func authorizedMessage(name string, claims agent.Claims) string { +func authorizedMessage(name string, claims agent.Claims, identity *resolvedOBOIdentity) string { msg := fmt.Sprintf("authorized as %s", name) - if claims.OBO != nil { - parts := make([]string, 0, 2) - if claims.OBO.User != "" { + parts := oboDisplayParts(claims, identity) + if len(parts) > 0 { + msg += " (on behalf of " + strings.Join(parts, " / ") + ")" + } + return msg +} + +func resolveOBOIdentity(ctx context.Context, application *app.App, token agent.Token, claims agent.Claims) (*resolvedOBOIdentity, error) { + if claims.OBO == nil || (claims.OBO.User == "" && claims.OBO.Org == "") { + return nil, nil + } + authClient, err := application.Auth() + if err != nil { + return nil, err + } + lookupCtx, cancel := context.WithTimeout(ctx, whoAmITimeout) + defer cancel() + response, err := authClient.WhoAmI(lookupCtx, token) + if err != nil { + return nil, err + } + identity := &resolvedOBOIdentity{} + if response.OrganizationName != nil { + identity.organizationName = strings.TrimSpace(*response.OrganizationName) + } + if response.PrimaryEmail != nil { + identity.primaryEmail = strings.TrimSpace(*response.PrimaryEmail) + } + if claims.OBO.Org != "" && identity.organizationName == "" { + return nil, errors.New("whoami response missing organization name") + } + if claims.OBO.User != "" && identity.primaryEmail == "" { + return nil, errors.New("whoami response missing primary email") + } + return identity, nil +} + +func oboDisplayParts(claims agent.Claims, identity *resolvedOBOIdentity) []string { + if claims.OBO == nil { + return nil + } + parts := make([]string, 0, 2) + if claims.OBO.User != "" { + if identity != nil { + parts = append(parts, identity.primaryEmail) + } else { parts = append(parts, "user "+claims.OBO.User) } - if claims.OBO.Org != "" { + } + if claims.OBO.Org != "" { + if identity != nil { + parts = append(parts, identity.organizationName) + } else { parts = append(parts, "org "+claims.OBO.Org) } - if len(parts) > 0 { - msg += " (on behalf of " + strings.Join(parts, " / ") + ")" - } } - return msg + return parts } func printAuthLoginRuntimeContext(cmd *cobra.Command, application *app.App) error { @@ -363,7 +421,11 @@ func runAuthComplete(cmd *cobra.Command, factory app.Factory, args []string) err if err != nil { return RuntimeError(err) } - fmt.Fprintln(cmd.ErrOrStderr(), authorizedMessage(pending.AgentIdentity.Name, claims)) + resolvedIdentity, resolveErr := resolveOBOIdentity(ctx, application, token, claims) + fmt.Fprintln(cmd.ErrOrStderr(), authorizedMessage(pending.AgentIdentity.Name, claims, resolvedIdentity)) + if resolveErr != nil { + fmt.Fprintln(cmd.ErrOrStderr(), identityResolutionWarning) + } return nil } @@ -466,6 +528,22 @@ func runAuthStatus(cmd *cobra.Command, factory app.Factory, opts authStatusOptio refreshStatus := credentials.RefreshTokenStatus(ctx) autoRefresh := credentials.AutoRefreshEnabled() + tokenStatus := agenttoken.Status(token, tokenExists, tokenErr) + var resolvedIdentity *resolvedOBOIdentity + if tokenExists && tokenErr == nil { + if claims, claimsErr := token.Claims(); claimsErr == nil { + var resolveErr error + resolvedIdentity, resolveErr = resolveOBOIdentity(ctx, app, token, claims) + if resolveErr != nil { + fmt.Fprintln(cmd.ErrOrStderr(), identityResolutionWarning) + } + } + } + if resolvedIdentity != nil && tokenStatus.OBO != nil { + tokenStatus.OBO.PrimaryEmail = resolvedIdentity.primaryEmail + tokenStatus.OBO.OrganizationName = resolvedIdentity.organizationName + } + status := map[string]any{ "identity": map[string]string{ "name": identity.Name, @@ -474,7 +552,7 @@ func runAuthStatus(cmd *cobra.Command, factory app.Factory, opts authStatusOptio "auto_refresh": autoRefresh, "pending_authorization": pendingAuthorizationStatus(pending, pendingExists), "refresh_token": refreshStatus, - "token": agenttoken.Status(token, tokenExists, tokenErr), + "token": tokenStatus, } if opts.asJSON { return RuntimeError(writeJSON(cmd.OutOrStdout(), status)) @@ -488,7 +566,7 @@ func runAuthStatus(cmd *cobra.Command, factory app.Factory, opts authStatusOptio } else { fmt.Fprintf(stdout, "User agent:\n") } - printAuthTokenStatus(stdout, token, tokenExists, tokenErr) + printAuthTokenStatus(stdout, token, tokenExists, tokenErr, resolvedIdentity) printRefreshTokenStatus(stdout, refreshStatus, autoRefresh) printPendingAuthorizationStatus(stdout, pending, pendingExists) if tokenExists && tokenErr == nil { @@ -584,7 +662,7 @@ func runAuthSet(cmd *cobra.Command, factory app.Factory, opts authSetOptions) er return nil } -func printAuthTokenStatus(w interface{ Write([]byte) (int, error) }, token agent.Token, exists bool, validationErr error) { +func printAuthTokenStatus(w interface{ Write([]byte) (int, error) }, token agent.Token, exists bool, validationErr error, identity *resolvedOBOIdentity) { if !exists { fmt.Fprintln(w, "Token: none") return @@ -611,13 +689,7 @@ func printAuthTokenStatus(w interface{ Write([]byte) (int, error) }, token agent if claims.OBO == nil { return } - parts := make([]string, 0, 2) - if claims.OBO.User != "" { - parts = append(parts, "user "+claims.OBO.User) - } - if claims.OBO.Org != "" { - parts = append(parts, "org "+claims.OBO.Org) - } + parts := oboDisplayParts(claims, identity) if len(parts) == 0 { return } @@ -627,6 +699,9 @@ func printAuthTokenStatus(w interface{ Write([]byte) (int, error) }, token agent suffix = " (" + source + ")" } fmt.Fprintf(w, "On behalf: %s%s\n", strings.Join(parts, " / "), suffix) + if identity != nil { + fmt.Fprintf(w, "OBO IDs: %s\n", strings.Join(oboDisplayParts(claims, nil), " / ")) + } } func tokenExpiryLabel(token agent.Token) string { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 01387d9..0d68ca8 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "context" "encoding/base64" "encoding/json" "fmt" @@ -20,6 +21,7 @@ import ( "github.com/tollbit/cli/internal/client/auth" "github.com/tollbit/cli/internal/client/tollbit" "github.com/tollbit/cli/internal/configuration" + "github.com/tollbit/cli/internal/tokens/agent" "github.com/tollbit/cli/internal/version" ) @@ -242,6 +244,7 @@ func TestRunAuthLoginStatusAndLogout(t *testing.T) { oboToken := testAgentJWTWithOBO(t) var sawStart bool var sawRedeem bool + var whoAmICalls int authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.RequestURI() { @@ -296,6 +299,19 @@ func TestRunAuthLoginStatusAndLogout(t *testing.T) { t.Fatalf("expected POST revoke, got %s", r.Method) } _ = json.NewEncoder(w).Encode(auth.RevokeRefreshTokenResponse{Revoked: true}) + case "/agent/v1/whoami": + if r.Method != http.MethodGet { + t.Fatalf("expected GET whoami, got %s", r.Method) + } + if r.Header.Get("Authorization") != "Bearer "+oboToken { + t.Fatalf("unexpected whoami authorization: %q", r.Header.Get("Authorization")) + } + whoAmICalls++ + _ = json.NewEncoder(w).Encode(map[string]string{ + "agent_identifier": "agent-test", + "organization_name": "Example Org", + "primary_email": "user@example.com", + }) default: t.Fatalf("unexpected auth request: %s %s", r.Method, r.URL.RequestURI()) } @@ -312,12 +328,15 @@ func TestRunAuthLoginStatusAndLogout(t *testing.T) { if !sawStart || !sawRedeem { t.Fatalf("expected start and redeem requests, sawStart=%v sawRedeem=%v", sawStart, sawRedeem) } - for _, want := range []string{"Authorize agent: agent-test", "Open this URL in your browser", "authorized as agent-test", "user usr_123", "org org_456"} { + for _, want := range []string{"Authorize agent: agent-test", "Open this URL in your browser", "authorized as agent-test (on behalf of user@example.com / Example Org)"} { combined := stdout.String() + stderr.String() if !strings.Contains(combined, want) { t.Fatalf("expected login output to contain %q, got stdout=%q stderr=%q", want, stdout.String(), stderr.String()) } } + if strings.Contains(stderr.String(), "usr_123") || strings.Contains(stderr.String(), "org_456") { + t.Fatalf("expected post-login message to contain names only, got stderr=%q", stderr.String()) + } stdout.Reset() stderr.Reset() @@ -325,7 +344,7 @@ func TestRunAuthLoginStatusAndLogout(t *testing.T) { if code != 0 { t.Fatalf("auth status failed: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } - for _, want := range []string{"Agent: agent-test", "Token: valid", "On behalf: user usr_123 / org org_456 (consent)"} { + for _, want := range []string{"Agent: agent-test", "Token: valid", "On behalf: user@example.com / Example Org (consent)", "OBO IDs: user usr_123 / org org_456"} { if !strings.Contains(stdout.String(), want) { t.Fatalf("expected status stdout to contain %q, got %q", want, stdout.String()) } @@ -350,6 +369,12 @@ func TestRunAuthLoginStatusAndLogout(t *testing.T) { if oboStatus["source"] != "consent" || oboStatus["user"] != "usr_123" || oboStatus["org"] != "org_456" { t.Fatalf("unexpected obo status: %#v", oboStatus) } + if oboStatus["primary_email"] != "user@example.com" || oboStatus["organization_name"] != "Example Org" { + t.Fatalf("expected resolved identity in OBO status: %#v", oboStatus) + } + if whoAmICalls != 3 { + t.Fatalf("expected whoami on login and both status commands, got %d calls", whoAmICalls) + } stdout.Reset() stderr.Reset() @@ -372,6 +397,130 @@ func TestRunAuthLoginStatusAndLogout(t *testing.T) { } } +func TestAuthorizedMessageOBOCases(t *testing.T) { + orgOnlyClaims := agent.Claims{OBO: &agent.OBOClaims{Source: "consent", Org: "org_456"}} + got := authorizedMessage("agent-test", orgOnlyClaims, &resolvedOBOIdentity{organizationName: "Example Org"}) + if got != "authorized as agent-test (on behalf of Example Org)" { + t.Fatalf("unexpected organization-only message: %q", got) + } + + noOBOClaims := agent.Claims{} + got = authorizedMessage("agent-test", noOBOClaims, nil) + if got != "authorized as agent-test" { + t.Fatalf("unexpected no-OBO message: %q", got) + } + resolved, err := resolveOBOIdentity(context.Background(), nil, agent.Token{}, noOBOClaims) + if err != nil || resolved != nil { + t.Fatalf("expected no identity lookup without OBO claims, got identity=%#v err=%v", resolved, err) + } +} + +func TestPrintAuthTokenStatusOrganizationOnly(t *testing.T) { + token := testAgentJWTWithOBOClaims(t, "", "org_456") + var output bytes.Buffer + printAuthTokenStatus(&output, agent.Token{RawToken: token}, true, nil, &resolvedOBOIdentity{organizationName: "Example Org"}) + if !strings.Contains(output.String(), "On behalf: Example Org (consent)\n") { + t.Fatalf("expected organization-only status, got %q", output.String()) + } + if !strings.Contains(output.String(), "OBO IDs: org org_456\n") { + t.Fatalf("expected organization ID, got %q", output.String()) + } + if strings.Contains(output.String(), " / ") { + t.Fatalf("unexpected dangling separator in organization-only status: %q", output.String()) + } +} + +func TestRunAuthLoginWhoAmIFailureFallsBack(t *testing.T) { + for _, tc := range []struct { + name string + timeout bool + }{ + {name: "bad gateway"}, + {name: "timeout", timeout: true}, + } { + t.Run(tc.name, func(t *testing.T) { + storageDir := t.TempDir() + baseToken := testAgentJWT(t) + oboToken := testAgentJWTWithOBO(t) + var whoAmICalls int + + authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/agent/v1/tokens/identity": + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["grant_type"] == "self_attested" { + _ = json.NewEncoder(w).Encode(map[string]string{"token": baseToken}) + return + } + _ = json.NewEncoder(w).Encode(auth.AgentTokenResponse{Token: oboToken}) + case "/agent/v1/consent/redirect/start": + var body auth.ConsentRedirectStartRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + go func() { + _, _ = http.Get(body.RedirectURI + "?code=auth-code&state=" + body.State) + }() + _ = json.NewEncoder(w).Encode(auth.ConsentRedirectStartResponse{ + ChallengeID: "ach_test", + ConsentURL: "https://auth.example/consent", + ExpiresAt: time.Now().Add(time.Minute).Format(time.RFC3339), + }) + case "/agent/v1/whoami": + whoAmICalls++ + if tc.timeout { + <-r.Context().Done() + return + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(map[string]any{ + "type": "about:blank", + "title": "Bad Gateway", + "status": http.StatusBadGateway, + }) + default: + t.Fatalf("unexpected auth request: %s %s", r.Method, r.URL.RequestURI()) + } + })) + defer authSrv.Close() + + config := testConfig() + config.Auth.BaseURL = authSrv.URL + config.Credentials.StorageDir = storageDir + config.Runtime.StateDir = storageDir + cmd := NewCommandTree(app.Factory{Config: config}) + cmd.SetArgs([]string{"auth", "login", "--name", "agent-test"}) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + if tc.timeout { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + cmd.SetContext(ctx) + } + err := cmd.Execute() + if code := ExitCode(err); code != 0 { + t.Fatalf("expected successful login, got code=%d err=%v stdout=%q stderr=%q", code, err, stdout.String(), stderr.String()) + } + if whoAmICalls != 1 { + t.Fatalf("expected one whoami request, got %d", whoAmICalls) + } + for _, want := range []string{"authorized as agent-test (on behalf of user usr_123 / org org_456)", identityResolutionWarning} { + if !strings.Contains(stderr.String(), want) { + t.Fatalf("expected stderr to contain %q, got %q", want, stderr.String()) + } + } + if strings.Contains(stderr.String(), "user@example.com") { + t.Fatalf("unexpected resolved email in fallback output: %q", stderr.String()) + } + }) + } +} + func TestRunAuthLogoutFailClosedAndForce(t *testing.T) { storageDir := t.TempDir() tokenPath := filepath.Join(storageDir, "agent-token.jwt") @@ -451,6 +600,10 @@ func testAgentJWT(t *testing.T) string { } func testAgentJWTWithOBO(t *testing.T) string { + return testAgentJWTWithOBOClaims(t, "usr_123", "org_456") +} + +func testAgentJWTWithOBOClaims(t *testing.T, user, org string) string { t.Helper() claims := struct { jwt.RegisteredClaims @@ -465,8 +618,8 @@ func testAgentJWTWithOBO(t *testing.T) string { OBO: map[string]any{ "ver": 1, "src": "consent", - "usr": "usr_123", - "org": "org_456", + "usr": user, + "org": org, }, } header := map[string]any{"alg": "none"} @@ -518,6 +671,42 @@ func TestRunAuthStatusCheckExitCodes(t *testing.T) { } } +func TestRunAuthStatusWhoAmIFailureFallsBack(t *testing.T) { + storageDir := t.TempDir() + if err := os.WriteFile(filepath.Join(storageDir, "agent-token.jwt"), []byte(testAgentJWTWithOBO(t)), 0o600); err != nil { + t.Fatal(err) + } + authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/agent/v1/whoami" { + t.Fatalf("unexpected auth request: %s %s", r.Method, r.URL.RequestURI()) + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(map[string]any{ + "type": "about:blank", + "title": "Bad Gateway", + "status": http.StatusBadGateway, + }) + })) + defer authSrv.Close() + + config := testConfig() + config.Auth.BaseURL = authSrv.URL + config.Credentials.StorageDir = storageDir + config.Runtime.StateDir = storageDir + var stdout, stderr bytes.Buffer + code := executeTestCommandWithConfig(config, []string{"auth", "status"}, nil, &stdout, &stderr) + if code != 0 { + t.Fatalf("expected successful status, got code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stdout.String(), "On behalf: user usr_123 / org org_456 (consent)") { + t.Fatalf("expected raw OBO fallback, got %q", stdout.String()) + } + if !strings.Contains(stderr.String(), identityResolutionWarning) { + t.Fatalf("expected resolution warning, got %q", stderr.String()) + } +} + func TestRunAuthSetNameChangeClearsToken(t *testing.T) { storageDir := t.TempDir() t.Setenv(testCredentialsStorageDirEnvVar, storageDir) @@ -1096,6 +1285,14 @@ func TestRunAuthCompleteAgentConfirmsIconsSucceedsWithIconNames(t *testing.T) { successToken := testAgentJWTWithOBO(t) var sawRedeem bool authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/agent/v1/whoami" { + _ = json.NewEncoder(w).Encode(map[string]string{ + "agent_identifier": "agent-test", + "organization_name": "Example Org", + "primary_email": "user@example.com", + }) + return + } if r.Method != http.MethodPost || r.URL.Path != "/agent/v1/tokens/identity" { t.Fatalf("unexpected auth request: %s %s", r.Method, r.URL.Path) } @@ -1138,7 +1335,7 @@ func TestRunAuthCompleteAgentConfirmsIconsSucceedsWithIconNames(t *testing.T) { if !sawRedeem { t.Fatal("expected redeem request") } - if !strings.Contains(stderr.String(), "authorized as pending-agent") { + if !strings.Contains(stderr.String(), "authorized as pending-agent (on behalf of user@example.com / Example Org)") { t.Fatalf("expected authorized message, got stderr=%q", stderr.String()) } if _, err := os.Stat(filepath.Join(storageDir, "pending-auth.json")); !os.IsNotExist(err) { diff --git a/internal/credentials/agenttoken/status.go b/internal/credentials/agenttoken/status.go index 22c6094..564c625 100644 --- a/internal/credentials/agenttoken/status.go +++ b/internal/credentials/agenttoken/status.go @@ -7,9 +7,11 @@ import ( ) type OBOStatus struct { - Source string `json:"source"` - User string `json:"user"` - Org string `json:"org"` + Source string `json:"source"` + User string `json:"user"` + Org string `json:"org"` + PrimaryEmail string `json:"primary_email,omitempty"` + OrganizationName string `json:"organization_name,omitempty"` } type TokenStatus struct { From 1622675c3ffe24ab5d36a46f8e44fd80b40295a0 Mon Sep 17 00:00:00 2001 From: Orion Delwaterman Date: Thu, 20 Aug 2026 10:21:34 -0400 Subject: [PATCH 3/3] Switch to table writer --- internal/cli/auth.go | 68 ++++++++++++++++---------- internal/cli/cli_test.go | 103 ++++++++++++++++++++++++++++++--------- 2 files changed, 122 insertions(+), 49 deletions(-) diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 2edd22e..ec48d02 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strings" + "text/tabwriter" "time" "github.com/spf13/cobra" @@ -560,15 +561,19 @@ func runAuthStatus(cmd *cobra.Command, factory app.Factory, opts authStatusOptio stdout := cmd.OutOrStdout() stderr := cmd.ErrOrStderr() - fmt.Fprintf(stdout, "Agent: %s\n", identity.Name) + table := tabwriter.NewWriter(stdout, 0, 4, 1, ' ', 0) + fmt.Fprintf(table, "Agent:\t%s\n", identity.Name) if identity.UserAgent != "" { - fmt.Fprintf(stdout, "User agent: %s\n", identity.UserAgent) + fmt.Fprintf(table, "User agent:\t%s\n", identity.UserAgent) } else { - fmt.Fprintf(stdout, "User agent:\n") + fmt.Fprintln(table, "User agent:\t") + } + printAuthTokenStatus(table, token, tokenExists, tokenErr, resolvedIdentity) + printRefreshTokenStatus(table, refreshStatus, autoRefresh) + printPendingAuthorizationStatus(table, pending, pendingExists) + if err := table.Flush(); err != nil { + return RuntimeError(err) } - printAuthTokenStatus(stdout, token, tokenExists, tokenErr, resolvedIdentity) - printRefreshTokenStatus(stdout, refreshStatus, autoRefresh) - printPendingAuthorizationStatus(stdout, pending, pendingExists) if tokenExists && tokenErr == nil { if claims, claimsErr := token.Claims(); claimsErr == nil && claims.Subject != "" && claims.Subject != identity.Name { fmt.Fprintf(stderr, "token subject %q does not match profile name %q — run 'tollbit auth login'\n", claims.Subject, identity.Name) @@ -591,17 +596,17 @@ func pendingAuthorizationStatus(pending agentauth.PendingConsent, exists bool) m } func printRefreshTokenStatus(w interface{ Write([]byte) (int, error) }, status agenttoken.RefreshTokenStatus, autoRefresh bool) { - fmt.Fprintf(w, "Auto-refresh: %s\n", enabledLabel(autoRefresh)) + fmt.Fprintf(w, "Auto-refresh:\t%s\n", enabledLabel(autoRefresh)) if status.Error != "" { if status.Present { - fmt.Fprintf(w, "Refresh: invalid (%s)\n", status.Error) + fmt.Fprintf(w, "Refresh:\tinvalid (%s)\n", status.Error) return } - fmt.Fprintf(w, "Refresh: absent (%s)\n", status.Error) + fmt.Fprintf(w, "Refresh:\tabsent (%s)\n", status.Error) return } if !status.Present { - fmt.Fprintln(w, "Refresh: absent") + fmt.Fprintln(w, "Refresh:\tabsent") return } state := "present" @@ -609,10 +614,10 @@ func printRefreshTokenStatus(w interface{ Write([]byte) (int, error) }, status a state = "expired" } if status.ExpiresAt != "" { - fmt.Fprintf(w, "Refresh: %s (expires %s)\n", state, status.ExpiresAt) + fmt.Fprintf(w, "Refresh:\t%s (expires %s)\n", state, status.ExpiresAt) return } - fmt.Fprintf(w, "Refresh: %s\n", state) + fmt.Fprintf(w, "Refresh:\t%s\n", state) } func enabledLabel(enabled bool) string { @@ -626,10 +631,10 @@ func printPendingAuthorizationStatus(w interface{ Write([]byte) (int, error) }, if !exists { return } - fmt.Fprintln(w, "Pending: authorization pending (complete in browser, then run 'tollbit auth complete')") - fmt.Fprintf(w, "Pending agent: %s\n", pending.AgentIdentity.Name) + fmt.Fprintln(w, "Pending:\tauthorization pending (complete in browser, then run 'tollbit auth complete')") + fmt.Fprintf(w, "Pending agent:\t%s\n", pending.AgentIdentity.Name) if pending.AgentIdentity.UserAgent != "" { - fmt.Fprintf(w, "Pending user agent: %s\n", pending.AgentIdentity.UserAgent) + fmt.Fprintf(w, "Pending user agent:\t%s\n", pending.AgentIdentity.UserAgent) } } @@ -664,43 +669,52 @@ func runAuthSet(cmd *cobra.Command, factory app.Factory, opts authSetOptions) er func printAuthTokenStatus(w interface{ Write([]byte) (int, error) }, token agent.Token, exists bool, validationErr error, identity *resolvedOBOIdentity) { if !exists { - fmt.Fprintln(w, "Token: none") + fmt.Fprintln(w, "Token:\tnone") return } if validationErr != nil { expires := tokenExpiryLabel(token) if expires != "" { - fmt.Fprintf(w, "Token: expired (%s)\n", expires) + fmt.Fprintf(w, "Token:\texpired (%s)\n", expires) return } - fmt.Fprintf(w, "Token: invalid (%v)\n", validationErr) + fmt.Fprintf(w, "Token:\tinvalid (%v)\n", validationErr) return } claims, err := token.Claims() if err != nil { - fmt.Fprintf(w, "Token: invalid (%v)\n", err) + fmt.Fprintf(w, "Token:\tinvalid (%v)\n", err) return } expires := "unknown" if claims.ExpiresAt != nil { expires = claims.ExpiresAt.Time.UTC().Format(time.RFC3339) } - fmt.Fprintf(w, "Token: valid (expires %s)\n", expires) + fmt.Fprintf(w, "Token:\tvalid (expires %s)\n", expires) if claims.OBO == nil { return } - parts := oboDisplayParts(claims, identity) - if len(parts) == 0 { + if claims.OBO.User == "" && claims.OBO.Org == "" { return } + fmt.Fprintln(w, "On behalf:\t") + if identity != nil { + if claims.OBO.User != "" { + fmt.Fprintf(w, " User:\t%s\n", identity.primaryEmail) + } + if claims.OBO.Org != "" { + fmt.Fprintf(w, " Organization:\t%s\n", identity.organizationName) + } + } source := strings.TrimSpace(claims.OBO.Source) - suffix := "" if source != "" { - suffix = " (" + source + ")" + fmt.Fprintf(w, " Source:\t%s\n", source) } - fmt.Fprintf(w, "On behalf: %s%s\n", strings.Join(parts, " / "), suffix) - if identity != nil { - fmt.Fprintf(w, "OBO IDs: %s\n", strings.Join(oboDisplayParts(claims, nil), " / ")) + if claims.OBO.User != "" { + fmt.Fprintf(w, " User ID:\t%s\n", claims.OBO.User) + } + if claims.OBO.Org != "" { + fmt.Fprintf(w, " Org ID:\t%s\n", claims.OBO.Org) } } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 0d68ca8..f2cbfe3 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -13,6 +13,7 @@ import ( "path/filepath" "strings" "testing" + "text/tabwriter" "time" "github.com/golang-jwt/jwt/v5" @@ -44,6 +45,39 @@ func executeTestCommand(args []string, stdin io.Reader, stdout, stderr *bytes.Bu return ExitCode(err) } +func statusLineValue(output, label string) string { + for line := range strings.SplitSeq(output, "\n") { + if strings.HasPrefix(line, label) { + return strings.TrimSpace(strings.TrimPrefix(line, label)) + } + } + return "" +} + +func assertStatusValueAlignment(t *testing.T, output string, values []string) { + t.Helper() + wantColumn := -1 + for _, value := range values { + column := -1 + for line := range strings.SplitSeq(output, "\n") { + if index := strings.Index(line, value); index >= 0 { + column = index + break + } + } + if column < 0 { + t.Fatalf("expected status output to contain %q, got %q", value, output) + } + if wantColumn < 0 { + wantColumn = column + continue + } + if column != wantColumn { + t.Fatalf("expected %q at column %d, got column %d in %q", value, wantColumn, column, output) + } + } +} + func testConfig() configuration.Config { gatewayBaseURL := os.Getenv(testGatewayBaseURLEnvVar) if gatewayBaseURL == "" { @@ -211,7 +245,7 @@ func TestRunAuthSetStatusAndLogoutAll(t *testing.T) { if code != 0 { t.Fatalf("auth status failed: code=%d stderr=%q", code, stderr.String()) } - if !strings.Contains(stdout.String(), "Agent: agent-test") || !strings.Contains(stdout.String(), "User agent: agent-test/0.1") { + if statusLineValue(stdout.String(), "Agent:") != "agent-test" || statusLineValue(stdout.String(), "User agent:") != "agent-test/0.1" { t.Fatalf("unexpected status stdout: %q", stdout.String()) } @@ -233,7 +267,7 @@ func TestRunAuthStatusDefaultsToAnonymous(t *testing.T) { if code != 0 { t.Fatalf("auth status failed: code=%d stderr=%q", code, stderr.String()) } - if !strings.Contains(stdout.String(), "Agent: anonymous") { + if statusLineValue(stdout.String(), "Agent:") != "anonymous" { t.Fatalf("unexpected status stdout: %q", stdout.String()) } } @@ -344,11 +378,21 @@ func TestRunAuthLoginStatusAndLogout(t *testing.T) { if code != 0 { t.Fatalf("auth status failed: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } - for _, want := range []string{"Agent: agent-test", "Token: valid", "On behalf: user@example.com / Example Org (consent)", "OBO IDs: user usr_123 / org org_456"} { + for _, want := range []string{ + "Agent: agent-test", + "Token: valid", + "On behalf:", + " User: user@example.com\n", + " Organization: Example Org\n", + " Source: consent\n", + " User ID: usr_123\n", + " Org ID: org_456\n", + } { if !strings.Contains(stdout.String(), want) { t.Fatalf("expected status stdout to contain %q, got %q", want, stdout.String()) } } + assertStatusValueAlignment(t, stdout.String(), []string{"agent-test", "valid (expires", "user@example.com", "Example Org", "consent", "usr_123", "org_456", "enabled", "present (expires"}) stdout.Reset() stderr.Reset() @@ -392,7 +436,7 @@ func TestRunAuthLoginStatusAndLogout(t *testing.T) { if code != 0 { t.Fatalf("auth status after logout failed: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } - if !strings.Contains(stdout.String(), "Token: none") { + if statusLineValue(stdout.String(), "Token:") != "none" { t.Fatalf("expected token to be cleared, got %q", stdout.String()) } } @@ -418,15 +462,25 @@ func TestAuthorizedMessageOBOCases(t *testing.T) { func TestPrintAuthTokenStatusOrganizationOnly(t *testing.T) { token := testAgentJWTWithOBOClaims(t, "", "org_456") var output bytes.Buffer - printAuthTokenStatus(&output, agent.Token{RawToken: token}, true, nil, &resolvedOBOIdentity{organizationName: "Example Org"}) - if !strings.Contains(output.String(), "On behalf: Example Org (consent)\n") { + table := tabwriter.NewWriter(&output, 0, 4, 1, ' ', 0) + printAuthTokenStatus(table, agent.Token{RawToken: token}, true, nil, &resolvedOBOIdentity{organizationName: "Example Org"}) + if err := table.Flush(); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), "On behalf:") { t.Fatalf("expected organization-only status, got %q", output.String()) } - if !strings.Contains(output.String(), "OBO IDs: org org_456\n") { + if !strings.Contains(output.String(), " Organization: Example Org\n") { + t.Fatalf("expected organization name, got %q", output.String()) + } + if !strings.Contains(output.String(), " Source: consent\n") { + t.Fatalf("expected OBO source, got %q", output.String()) + } + if !strings.Contains(output.String(), " Org ID: org_456\n") { t.Fatalf("expected organization ID, got %q", output.String()) } - if strings.Contains(output.String(), " / ") { - t.Fatalf("unexpected dangling separator in organization-only status: %q", output.String()) + if strings.Contains(output.String(), "User:") || strings.Contains(output.String(), "User ID:") { + t.Fatalf("unexpected user fields in organization-only status: %q", output.String()) } } @@ -699,7 +753,12 @@ func TestRunAuthStatusWhoAmIFailureFallsBack(t *testing.T) { if code != 0 { t.Fatalf("expected successful status, got code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } - if !strings.Contains(stdout.String(), "On behalf: user usr_123 / org org_456 (consent)") { + for _, want := range []string{"On behalf:", " Source: consent\n", " User ID: usr_123\n", " Org ID: org_456\n"} { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("expected raw OBO fallback field %q, got %q", want, stdout.String()) + } + } + if strings.Contains(stdout.String(), " User:") || strings.Contains(stdout.String(), " Organization:") { t.Fatalf("expected raw OBO fallback, got %q", stdout.String()) } if !strings.Contains(stderr.String(), identityResolutionWarning) { @@ -736,10 +795,10 @@ func TestRunAuthSetNameChangeClearsToken(t *testing.T) { if code != 0 { t.Fatalf("auth status failed: code=%d stderr=%q", code, stderr.String()) } - if !strings.Contains(stdout.String(), "Token: none") { + if statusLineValue(stdout.String(), "Token:") != "none" { t.Fatalf("expected missing token after rename, got %q", stdout.String()) } - if !strings.Contains(stdout.String(), "User agent: agent-test/0.1") { + if statusLineValue(stdout.String(), "User agent:") != "agent-test/0.1" { t.Fatalf("expected user agent preserved, got %q", stdout.String()) } } @@ -992,17 +1051,17 @@ func TestRunAuthStatusShowsPendingAuthorization(t *testing.T) { if code != 0 { t.Fatalf("auth status failed: code=%d stderr=%q", code, stderr.String()) } - for _, want := range []string{ - "Agent: anonymous", - "Token: none", - "Auto-refresh: enabled", - "Refresh: absent", - "Pending: authorization pending (complete in browser, then run 'tollbit auth complete')", - "Pending agent: pending-agent", - "Pending user agent: pending-agent/0.1", + for label, want := range map[string]string{ + "Agent:": "anonymous", + "Token:": "none", + "Auto-refresh:": "enabled", + "Refresh:": "absent", + "Pending:": "authorization pending (complete in browser, then run 'tollbit auth complete')", + "Pending agent:": "pending-agent", + "Pending user agent:": "pending-agent/0.1", } { - if !strings.Contains(stdout.String(), want) { - t.Fatalf("expected status stdout to contain %q, got %q", want, stdout.String()) + if got := statusLineValue(stdout.String(), label); got != want { + t.Fatalf("expected %s value %q, got %q in %q", label, want, got, stdout.String()) } } if strings.Contains(stdout.String(), "browser_select_icon") {