diff --git a/README.md b/README.md index 052bd7d..175e316 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,10 @@ Flags: --enable-partitioned-dml Execute DML statement using Partitioned DML --timeout=10m Maximum time to wait for the SQL query to complete + --reauth="off" When auto, run gcloud application-default + login once if local user ADC needs + reauthentication; off only prints a hint + ($EXECSPANSQL_REAUTH). --try-partition-query (Experimental) Check whether the query can be executed as partition query or not @@ -398,6 +402,32 @@ Note: `--log-grpc=payload` can log request and response payloads (including boun Use `--priority=high`, `--priority=medium`, `--priority=low`, or `--priority=unspecified` to set the Spanner execute-SQL request priority. Omitting the flag keeps the unspecified priority. The setting applies to JSON/YAML output (including eager and lazy jq input), CSV, ordinary DML, and Partitioned DML; it does not change read-write transaction commit priority. +### Reauthentication + +Google Workspace session policies can invalidate a user Application Default Credentials refresh token (`invalid_grant` with `error_subtype` `invalid_rapt` or `rapt_required`). execspansql never replays SQL after a Spanner RPC has started. + +`--reauth=off` (default, also `EXECSPANSQL_REAUTH`) leaves client construction unchanged. If a classified reauth error or a gRPC `Unauthenticated` message containing `invalid_rapt` / `rapt_required` is reported, the process exits non-zero with: + +``` +Reauthentication is needed. Please run 'gcloud auth application-default login' to reauthenticate. +``` + +`--reauth=auto` is explicit consent for one interactive `gcloud auth application-default login` **before** the Spanner client is created. `auto` means continue as whoever completes that login; the principal is not compared with the previous ADC identity. The login budget is exactly one attempt per process. After a successful login the well-known ADC file is re-read, must still be `authorized_user`, and a token is fetched again. A quota project change is reported on stderr and does not fail the command. + +Automatic login runs only when all of the following hold: + +* `SPANNER_EMULATOR_HOST` is unset (the emulator does not use these credentials) +* `GOOGLE_APPLICATION_CREDENTIALS` is unset (gcloud writes the well-known file, not that path) +* `CLOUDSDK_CONFIG` is unset (gcloud honors it; the Go auth library does not, so the login result would not be picked up) +* the well-known ADC file exists, is writable, and has `"type": "authorized_user"` +* stdin and stderr are terminals (stdout may be a pipe) +* `gcloud` is on `PATH` +* no test/emulator client options that bypass ADC were injected + +`--timeout` applies only to query execution, not to the login. A reauth failure during a long-running statement (after the preflight) is reported with the hint rather than retried. DML is never replayed. + +The Go client always reads `$HOME/.config/gcloud/application_default_credentials.json` (`%APPDATA%\gcloud\...` on Windows). If `CLOUDSDK_CONFIG` is set, automatic login is skipped and the hint names that variable. + ### (Experimental) `--try-partition-query` Check whether the query can be executed as partition query or not. diff --git a/go.mod b/go.mod index db373a8..189d432 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/apstndb/execspansql go 1.25.0 require ( + cloud.google.com/go/auth v0.20.0 cloud.google.com/go/spanner v1.90.0 github.com/alecthomas/kong v1.15.0 github.com/apstndb/gsqlutils v0.0.0-20260502161854-d7d6011a36e0 @@ -21,6 +22,8 @@ require ( go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/sdk v1.44.0 go.uber.org/zap v1.27.0 + golang.org/x/oauth2 v0.36.0 + golang.org/x/term v0.43.0 google.golang.org/api v0.280.0 google.golang.org/grpc v1.81.1 google.golang.org/protobuf v1.36.11 @@ -29,7 +32,6 @@ require ( require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.11.0 // indirect @@ -133,7 +135,6 @@ require ( golang.org/x/crypto v0.51.0 // indirect golang.org/x/image v0.43.0 // indirect golang.org/x/net v0.55.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.38.0 // indirect diff --git a/main.go b/main.go index 7aa9d5c..b632625 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( "fmt" "log" "os" + "os/signal" "encoding/json" @@ -76,6 +77,7 @@ type opts struct { TraceOTLPEndpoint string `name:"experimental-trace-otlp-endpoint" default:"localhost:4317" help:"OTLP/gRPC endpoint used with --experimental-trace-otlp."` EnablePartitionedDML bool `name:"enable-partitioned-dml" help:"Execute DML statement using Partitioned DML"` Timeout time.Duration `name:"timeout" default:"10m" help:"Maximum time to wait for the SQL query to complete"` + Reauth string `name:"reauth" enum:"off,auto" default:"off" env:"EXECSPANSQL_REAUTH" help:"When auto, run gcloud application-default login once if local user ADC needs reauthentication; off only prints a hint."` TryPartitionQuery bool `name:"try-partition-query" help:"(Experimental) Check whether the query can be executed as partition query or not"` TimestampBound struct { Strong bool `name:"strong" xor:"timestamp" help:"Perform a strong query."` @@ -413,14 +415,24 @@ func _main() error { } // runCLI accepts client options so transport tests can inspect outgoing RPCs. -func runCLI(clientOptions ...option.ClientOption) error { +// Non-empty clientOptions skip the ADC reauth preflight (tests inject insecure +// dial options that bypass application-default credentials). +func runCLI(clientOptions ...option.ClientOption) (err error) { o, err := processFlags() if err != nil { os.Exit(1) } + defer func() { err = wrapWithHint(err) }() - ctx, cancel := context.WithTimeout(context.Background(), o.Timeout) - defer cancel() + // The first interrupt cancels ctx so an in-progress gcloud login or query + // unwinds cleanly; stop() then restores default signal handling so a + // second interrupt still terminates the process if shutdown hangs. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + go func() { + <-ctx.Done() + stop() + }() jqMode, err := jqresult.ParseInputMode(o.JqInputMode) if err != nil { @@ -469,12 +481,34 @@ func runCLI(clientOptions ...option.ClientOption) error { return err } + // Freeze the statement (SQL and parameters) before any interactive step so + // a parameter file edited during a browser login cannot change what runs. + paramStrMap, err := o.mergedParams() + if err != nil { + return err + } + paramMap, err := params.GenerateParams(paramStrMap, mode == sppb.ExecuteSqlRequest_PLAN) + if err != nil { + return err + } + stmt := spanner.Statement{SQL: query, Params: paramMap} + sinks, err := newOutputSinks(o) if err != nil { return err } defer sinks.Abort() + authOpts, err := maybeAuthPreflight(ctx, o, clientOptions, newReauthHooks()) + if err != nil { + return err + } + + // Query execution timeout starts after authentication. Login is + // human-paced and must not consume --timeout. + ctx, cancel := context.WithTimeout(ctx, o.Timeout) + defer cancel() + ctx, tp, err := enableTracing(ctx, o) if err != nil { return err @@ -487,23 +521,12 @@ func runCLI(clientOptions ...option.ClientOption) error { }() } - client, err := newClient(ctx, o.Project, o.Instance, o.Database, o.DatabaseRole, string(o.LogGrpc), tracingEnabled(o), clientOptions...) + client, err := newClient(ctx, o.Project, o.Instance, o.Database, o.DatabaseRole, string(o.LogGrpc), tracingEnabled(o), append(clientOptions, authOpts...)...) if err != nil { return err } defer client.Close() - paramStrMap, err := o.mergedParams() - if err != nil { - return err - } - paramMap, err := params.GenerateParams(paramStrMap, mode == sppb.ExecuteSqlRequest_PLAN) - if err != nil { - return err - } - - stmt := spanner.Statement{SQL: query, Params: paramMap} - if o.TryPartitionQuery { bt, err := client.BatchReadOnlyTransaction(ctx, tb) if err != nil { diff --git a/reauth.go b/reauth.go new file mode 100644 index 0000000..98f97a4 --- /dev/null +++ b/reauth.go @@ -0,0 +1,400 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + + "cloud.google.com/go/auth" + "cloud.google.com/go/auth/credentials" + apiv1 "cloud.google.com/go/spanner/apiv1" + "golang.org/x/oauth2" + "golang.org/x/term" + "google.golang.org/api/option" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + reauthModeOff = "off" + reauthModeAuto = "auto" + + adcFileName = "application_default_credentials.json" + + reauthHintText = "Reauthentication is needed. Please run 'gcloud auth application-default login' to reauthenticate." + + gcloudADCLoginNotice = "Reauthentication is needed; updating Application Default Credentials via gcloud auth application-default login." +) + +const ( + envSpannerEmulatorHost = "SPANNER_EMULATOR_HOST" + envAppCredentials = "GOOGLE_APPLICATION_CREDENTIALS" + envCloudSDKConfig = "CLOUDSDK_CONFIG" + envSSHConnection = "SSH_CONNECTION" + envSSHTTY = "SSH_TTY" + envDisplay = "DISPLAY" +) + +// reauthClass distinguishes a typed token error (login-eligible) from a +// message-only match that may only receive a hint. +type reauthClass int + +const ( + reauthClassNone reauthClass = iota + reauthClassTyped + reauthClassHint +) + +var errLoginBudgetSpent = errors.New("reauth login budget already spent") + +type adcSnapshot struct { + Type string + Writable bool + Contents []byte +} + +// reauthHooks are the injectable seams for the ADC preflight. Production +// wiring uses productionReauthHooks. Tests replace individual fields. +type reauthHooks struct { + detect func(ctx context.Context) (*auth.Credentials, error) + fetchToken func(ctx context.Context, creds *auth.Credentials) error + login func(ctx context.Context) error + getenv func(key string) string + lookPath func(file string) (string, error) + isTerminal func(fd int) bool + stdinFD int + stderrFD int + wellKnownPath func() string + inspectADC func(path string) (adcSnapshot, error) + stderr io.Writer + + mu sync.Mutex + loginUsed bool +} + +func productionReauthHooks() *reauthHooks { + return &reauthHooks{ + detect: detectDefaultCredentials, + fetchToken: func(ctx context.Context, creds *auth.Credentials) error { + if creds == nil { + return errors.New("no credentials") + } + _, err := creds.Token(ctx) + return err + }, + login: func(ctx context.Context) error { + return runGcloudADCLogin(ctx, os.Getenv, exec.LookPath) + }, + getenv: os.Getenv, + lookPath: exec.LookPath, + isTerminal: term.IsTerminal, + stdinFD: int(os.Stdin.Fd()), + stderrFD: int(os.Stderr.Fd()), + wellKnownPath: wellKnownADCPath, + inspectADC: inspectADCFile, + stderr: os.Stderr, + } +} + +var newReauthHooks = productionReauthHooks + +func detectDefaultCredentials(context.Context) (*auth.Credentials, error) { + return credentials.DetectDefault(&credentials.DetectOptions{ + Scopes: apiv1.DefaultAuthScopes(), + }) +} + +// wellKnownADCPath is the path the Go auth library reads. It ignores +// CLOUDSDK_CONFIG; gcloud honors that variable, which is why a set +// CLOUDSDK_CONFIG makes automatic login inapplicable. +func wellKnownADCPath() string { + if runtime.GOOS == "windows" { + return filepath.Join(os.Getenv("APPDATA"), "gcloud", adcFileName) + } + return filepath.Join(os.Getenv("HOME"), ".config", "gcloud", adcFileName) +} + +func inspectADCFile(path string) (adcSnapshot, error) { + data, err := os.ReadFile(path) + if err != nil { + return adcSnapshot{}, err + } + snap := adcSnapshot{ + Type: credentialJSONType(data), + Contents: data, + } + f, err := os.OpenFile(path, os.O_WRONLY, 0) + if err != nil { + return snap, nil + } + snap.Writable = true + _ = f.Close() + return snap, nil +} + +func credentialJSONType(raw []byte) string { + var parsed struct { + Type string `json:"type"` + } + if json.Unmarshal(raw, &parsed) != nil { + return "" + } + return parsed.Type +} + +func isAuthorizedUserType(typ string) bool { + return typ == string(credentials.AuthorizedUser) +} + +func classifyReauthError(err error) reauthClass { + if err == nil { + return reauthClassNone + } + if isTypedReauthError(err) { + return reauthClassTyped + } + if hasReauthSubstring(err.Error()) { + return reauthClassHint + } + return reauthClassNone +} + +// isReauthError reports whether err is a typed reauthentication failure +// (invalid_grant with error_subtype invalid_rapt or rapt_required). +// Message-only matches are not login-eligible. +func isReauthError(err error) bool { + return classifyReauthError(err) == reauthClassTyped +} + +func isTypedReauthError(err error) bool { + var ae *auth.Error + if errors.As(err, &ae) { + return bodyIsRAPTGrant(ae.Body) + } + var re *oauth2.RetrieveError + if errors.As(err, &re) { + return retrieveErrorIsRAPTGrant(re) + } + return false +} + +func retrieveErrorIsRAPTGrant(re *oauth2.RetrieveError) bool { + code, subtype, parsed := parseOAuthErrorBody(re.Body) + if re.ErrorCode != "" { + code = re.ErrorCode + } + if !parsed && re.ErrorCode == "" { + return false + } + return isRAPTGrant(code, subtype) +} + +func bodyIsRAPTGrant(body []byte) bool { + code, subtype, ok := parseOAuthErrorBody(body) + if !ok { + return false + } + return isRAPTGrant(code, subtype) +} + +func parseOAuthErrorBody(body []byte) (code, subtype string, ok bool) { + var parsed struct { + Error string `json:"error"` + ErrorSubtype string `json:"error_subtype"` + } + if json.Unmarshal(body, &parsed) != nil { + return "", "", false + } + return parsed.Error, parsed.ErrorSubtype, true +} + +func isRAPTGrant(code, subtype string) bool { + if code != "invalid_grant" { + return false + } + return subtype == "invalid_rapt" || subtype == "rapt_required" +} + +func hasReauthSubstring(s string) bool { + return strings.Contains(s, "invalid_rapt") || strings.Contains(s, "rapt_required") +} + +func needsReauthHint(err error) bool { + if err == nil { + return false + } + if isReauthError(err) { + return true + } + if st, ok := status.FromError(err); ok && st.Code() == codes.Unauthenticated && hasReauthSubstring(st.Message()) { + return true + } + return hasReauthSubstring(err.Error()) +} + +func wrapWithHint(err error) error { + if err == nil { + return nil + } + if !needsReauthHint(err) { + return err + } + if strings.Contains(err.Error(), reauthHintText) { + return err + } + var b strings.Builder + b.WriteString(reauthHintText) + if os.Getenv(envAppCredentials) != "" { + b.WriteString("\nGOOGLE_APPLICATION_CREDENTIALS is set; gcloud writes the well-known ADC file, not that path.") + } + if os.Getenv(envCloudSDKConfig) != "" { + b.WriteString("\nCLOUDSDK_CONFIG is set; the Go auth library does not read that config directory.") + } + return fmt.Errorf("%w\n%s", err, b.String()) +} + +func reauthApplicable(o opts, injectedClientOptions []option.ClientOption, h *reauthHooks) bool { + if o.Reauth != reauthModeAuto { + return false + } + if len(injectedClientOptions) > 0 { + return false + } + if h.getenv(envSpannerEmulatorHost) != "" { + return false + } + if h.getenv(envAppCredentials) != "" { + return false + } + if h.getenv(envCloudSDKConfig) != "" { + return false + } + if !h.isTerminal(h.stdinFD) || !h.isTerminal(h.stderrFD) { + return false + } + if _, err := h.lookPath("gcloud"); err != nil { + return false + } + snap, err := h.inspectADC(h.wellKnownPath()) + if err != nil { + return false + } + return snap.Writable && isAuthorizedUserType(snap.Type) +} + +func useNoLaunchBrowser(getenv func(string) string) bool { + if getenv(envSSHConnection) != "" || getenv(envSSHTTY) != "" { + return true + } + return runtime.GOOS == "linux" && getenv(envDisplay) == "" +} + +func gcloudADCLoginArgs(getenv func(string) string) []string { + args := []string{"auth", "application-default", "login"} + if useNoLaunchBrowser(getenv) { + args = append(args, "--no-launch-browser") + } + return args +} + +// runGcloudADCLogin runs `gcloud auth application-default login` with a +// fixed argument vector. Stdout is attached to stderr so query output is +// not mixed with gcloud's instructions. Tokens and ADC JSON are not logged. +// +// On Windows the SDK installs gcloud as gcloud.cmd. exec.LookPath resolves +// it through PATHEXT and CreateProcess launches batch files through cmd.exe +// implicitly, so no explicit interpreter is needed (this is the same +// mechanism os/exec documents under its cmd.exe quoting caveat). The cmd.exe +// unquoting differences do not matter here because every argument is a fixed +// literal without spaces or metacharacters; only the resolved path may +// contain spaces, and Go quotes argv[0] like any other argument. +func runGcloudADCLogin(ctx context.Context, getenv func(string) string, lookPath func(string) (string, error)) error { + bin, err := lookPath("gcloud") + if err != nil { + return err + } + cmd := exec.CommandContext(ctx, bin, gcloudADCLoginArgs(getenv)...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func (h *reauthHooks) tryLogin(ctx context.Context) error { + h.mu.Lock() + defer h.mu.Unlock() + if h.loginUsed { + return errLoginBudgetSpent + } + h.loginUsed = true + _, _ = fmt.Fprintln(h.stderr, gcloudADCLoginNotice) + return h.login(ctx) +} + +func quotaProjectID(ctx context.Context, creds *auth.Credentials) string { + if creds == nil { + return "" + } + id, err := creds.QuotaProjectID(ctx) + if err != nil { + return "" + } + return id +} + +func maybeAuthPreflight(ctx context.Context, o opts, injectedClientOptions []option.ClientOption, h *reauthHooks) ([]option.ClientOption, error) { + if o.Reauth != reauthModeAuto || len(injectedClientOptions) > 0 || !reauthApplicable(o, injectedClientOptions, h) { + return nil, nil + } + return runAuthPreflight(ctx, h) +} + +func runAuthPreflight(ctx context.Context, h *reauthHooks) ([]option.ClientOption, error) { + creds, err := h.detect(ctx) + if err == nil { + err = h.fetchToken(ctx, creds) + } + if !isReauthError(err) { + if err != nil { + return nil, err + } + return []option.ClientOption{option.WithAuthCredentials(creds)}, nil + } + orig := err + before, _ := h.inspectADC(h.wellKnownPath()) + quotaBefore := quotaProjectID(ctx, creds) + if err := h.tryLogin(ctx); err != nil { + if !errors.Is(err, errLoginBudgetSpent) { + _, _ = fmt.Fprintln(h.stderr, "gcloud auth application-default login failed") + } + return nil, orig + } + after, inspErr := h.inspectADC(h.wellKnownPath()) + if inspErr != nil || !isAuthorizedUserType(after.Type) || bytes.Equal(after.Contents, before.Contents) { + return nil, orig + } + creds, err = h.detect(ctx) + if err != nil { + return nil, orig + } + if !isAuthorizedUserType(credentialJSONType(creds.JSON())) { + return nil, orig + } + if err := h.fetchToken(ctx, creds); err != nil { + return nil, orig + } + quotaAfter := quotaProjectID(ctx, creds) + if quotaBefore != quotaAfter { + _, _ = fmt.Fprintf(h.stderr, "warning: quota project ID changed from %q to %q after reauthentication\n", quotaBefore, quotaAfter) + } + return []option.ClientOption{option.WithAuthCredentials(creds)}, nil +} diff --git a/reauth_test.go b/reauth_test.go new file mode 100644 index 0000000..296429e --- /dev/null +++ b/reauth_test.go @@ -0,0 +1,864 @@ +package main + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "cloud.google.com/go/auth" + "cloud.google.com/go/auth/credentials" + "github.com/alecthomas/kong" + "golang.org/x/oauth2" + "google.golang.org/api/option" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var ( + raptJSON = []byte(`{"error":"invalid_grant","error_description":"reauth related error (invalid_rapt)","error_uri":"https://support.google.com/a/answer/9368756","error_subtype":"invalid_rapt"}`) + raptRequiredJSON = []byte(`{"error":"invalid_grant","error_subtype":"rapt_required"}`) + revokedJSON = []byte(`{"error":"invalid_grant","error_description":"Token has been expired or revoked."}`) +) + +func newAuthErr(body []byte) *auth.Error { + // auth.Error.Error() dereferences Response when the unexported code is empty. + return &auth.Error{ + Response: &http.Response{StatusCode: http.StatusBadRequest}, + Body: body, + } +} + +func TestClassifyReauthError(t *testing.T) { + t.Parallel() + + grpcRAPT := status.Error(codes.Unauthenticated, `transport: per-RPC creds failed due to error: auth: "invalid_grant" "reauth related error (invalid_rapt)"`) + + tests := []struct { + name string + err error + want reauthClass + }{ + {name: "nil", want: reauthClassNone}, + {name: "auth_invalid_rapt", err: newAuthErr(raptJSON), want: reauthClassTyped}, + {name: "auth_rapt_required", err: newAuthErr(raptRequiredJSON), want: reauthClassTyped}, + {name: "auth_revoked_invalid_grant", err: newAuthErr(revokedJSON), want: reauthClassNone}, + {name: "auth_non_json_body", err: newAuthErr([]byte("not json")), want: reauthClassNone}, + {name: "auth_wrapped_invalid_rapt", err: fmtWrap(newAuthErr(raptJSON)), want: reauthClassTyped}, + { + name: "oauth2_invalid_rapt", + err: &oauth2.RetrieveError{ErrorCode: "invalid_grant", Body: raptJSON}, + want: reauthClassTyped, + }, + { + name: "oauth2_rapt_required", + err: &oauth2.RetrieveError{ErrorCode: "invalid_grant", Body: raptRequiredJSON}, + want: reauthClassTyped, + }, + { + name: "oauth2_revoked_invalid_grant", + err: &oauth2.RetrieveError{ + ErrorCode: "invalid_grant", + ErrorDescription: "Token has been expired or revoked.", + Body: revokedJSON, + }, + want: reauthClassNone, + }, + { + name: "oauth2_non_json_body", + err: &oauth2.RetrieveError{ErrorCode: "invalid_grant", Body: []byte("not json")}, + want: reauthClassNone, + }, + { + name: "oauth2_body_only_invalid_rapt", + err: &oauth2.RetrieveError{Body: raptJSON}, + want: reauthClassTyped, + }, + {name: "grpc_unauthenticated_rapt_hint_only", err: grpcRAPT, want: reauthClassHint}, + {name: "unrelated", err: errors.New("connection refused"), want: reauthClassNone}, + {name: "permission_denied", err: status.Error(codes.PermissionDenied, "denied"), want: reauthClassNone}, + {name: "unauthenticated_without_rapt", err: status.Error(codes.Unauthenticated, "missing credentials"), want: reauthClassNone}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := classifyReauthError(tt.err) + if got != tt.want { + t.Fatalf("classifyReauthError() = %v, want %v (err=%v)", got, tt.want, tt.err) + } + if tt.want == reauthClassTyped && !isReauthError(tt.err) { + t.Fatal("isReauthError() = false, want true for typed reauth") + } + if tt.want != reauthClassTyped && isReauthError(tt.err) { + t.Fatal("isReauthError() = true, want false") + } + }) + } +} + +func fmtWrap(err error) error { + return errors.Join(err) +} + +func TestWrapWithHint(t *testing.T) { + t.Setenv(envAppCredentials, "") + t.Setenv(envCloudSDKConfig, "") + + typed := newAuthErr(raptJSON) + hinted := wrapWithHint(typed) + if hinted == nil || !strings.Contains(hinted.Error(), reauthHintText) { + t.Fatalf("typed wrap = %v, want hint", hinted) + } + if !errors.Is(hinted, typed) { + t.Fatal("wrapWithHint should wrap the original typed error") + } + + grpcErr := status.Error(codes.Unauthenticated, `transport: per-RPC creds failed due to error: auth: "invalid_grant" "reauth related error (invalid_rapt)"`) + got := wrapWithHint(grpcErr) + if got == nil || !strings.Contains(got.Error(), reauthHintText) { + t.Fatalf("unauthenticated wrap = %v, want hint", got) + } + + unrelated := errors.New("boom") + if wrapWithHint(unrelated) != unrelated { + t.Fatalf("unrelated error was rewritten: %v", wrapWithHint(unrelated)) + } + + already := wrapWithHint(typed) + if wrapWithHint(already) != already && strings.Count(wrapWithHint(already).Error(), reauthHintText) != 1 { + t.Fatal("hint was applied more than once") + } +} + +func TestWrapWithHintMentionsEnv(t *testing.T) { + t.Setenv(envCloudSDKConfig, "") + t.Setenv(envAppCredentials, "/tmp/creds.json") + got := wrapWithHint(newAuthErr(raptJSON)).Error() + if !strings.Contains(got, "GOOGLE_APPLICATION_CREDENTIALS") { + t.Fatalf("hint = %q, want GOOGLE_APPLICATION_CREDENTIALS", got) + } + + t.Setenv(envAppCredentials, "") + t.Setenv(envCloudSDKConfig, "/tmp/gcloud-config") + got = wrapWithHint(newAuthErr(raptJSON)).Error() + if !strings.Contains(got, "CLOUDSDK_CONFIG") { + t.Fatalf("hint = %q, want CLOUDSDK_CONFIG", got) + } +} + +func TestProcessFlagsReauth(t *testing.T) { + oldArgs := os.Args + t.Cleanup(func() { os.Args = oldArgs }) + + base := []string{"execspansql", "database", "--project", "p", "--instance", "i", "--sql", "SELECT 1"} + tests := []struct { + name string + env string + args []string + want string + wantErr string + }{ + {name: "default_off", args: base, want: reauthModeOff}, + {name: "flag_auto", args: append(append([]string{}, base...), "--reauth", "auto"), want: reauthModeAuto}, + {name: "flag_off", args: append(append([]string{}, base...), "--reauth", "off"), want: reauthModeOff}, + {name: "env_auto", env: reauthModeAuto, args: base, want: reauthModeAuto}, + {name: "invalid", args: append(append([]string{}, base...), "--reauth", "prompt"), wantErr: "--reauth must be one of"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != "" { + t.Setenv("EXECSPANSQL_REAUTH", tt.env) + } else { + t.Setenv("EXECSPANSQL_REAUTH", "") + _ = os.Unsetenv("EXECSPANSQL_REAUTH") + } + os.Args = tt.args + got, err := processFlags() + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("processFlags() error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + if got.Reauth != tt.want { + t.Fatalf("Reauth = %q, want %q", got.Reauth, tt.want) + } + }) + } +} + +func TestGcloudADCLoginArgs(t *testing.T) { + t.Parallel() + + plain := gcloudADCLoginArgs(func(k string) string { + if k == envDisplay { + return ":0" + } + return "" + }) + if want := []string{"auth", "application-default", "login"}; !equalStrings(plain, want) { + t.Fatalf("args = %v, want %v", plain, want) + } + ssh := gcloudADCLoginArgs(func(k string) string { + if k == envSSHConnection { + return "1 2 3 4" + } + return "" + }) + if want := []string{"auth", "application-default", "login", "--no-launch-browser"}; !equalStrings(ssh, want) { + t.Fatalf("ssh args = %v, want %v", ssh, want) + } +} + +func TestReauthApplicability(t *testing.T) { + authorized := adcSnapshot{ + Type: string(credentials.AuthorizedUser), + Writable: true, + Contents: []byte(`{"type":"authorized_user"}`), + } + base := func() *reauthHooks { + return &reauthHooks{ + getenv: func(string) string { return "" }, + lookPath: func(string) (string, error) { return "/usr/bin/gcloud", nil }, + isTerminal: func(int) bool { return true }, + stdinFD: 1, + stderrFD: 2, + wellKnownPath: func() string { return "/tmp/adc.json" }, + inspectADC: func(string) (adcSnapshot, error) { return authorized, nil }, + } + } + + tests := []struct { + name string + o opts + injected []option.ClientOption + hooks func() *reauthHooks + want bool + }{ + {name: "all_hold", o: opts{Reauth: reauthModeAuto}, hooks: base, want: true}, + {name: "reauth_off", o: opts{Reauth: reauthModeOff}, hooks: base}, + { + name: "injected_client_options", + o: opts{Reauth: reauthModeAuto}, + injected: []option.ClientOption{option.WithoutAuthentication()}, + hooks: base, + }, + { + name: "emulator_host", + o: opts{Reauth: reauthModeAuto}, + hooks: func() *reauthHooks { + h := base() + h.getenv = getenvMap(map[string]string{envSpannerEmulatorHost: "localhost:9010"}) + return h + }, + }, + { + name: "application_credentials", + o: opts{Reauth: reauthModeAuto}, + hooks: func() *reauthHooks { + h := base() + h.getenv = getenvMap(map[string]string{envAppCredentials: "/tmp/sa.json"}) + return h + }, + }, + { + name: "cloudsdk_config", + o: opts{Reauth: reauthModeAuto}, + hooks: func() *reauthHooks { + h := base() + h.getenv = getenvMap(map[string]string{envCloudSDKConfig: "/tmp/gcloud"}) + return h + }, + }, + { + name: "missing_file", + o: opts{Reauth: reauthModeAuto}, + hooks: func() *reauthHooks { + h := base() + h.inspectADC = func(string) (adcSnapshot, error) { return adcSnapshot{}, os.ErrNotExist } + return h + }, + }, + { + name: "unwritable_file", + o: opts{Reauth: reauthModeAuto}, + hooks: func() *reauthHooks { + h := base() + h.inspectADC = func(string) (adcSnapshot, error) { + s := authorized + s.Writable = false + return s, nil + } + return h + }, + }, + { + name: "service_account_type", + o: opts{Reauth: reauthModeAuto}, + hooks: func() *reauthHooks { + h := base() + h.inspectADC = func(string) (adcSnapshot, error) { + return adcSnapshot{Type: string(credentials.ServiceAccount), Writable: true, Contents: []byte(`{"type":"service_account"}`)}, nil + } + return h + }, + }, + { + name: "non_tty_stdin", + o: opts{Reauth: reauthModeAuto}, + hooks: func() *reauthHooks { + h := base() + h.isTerminal = func(fd int) bool { return fd != h.stdinFD } + return h + }, + }, + { + name: "non_tty_stderr", + o: opts{Reauth: reauthModeAuto}, + hooks: func() *reauthHooks { + h := base() + h.isTerminal = func(fd int) bool { return fd != h.stderrFD } + return h + }, + }, + { + name: "gcloud_missing", + o: opts{Reauth: reauthModeAuto}, + hooks: func() *reauthHooks { + h := base() + h.lookPath = func(string) (string, error) { return "", os.ErrNotExist } + return h + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := reauthApplicable(tt.o, tt.injected, tt.hooks()) + if got != tt.want { + t.Fatalf("reauthApplicable() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestReauthApplicabilityEnvAndHome(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv(envSpannerEmulatorHost, "") + t.Setenv(envAppCredentials, "") + t.Setenv(envCloudSDKConfig, "") + + adcDir := filepath.Join(home, ".config", "gcloud") + if err := os.MkdirAll(adcDir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(adcDir, adcFileName) + if err := os.WriteFile(path, []byte(`{"type":"authorized_user"}`), 0o600); err != nil { + t.Fatal(err) + } + + hooks := func() *reauthHooks { + h := productionReauthHooks() + h.getenv = os.Getenv + h.isTerminal = func(int) bool { return true } + h.lookPath = func(string) (string, error) { return "/usr/bin/gcloud", nil } + return h + } + o := opts{Reauth: reauthModeAuto} + if !reauthApplicable(o, nil, hooks()) { + t.Fatal("want applicable with temp HOME ADC") + } + if wellKnownADCPath() != path { + t.Fatalf("wellKnownADCPath() = %q, want %q", wellKnownADCPath(), path) + } + + t.Run("emulator", func(t *testing.T) { + t.Setenv(envSpannerEmulatorHost, "localhost:9010") + if reauthApplicable(o, nil, hooks()) { + t.Fatal("SPANNER_EMULATOR_HOST should disable auto reauth") + } + }) + t.Run("gac", func(t *testing.T) { + t.Setenv(envAppCredentials, filepath.Join(home, "sa.json")) + if reauthApplicable(o, nil, hooks()) { + t.Fatal("GOOGLE_APPLICATION_CREDENTIALS should disable auto reauth") + } + }) + t.Run("cloudsdk_config", func(t *testing.T) { + t.Setenv(envCloudSDKConfig, filepath.Join(home, "gcloud-config")) + if reauthApplicable(o, nil, hooks()) { + t.Fatal("CLOUDSDK_CONFIG should disable auto reauth") + } + }) + t.Run("missing_file", func(t *testing.T) { + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if reauthApplicable(o, nil, hooks()) { + t.Fatal("missing ADC file should disable auto reauth") + } + }) + t.Run("wrong_type", func(t *testing.T) { + if err := os.WriteFile(path, []byte(`{"type":"service_account"}`), 0o600); err != nil { + t.Fatal(err) + } + if reauthApplicable(o, nil, hooks()) { + t.Fatal("non-authorized_user ADC should disable auto reauth") + } + }) + t.Run("unwritable", func(t *testing.T) { + if err := os.WriteFile(path, []byte(`{"type":"authorized_user"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o400); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(path, 0o600) }) + if reauthApplicable(o, nil, hooks()) { + t.Fatal("unwritable ADC file should disable auto reauth") + } + }) +} + +func TestReauthPreflight(t *testing.T) { + raptErr := newAuthErr(raptJSON) + auto := opts{Reauth: reauthModeAuto} + off := opts{Reauth: reauthModeOff} + + t.Run("off_no_detection", func(t *testing.T) { + h, loginCalls, detectCalls := newPreflightHooks(t, raptErr) + opts, err := runPreflight(context.Background(), off, nil, h) + if err != nil { + t.Fatal(err) + } + if opts != nil { + t.Fatalf("client options = %v, want nil", opts) + } + if *detectCalls != 0 || *loginCalls != 0 { + t.Fatalf("detect=%d login=%d, want 0, 0", *detectCalls, *loginCalls) + } + }) + + t.Run("reauth_off_hint_only", func(t *testing.T) { + h, loginCalls, _ := newPreflightHooks(t, raptErr) + _, err := runPreflight(context.Background(), off, nil, h) + if err != nil { + t.Fatal(err) + } + if *loginCalls != 0 { + t.Fatalf("login calls = %d, want 0", *loginCalls) + } + hinted := wrapWithHint(raptErr) + if !strings.Contains(hinted.Error(), reauthHintText) { + t.Fatalf("hint = %v", hinted) + } + }) + + t.Run("auto_success_no_login", func(t *testing.T) { + h, loginCalls, detectCalls := newPreflightHooks(t, nil) + opts, err := runPreflight(context.Background(), auto, nil, h) + if err != nil { + t.Fatal(err) + } + if len(opts) != 1 { + t.Fatalf("client options len = %d, want 1", len(opts)) + } + if *loginCalls != 0 || *detectCalls != 1 { + t.Fatalf("detect=%d login=%d, want 1, 0", *detectCalls, *loginCalls) + } + }) + + t.Run("auto_reauth_one_login", func(t *testing.T) { + h, loginCalls, detectCalls, stderr := newMutablePreflightHooks(t, raptErr) + h.login = func(context.Context) error { + *loginCalls++ + h.inspectADC = func(string) (adcSnapshot, error) { + return adcSnapshot{ + Type: string(credentials.AuthorizedUser), + Writable: true, + Contents: []byte(`{"type":"authorized_user","refresh_token":"new"}`), + }, nil + } + return nil + } + fetch := 0 + h.fetchToken = func(context.Context, *auth.Credentials) error { + fetch++ + if fetch == 1 { + return raptErr + } + return nil + } + opts, err := runPreflight(context.Background(), auto, nil, h) + if err != nil { + t.Fatal(err) + } + if len(opts) != 1 { + t.Fatalf("client options len = %d, want 1", len(opts)) + } + if *loginCalls != 1 { + t.Fatalf("login calls = %d, want 1", *loginCalls) + } + if *detectCalls != 2 { + t.Fatalf("detect calls = %d, want 2", *detectCalls) + } + if fetch != 2 { + t.Fatalf("fetch calls = %d, want 2", fetch) + } + if !strings.Contains(stderr.String(), gcloudADCLoginNotice) { + t.Fatalf("stderr = %q, want login notice", stderr.String()) + } + }) + + t.Run("login_fails", func(t *testing.T) { + h, loginCalls, detectCalls := newPreflightHooks(t, raptErr) + h.login = func(context.Context) error { + *loginCalls++ + return errors.New("gcloud failed") + } + h.fetchToken = func(context.Context, *auth.Credentials) error { return raptErr } + _, err := runPreflight(context.Background(), auto, nil, h) + if err == nil || !strings.Contains(err.Error(), reauthHintText) { + t.Fatalf("error = %v, want original plus hint", err) + } + if !errors.Is(err, raptErr) { + t.Fatalf("error = %v, want original typed error", err) + } + if *loginCalls != 1 || *detectCalls != 1 { + t.Fatalf("detect=%d login=%d, want 1, 1 (no second login)", *detectCalls, *loginCalls) + } + }) + + t.Run("reloaded_unchanged", func(t *testing.T) { + h, loginCalls, detectCalls := newPreflightHooks(t, raptErr) + h.login = func(context.Context) error { *loginCalls++; return nil } + h.fetchToken = func(context.Context, *auth.Credentials) error { return raptErr } + _, err := runPreflight(context.Background(), auto, nil, h) + if err == nil || !errors.Is(err, raptErr) || !strings.Contains(err.Error(), reauthHintText) { + t.Fatalf("error = %v, want original plus hint", err) + } + if *loginCalls != 1 || *detectCalls != 1 { + t.Fatalf("detect=%d login=%d, want no loop", *detectCalls, *loginCalls) + } + }) + + t.Run("reloaded_wrong_type", func(t *testing.T) { + h, loginCalls, detectCalls := newPreflightHooks(t, raptErr) + h.login = func(context.Context) error { + *loginCalls++ + h.inspectADC = func(string) (adcSnapshot, error) { + return adcSnapshot{Type: string(credentials.ServiceAccount), Writable: true, Contents: []byte(`{"type":"service_account"}`)}, nil + } + return nil + } + h.fetchToken = func(context.Context, *auth.Credentials) error { return raptErr } + _, err := runPreflight(context.Background(), auto, nil, h) + if err == nil || !errors.Is(err, raptErr) { + t.Fatalf("error = %v, want original", err) + } + if *loginCalls != 1 || *detectCalls != 1 { + t.Fatalf("detect=%d login=%d, want no loop", *detectCalls, *loginCalls) + } + }) + + t.Run("reloaded_unreadable", func(t *testing.T) { + h, loginCalls, detectCalls := newPreflightHooks(t, raptErr) + h.login = func(context.Context) error { + *loginCalls++ + h.inspectADC = func(string) (adcSnapshot, error) { return adcSnapshot{}, errors.New("unreadable") } + return nil + } + h.fetchToken = func(context.Context, *auth.Credentials) error { return raptErr } + _, err := runPreflight(context.Background(), auto, nil, h) + if err == nil || !errors.Is(err, raptErr) { + t.Fatalf("error = %v, want original", err) + } + if *loginCalls != 1 || *detectCalls != 1 { + t.Fatalf("detect=%d login=%d, want no loop", *detectCalls, *loginCalls) + } + }) + + t.Run("second_fetch_fails", func(t *testing.T) { + h, loginCalls, detectCalls := newPreflightHooks(t, raptErr) + h.login = func(context.Context) error { + *loginCalls++ + h.inspectADC = func(string) (adcSnapshot, error) { + return adcSnapshot{ + Type: string(credentials.AuthorizedUser), + Writable: true, + Contents: []byte(`{"type":"authorized_user","refresh_token":"new"}`), + }, nil + } + return nil + } + h.fetchToken = func(context.Context, *auth.Credentials) error { return raptErr } + _, err := runPreflight(context.Background(), auto, nil, h) + if err == nil || !errors.Is(err, raptErr) || !strings.Contains(err.Error(), reauthHintText) { + t.Fatalf("error = %v, want original plus hint", err) + } + if *loginCalls != 1 || *detectCalls != 2 { + t.Fatalf("detect=%d login=%d, want 2, 1", *detectCalls, *loginCalls) + } + }) + + t.Run("budget_already_spent", func(t *testing.T) { + h, loginCalls, _ := newPreflightHooks(t, raptErr) + h.loginUsed = true + h.fetchToken = func(context.Context, *auth.Credentials) error { return raptErr } + _, err := runPreflight(context.Background(), auto, nil, h) + if err == nil || !errors.Is(err, raptErr) || !strings.Contains(err.Error(), reauthHintText) { + t.Fatalf("error = %v, want original plus hint", err) + } + if *loginCalls != 0 { + t.Fatalf("login calls = %d, want 0", *loginCalls) + } + }) + + t.Run("quota_project_changed", func(t *testing.T) { + h, _, _, stderr := newMutablePreflightHooks(t, raptErr) + firstJSON := []byte(`{"type":"authorized_user","quota_project_id":"old"}`) + secondJSON := []byte(`{"type":"authorized_user","quota_project_id":"new","refresh_token":"new"}`) + detect := 0 + h.detect = func(context.Context) (*auth.Credentials, error) { + detect++ + if detect == 1 { + return credsWithQuota("old", firstJSON), nil + } + return credsWithQuota("new", secondJSON), nil + } + h.login = func(context.Context) error { + h.inspectADC = func(string) (adcSnapshot, error) { + return adcSnapshot{Type: string(credentials.AuthorizedUser), Writable: true, Contents: secondJSON}, nil + } + return nil + } + fetch := 0 + h.fetchToken = func(context.Context, *auth.Credentials) error { + fetch++ + if fetch == 1 { + return raptErr + } + return nil + } + _, err := runPreflight(context.Background(), auto, nil, h) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stderr.String(), "old") || !strings.Contains(stderr.String(), "new") { + t.Fatalf("stderr = %q, want quota project difference", stderr.String()) + } + }) + + t.Run("injected_options_skip_detect", func(t *testing.T) { + h, loginCalls, detectCalls := newPreflightHooks(t, raptErr) + _, err := runPreflight(context.Background(), auto, []option.ClientOption{option.WithoutAuthentication()}, h) + if err != nil { + t.Fatal(err) + } + if *detectCalls != 0 || *loginCalls != 0 { + t.Fatalf("detect=%d login=%d, want skipped", *detectCalls, *loginCalls) + } + }) +} + +func runPreflight(ctx context.Context, o opts, injected []option.ClientOption, h *reauthHooks) ([]option.ClientOption, error) { + opts, err := maybeAuthPreflight(ctx, o, injected, h) + return opts, wrapWithHint(err) +} + +func newPreflightHooks(t *testing.T, tokenErr error) (*reauthHooks, *int, *int) { + t.Helper() + h, loginCalls, detectCalls, _ := newMutablePreflightHooks(t, tokenErr) + return h, loginCalls, detectCalls +} + +func newMutablePreflightHooks(t *testing.T, tokenErr error) (*reauthHooks, *int, *int, *bytes.Buffer) { + t.Helper() + var stderr bytes.Buffer + var loginCalls, detectCalls int + snap := adcSnapshot{ + Type: string(credentials.AuthorizedUser), + Writable: true, + Contents: []byte(`{"type":"authorized_user","refresh_token":"old"}`), + } + h := &reauthHooks{ + getenv: func(string) string { return "" }, + lookPath: func(string) (string, error) { return "/usr/bin/gcloud", nil }, + isTerminal: func(int) bool { return true }, + wellKnownPath: func() string { return "/tmp/adc.json" }, + inspectADC: func(string) (adcSnapshot, error) { return snap, nil }, + stderr: &stderr, + detect: func(context.Context) (*auth.Credentials, error) { + detectCalls++ + return credsWithQuota("proj", snap.Contents), nil + }, + fetchToken: func(context.Context, *auth.Credentials) error { return tokenErr }, + login: func(context.Context) error { + loginCalls++ + t.Fatal("unexpected login") + return nil + }, + } + return h, &loginCalls, &detectCalls, &stderr +} + +func credsWithQuota(quota string, raw []byte) *auth.Credentials { + return auth.NewCredentials(&auth.CredentialsOptions{ + TokenProvider: tokenStub{tok: &auth.Token{Value: "ya29.fake"}}, + JSON: raw, + QuotaProjectIDProvider: auth.CredentialsPropertyFunc(func(context.Context) (string, error) { + return quota, nil + }), + }) +} + +type tokenStub struct { + tok *auth.Token + err error +} + +func (s tokenStub) Token(context.Context) (*auth.Token, error) { + return s.tok, s.err +} + +func getenvMap(m map[string]string) func(string) string { + return func(k string) string { return m[k] } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestReauthKongEnum(t *testing.T) { + t.Parallel() + var o opts + parser, err := kong.New(&o, kong.Name("execspansql")) + if err != nil { + t.Fatal(err) + } + _, err = parser.Parse([]string{"db", "--project", "p", "--instance", "i", "--sql", "SELECT 1", "--reauth", "auto"}) + if err != nil { + t.Fatal(err) + } + if o.Reauth != reauthModeAuto { + t.Fatalf("Reauth = %q, want %q", o.Reauth, reauthModeAuto) + } +} + +func TestInspectADCFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, adcFileName) + if _, err := inspectADCFile(path); err == nil { + t.Fatal("missing file should error") + } + if err := os.WriteFile(path, []byte(`{"type":"authorized_user"}`), 0o600); err != nil { + t.Fatal(err) + } + snap, err := inspectADCFile(path) + if err != nil { + t.Fatal(err) + } + if snap.Type != string(credentials.AuthorizedUser) || !snap.Writable { + t.Fatalf("snapshot = %+v", snap) + } + if err := os.Chmod(path, 0o400); err != nil { + t.Fatal(err) + } + snap, err = inspectADCFile(path) + if err != nil { + t.Fatal(err) + } + if snap.Writable { + t.Fatal("want unwritable") + } +} + +func TestRunGcloudADCLogin(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake gcloud script is a POSIX shell script") + } + dir := t.TempDir() + argvPath := filepath.Join(dir, "argv") + script := "#!/bin/sh\n" + + "printf '%s\\n' \"$@\" > " + shellQuote(argvPath) + "\n" + + "echo gcloud-login-stdout\n" + gcloudPath := filepath.Join(dir, "gcloud") + if err := os.WriteFile(gcloudPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + t.Setenv(envSSHConnection, "203.0.113.1 60000 203.0.113.2 22") + t.Setenv(envSSHTTY, "") + + stdoutR, stdoutW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + stderrR, stderrW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + oldStdout, oldStderr := os.Stdout, os.Stderr + os.Stdout, os.Stderr = stdoutW, stderrW + defer func() { + os.Stdout, os.Stderr = oldStdout, oldStderr + }() + + var stdoutBuf, stderrBuf bytes.Buffer + stdoutDone := make(chan struct{}) + stderrDone := make(chan struct{}) + go func() { _, _ = io.Copy(&stdoutBuf, stdoutR); close(stdoutDone) }() + go func() { _, _ = io.Copy(&stderrBuf, stderrR); close(stderrDone) }() + + runErr := runGcloudADCLogin(context.Background(), os.Getenv, exec.LookPath) + if err := stdoutW.Close(); err != nil { + t.Fatal(err) + } + if err := stderrW.Close(); err != nil { + t.Fatal(err) + } + <-stdoutDone + <-stderrDone + os.Stdout, os.Stderr = oldStdout, oldStderr + + if runErr != nil { + t.Fatalf("runGcloudADCLogin() = %v", runErr) + } + gotArgv, err := os.ReadFile(argvPath) + if err != nil { + t.Fatal(err) + } + wantArgv := "auth\napplication-default\nlogin\n--no-launch-browser\n" + if string(gotArgv) != wantArgv { + t.Fatalf("argv = %q, want %q", gotArgv, wantArgv) + } + if strings.Contains(stdoutBuf.String(), "gcloud-login-stdout") { + t.Fatalf("gcloud stdout leaked to process stdout: %q", stdoutBuf.String()) + } + if !strings.Contains(stderrBuf.String(), "gcloud-login-stdout") { + t.Fatalf("stderr = %q, want gcloud-login-stdout", stderrBuf.String()) + } +} + +func shellQuote(path string) string { + return "'" + strings.ReplaceAll(path, "'", `'"'"'`) + "'" +} diff --git a/reauth_transport_test.go b/reauth_transport_test.go new file mode 100644 index 0000000..7eb39ec --- /dev/null +++ b/reauth_transport_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" + + "cloud.google.com/go/auth" + sppb "cloud.google.com/go/spanner/apiv1/spannerpb" + "github.com/apstndb/execspansql/internal/grpctest" + "github.com/apstndb/spanemuboost" + "google.golang.org/api/option" +) + +func TestReauthAutoSkipsPreflightWithInjectedClientOptions(t *testing.T) { + env, err := spanemuboost.RunEmulatorWithClients(context.Background()) + if err != nil { + t.Fatal(err) + } + defer env.Close() //nolint:errcheck + t.Setenv("SPANNER_EMULATOR_HOST", env.Emulator().URI()) + + orig := newReauthHooks + t.Cleanup(func() { newReauthHooks = orig }) + var detectCalls int + newReauthHooks = func() *reauthHooks { + h := orig() + h.detect = func(context.Context) (*auth.Credentials, error) { + detectCalls++ + return nil, errors.New("DetectDefault must not run when client options are injected") + } + return h + } + + var executeSQL int + dialOptions := grpctest.Inspect(func(_ string, req any) error { + if _, ok := req.(*sppb.ExecuteSqlRequest); ok { + executeSQL++ + } + return nil + }) + + args := []string{ + env.DatabaseID, "--project", env.ProjectID, "--instance", env.InstanceID, + "--sql", "SELECT 1", "--timeout", "30s", "--reauth", "auto", + } + out, err := captureStdout(t, func() error { + return runMain(t, args, option.WithGRPCDialOption(dialOptions[0]), option.WithGRPCDialOption(dialOptions[1])) + }) + if err != nil { + t.Fatal(err) + } + if detectCalls != 0 { + t.Fatalf("detect calls = %d, want 0", detectCalls) + } + if executeSQL != 1 { + t.Fatalf("ExecuteSql count = %d, want 1 (unchanged from a single SELECT)", executeSQL) + } + if !strings.Contains(out, "1") { + t.Fatalf("stdout = %q, want query result", out) + } +}