diff --git a/cmd/config/config_test.go b/cmd/config/config_test.go index 04645c4ea..509005f49 100644 --- a/cmd/config/config_test.go +++ b/cmd/config/config_test.go @@ -12,6 +12,7 @@ import ( "strings" "testing" + "github.com/larksuite/cli/errs" extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -278,12 +279,17 @@ func TestConfigInitRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) { if err == nil { t.Fatal("expected error for non-terminal without flags") } - msg := err.Error() - if !strings.Contains(msg, "--new") { - t.Errorf("expected error to mention --new, got: %s", msg) + if !strings.Contains(err.Error(), "terminal") { + t.Errorf("expected error to mention terminal, got: %s", err.Error()) } - if !strings.Contains(msg, "terminal") { - t.Errorf("expected error to mention terminal, got: %s", msg) + // Missing-terminal is a failed precondition (valid request, wrong runtime + // state), and the actionable guidance lives in the hint. + p, ok := errs.ProblemOf(err) + if !ok || p.Subtype != errs.SubtypeFailedPrecondition { + t.Fatalf("expected subtype=%q, got problem=%+v", errs.SubtypeFailedPrecondition, p) + } + if !strings.Contains(p.Hint, "--new") { + t.Errorf("expected hint to mention --new, got hint: %s", p.Hint) } } diff --git a/cmd/config/init.go b/cmd/config/init.go index de8f7b355..274a1b48c 100644 --- a/cmd/config/init.go +++ b/cmd/config/init.go @@ -34,6 +34,13 @@ type ConfigInitOptions struct { Brand string New bool + // NoWait initiates a new-app creation and returns immediately with a + // device code (non-blocking step 1); DeviceCode completes a creation + // previously started with --no-wait (non-blocking step 2). They mirror + // `auth login`'s --no-wait / --device-code split. + NoWait bool + DeviceCode string + Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang langExplicit bool // true when --lang was explicitly passed @@ -58,9 +65,11 @@ func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) * Short: "Initialize configuration (app-id / app-secret-stdin / brand)", Long: `Initialize configuration (app-id / app-secret-stdin / brand). -For AI agents: use --new to create a new app. The command blocks until the user -completes setup in the browser. Run it in the background and retrieve the -verification URL from its output. +For AI agents: prefer the non-blocking two-step flow. Run '--new --no-wait' to +get a device code and verification URL immediately (printed as JSON), send the +URL/QR to the user, then run '--device-code ' after they confirm to finish. +The plain '--new' still blocks until the user completes setup in the browser if +you need the old behavior. Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command refuses by default — use 'lark-cli config bind' to bind to the Agent's @@ -83,6 +92,8 @@ if the user explicitly wants a separate app inside the Agent workspace.`, } cmd.Flags().BoolVar(&opts.New, "new", false, "create a new app directly (skip mode selection)") + cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "create a new app but return immediately with a device code; complete later with --device-code (non-blocking, for AI agents)") + cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "complete a new-app creation started with --no-wait, using its device code") cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)") cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure") cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)") @@ -137,7 +148,7 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error { // hasAnyNonInteractiveFlag returns true if any non-interactive flag is set. func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool { - return o.New || o.AppID != "" || o.AppSecretStdin + return o.New || o.AppID != "" || o.AppSecretStdin || o.NoWait || o.DeviceCode != "" } // cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID. @@ -301,6 +312,22 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa func configInitRun(opts *ConfigInitOptions) error { f := opts.Factory + // Validate the non-blocking flags before touching stdin so a contradictory + // combination (e.g. --no-wait --app-secret-stdin) fails fast instead of + // blocking on a stdin read. + if opts.NoWait && opts.DeviceCode != "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--no-wait and --device-code cannot be used together").WithParam("--device-code") + } + if (opts.NoWait || opts.DeviceCode != "") && (opts.AppID != "" || opts.AppSecretStdin) { + // Point remediation at whichever non-blocking flag the caller actually + // passed (mutual exclusion above guarantees at most one is set here). + conflictParam := "--no-wait" + if opts.DeviceCode != "" { + conflictParam = "--device-code" + } + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--no-wait/--device-code create a new app and cannot be combined with --app-id/--app-secret-stdin").WithParam(conflictParam) + } + // Read secret from stdin if --app-secret-stdin is set if opts.AppSecretStdin { scanner := bufio.NewScanner(f.IOStreams.In) @@ -328,6 +355,15 @@ func configInitRun(opts *ConfigInitOptions) error { } } + // Non-blocking step 2: complete a creation started with --no-wait. + if opts.DeviceCode != "" { + return resumeAppRegistration(opts) + } + // Non-blocking step 1: initiate a new-app creation and return immediately. + if opts.NoWait { + return initiateNoWaitAppRegistration(opts, existing) + } + // Mode 1: Non-interactive if opts.AppID != "" && opts.appSecret != "" { brand := parseBrand(opts.Brand) @@ -433,9 +469,12 @@ func configInitRun(opts *ConfigInitOptions) error { return nil } - // Non-terminal: cannot run interactive mode, guide user to --new + // Non-terminal: the request is valid but the runtime state is wrong (no + // terminal for interactive mode) — a failed precondition, not a bad + // argument. Point the caller at the non-blocking two-step flow. if !f.IOStreams.IsTerminal { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.") + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "config init interactive mode requires a terminal"). + WithHint("Create a new app non-interactively with the two-step flow: `lark-cli config init --new --no-wait` (prints device_code + verification_url, returns immediately), then `lark-cli config init --device-code ` after the user finishes in the browser. Or run `lark-cli config init --new` in a terminal.") } // Mode 5: Legacy interactive (readline fallback) diff --git a/cmd/config/init_nowait.go b/cmd/config/init_nowait.go new file mode 100644 index 000000000..e9618a93f --- /dev/null +++ b/cmd/config/init_nowait.go @@ -0,0 +1,232 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/larksuite/cli/errs" + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/build" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/transport" +) + +// newRegistrationHTTPClient builds the HTTP client used for app-registration +// traffic. It is a package var so tests can inject a stub transport. +var newRegistrationHTTPClient = func() *http.Client { return transport.NewHTTPClient(0) } + +// initNoWaitHint is the agent-facing guidance embedded in the --no-wait JSON +// output, mirroring the two-step contract of `auth login --no-wait`. +const initNoWaitHint = "**Generate AND display the QR code:** call `lark-cli auth qrcode ` and show it (PNG via --output; ASCII via --ascii only if the user asks). " + + "**You MUST include the QR image in your response** — generating the file alone is not enough. Output the URL first, then the QR image below it. " + + "**Treat verification_url as an opaque string** — do not URL-encode/decode it or add spaces/punctuation. " + + "**Hand control back:** make the QR/URL the final message of this turn; do NOT run --device-code in the same turn. Tell the user to come back and notify you after they finish creating the app in the browser. " + + "**After the user confirms:** YOU must finish by running lark-cli with the exact arguments in `resume_args`, passing each element as a separate literal argument (do not re-quote or shell-interpret them). It already carries the right flags. " + + "**Do NOT cache verification_url or device_code** — run `lark-cli config init --new --no-wait` fresh whenever a new app is needed." + +// initiateNoWaitAppRegistration runs the non-blocking first step: request a +// device code, cache the resume context, print JSON, and return immediately +// without polling. +func initiateNoWaitAppRegistration(opts *ConfigInitOptions, existing *core.MultiAppConfig) error { + f := opts.Factory + brand := parseBrand(opts.Brand) + + httpClient := newRegistrationHTTPClient() + authResp, err := larkauth.RequestAppRegistration(httpClient, brand, f.IOStreams.ErrOut) + if err != nil { + return errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err) + } + + rec := initNoWaitRecord{ + Version: initNoWaitCacheVersion, + Brand: string(brand), + ProfileName: opts.ProfileName, + Lang: opts.Lang, + LangExplicit: opts.langExplicit, + Interval: authResp.Interval, + ExpiresAt: time.Now().Unix() + int64(authResp.ExpiresIn), + ConfigDigest: computeConfigDigest(existing), + } + // The resume step (--device-code) fully depends on this cache to finish + // persisting the app — unlike auth login, which can re-derive its scope. So + // a cache-write failure is fatal: fail now rather than hand back a + // device_code the user can never complete. + if err := saveInitNoWaitRecord(authResp.DeviceCode, rec); err != nil { + return errs.NewInternalError(errs.SubtypeStorage, "failed to persist the context needed by `config init --device-code`: %v", err).WithCause(err) + } + + // Emit the resume step as an argv array rather than a shell string: the + // device_code is opaque and may contain spaces or metacharacters, and a + // single quoted string can't be both POSIX- and cmd.exe-safe. argv sidesteps + // quoting entirely — agents pass each element as a literal argument. + // --force-init must be carried along: guardAgentWorkspace runs in RunE + // before the cache is read, so resuming without it inside an agent workspace + // would be rejected. (Profile name is recovered from the cache.) + resumeArgs := []string{"lark-cli", "config", "init", "--device-code", authResp.DeviceCode} + if opts.ForceInit { + resumeArgs = append(resumeArgs, "--force-init") + } + + verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version) + data := map[string]interface{}{ + "verification_url": verificationURL, + "device_code": authResp.DeviceCode, + "expires_in": authResp.ExpiresIn, + "resume_args": resumeArgs, + "hint": initNoWaitHint, + } + encoder := json.NewEncoder(f.IOStreams.Out) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(data); err != nil { + return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err) + } + return nil +} + +// resumeAppRegistration runs the non-blocking second step: poll with a device +// code from a previous --no-wait call, then persist the new app and probe it. +func resumeAppRegistration(opts *ConfigInitOptions) error { + f := opts.Factory + + rec, err := loadInitNoWaitRecord(opts.DeviceCode) + if err != nil { + // The record exists but could not be read/parsed (permissions, disk, + // corruption). The resume step fully depends on this cache, so surface a + // storage error instead of the misleading "no pending creation" + // validation path — the user should fix local storage, not assume the + // device code is bad and throw away a still-valid creation attempt. + return errs.NewInternalError(errs.SubtypeStorage, "failed to read the cached resume context: %v", err).WithCause(err) + } + if rec == nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "no pending app creation found for this device code; re-initiate with `lark-cli config init --new --no-wait`"). + WithParam("--device-code") + } + + // Expiry check against the cached absolute deadline (device codes are + // short-lived — the registration default is 300s). + remaining := rec.ExpiresAt - time.Now().Unix() + if remaining <= 0 { + _ = removeInitNoWaitRecord(opts.DeviceCode) + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "device code expired; re-initiate with `lark-cli config init --new --no-wait`"). + WithParam("--device-code") + } + + // Drift guard (fast path): bail out before the long poll if the config + // already changed since initiation, so we don't waste minutes polling. + existing, _ := core.LoadMultiAppConfig() + if computeConfigDigest(existing) != rec.ConfigDigest { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "configuration changed since this app creation was started; re-initiate with `lark-cli config init --new --no-wait` to avoid overwriting it"). + WithParam("--device-code") + } + + interval := rec.Interval + if interval <= 0 { + interval = 5 + } + + httpClient := newRegistrationHTTPClient() + result, pollErr := pollAppRegistrationResume(opts.Ctx, httpClient, opts.DeviceCode, interval, int(remaining), f.IOStreams.ErrOut) + if pollErr != nil { + // Clear the cache only on terminal failures (denied / expired / + // timed-out). Keep it on cancellation or transient errors so the user + // can retry with the same device code while it is still valid. + if appRegShouldClearCache(pollErr) { + _ = removeInitNoWaitRecord(opts.DeviceCode) + } + return errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", pollErr).WithCause(pollErr) + } + + // Re-check drift immediately before persisting. The poll above can block + // for minutes while the user finishes in the browser, and a concurrent + // process may have changed config.json in that window — saving the stale + // pre-poll snapshot would drop those edits. Reload and compare again. + existing, _ = core.LoadMultiAppConfig() + if computeConfigDigest(existing) != rec.ConfigDigest { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "configuration changed while the app was being created, so it was not saved (to avoid overwriting that change); re-run `lark-cli config init --new --no-wait`"). + WithParam("--device-code") + } + + // Determine the final brand from the response, falling back to the cached + // brand. The cached brand only seeds link generation + this fallback; the + // Lark-tenant re-poll inside pollAppRegistrationResume is what actually + // detects a Lark tenant. + finalBrand := parseBrand(rec.Brand) + if result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" { + finalBrand = core.BrandLark + } else if result.UserInfo != nil && result.UserInfo.TenantBrand == "feishu" { + finalBrand = core.BrandFeishu + } + + secret, err := core.ForStorage(result.ClientID, core.PlainSecret(result.ClientSecret), f.Keychain) + if err != nil { + return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err) + } + if err := saveInitConfig(rec.ProfileName, existing, f, result.ClientID, secret, finalBrand, rec.Lang); err != nil { + return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) + } + + // Config persisted — only now is it safe to drop the resume cache. Clearing + // it only after a successful save means a failure in the drift re-check, + // ForStorage, or saveInitConfig above leaves the cache intact so the user + // can retry `--device-code` (the remote app already exists). + _ = removeInitNoWaitRecord(opts.DeviceCode) + + if rec.LangExplicit && rec.Lang != "" { + msg := getInitMsg(opts.UILang) + fmt.Fprintln(f.IOStreams.ErrOut, fmt.Sprintf(msg.LangPreferenceSet, rec.Lang)) + } + + output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.ClientID, "appSecret": "****", "brand": finalBrand}) + if err := runProbe(opts.Ctx, f, result.ClientID, result.ClientSecret, finalBrand); err != nil { + return err + } + return nil +} + +// pollAppRegistrationResume polls the registration endpoint (feishu first, then +// the lark endpoint on the tenant_brand=lark special case) and returns the raw +// error so the caller can classify it for cache-cleanup decisions. +func pollAppRegistrationResume(ctx context.Context, httpClient *http.Client, deviceCode string, interval, expiresIn int, errOut io.Writer) (*larkauth.AppRegistrationResult, error) { + result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, deviceCode, interval, expiresIn, errOut) + if err != nil { + return nil, err + } + // Lark tenant special case: if tenant_brand=lark and no client_secret, + // re-poll against the lark endpoint to obtain the secret. + if result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" { + result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, deviceCode, interval, expiresIn, errOut) + if err != nil { + return nil, err + } + } + if result.ClientID == "" || result.ClientSecret == "" { + return nil, fmt.Errorf("app registration succeeded but missing client_id or client_secret") + } + return result, nil +} + +// appRegShouldClearCache reports whether the cached resume context should be +// discarded after a poll outcome. Success and terminal failures (user denied, +// device code expired, deadline elapsed) clear it; cancellation and transient +// errors keep it so the user can retry while the device code is still valid. +func appRegShouldClearCache(err error) bool { + if err == nil { + return true + } + return errors.Is(err, larkauth.ErrAppRegDenied) || + errors.Is(err, larkauth.ErrAppRegExpired) || + errors.Is(err, larkauth.ErrAppRegTimeout) +} diff --git a/cmd/config/init_nowait_cache.go b/cmd/config/init_nowait_cache.go new file mode 100644 index 000000000..b0a32be00 --- /dev/null +++ b/cmd/config/init_nowait_cache.go @@ -0,0 +1,116 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/internal/vfs" +) + +// initNoWaitCacheVersion is the schema version of the cached init context. +// Bump it when the record shape changes so stale entries are ignored. +const initNoWaitCacheVersion = 1 + +// initNoWaitRecord is the context persisted by `config init --new --no-wait` so +// that the later `--device-code` step can complete the app creation. It must +// never hold a secret, verification URL, or full config — only what the resume +// step needs to finish persisting the new app. +type initNoWaitRecord struct { + Version int `json:"version"` + Brand string `json:"brand"` + ProfileName string `json:"profile_name"` + Lang string `json:"lang"` + LangExplicit bool `json:"lang_explicit"` + Interval int `json:"interval"` + ExpiresAt int64 `json:"expires_at"` // unix seconds; absolute device-code deadline + ConfigDigest string `json:"config_digest"` +} + +// initNoWaitCacheDir returns the directory used to persist config init +// --no-wait context keyed by device_code. +func initNoWaitCacheDir() string { + return filepath.Join(core.GetConfigDir(), "cache", "config_init_nowait") +} + +// initNoWaitCachePath returns the cache file path for a given device_code. +func initNoWaitCachePath(deviceCode string) string { + return filepath.Join(initNoWaitCacheDir(), initNoWaitCacheKey(deviceCode)+".json") +} + +// initNoWaitCacheKey derives a collision-free, filesystem-safe filename token +// from an opaque device_code. A sha256 hex digest avoids the collisions a +// character-replacement sanitizer would cause (e.g. "a/b" and "a:b" both +// mapping to "a_b"). +func initNoWaitCacheKey(deviceCode string) string { + sum := sha256.Sum256([]byte(deviceCode)) + return hex.EncodeToString(sum[:]) +} + +// saveInitNoWaitRecord persists the resume context for a device_code. +func saveInitNoWaitRecord(deviceCode string, rec initNoWaitRecord) error { + if err := vfs.MkdirAll(initNoWaitCacheDir(), 0700); err != nil { + return err + } + data, err := json.Marshal(rec) + if err != nil { + return err + } + return validate.AtomicWrite(initNoWaitCachePath(deviceCode), data, 0600) +} + +// loadInitNoWaitRecord loads the resume context for a device_code. It returns +// (nil, nil) when no cache entry exists. +func loadInitNoWaitRecord(deviceCode string) (*initNoWaitRecord, error) { + data, err := vfs.ReadFile(initNoWaitCachePath(deviceCode)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + var rec initNoWaitRecord + if err := json.Unmarshal(data, &rec); err != nil { + _ = vfs.Remove(initNoWaitCachePath(deviceCode)) + return nil, err + } + if rec.Version != initNoWaitCacheVersion { + _ = vfs.Remove(initNoWaitCachePath(deviceCode)) + return nil, nil + } + return &rec, nil +} + +// removeInitNoWaitRecord deletes the cache entry for a device_code. +func removeInitNoWaitRecord(deviceCode string) error { + err := vfs.Remove(initNoWaitCachePath(deviceCode)) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +// computeConfigDigest returns a stable digest of the existing config so the +// resume step can detect drift between initiation and completion. The digest +// is a hash of config.json content (app IDs, brands, users, secret references) +// — it contains no plaintext secret and is safe to cache. A nil config and an +// (unexpected) marshal error both map to the empty digest. +func computeConfigDigest(existing *core.MultiAppConfig) string { + if existing == nil { + return "" + } + data, err := json.Marshal(existing) + if err != nil { + return "" + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} diff --git a/cmd/config/init_nowait_test.go b/cmd/config/init_nowait_test.go new file mode 100644 index 000000000..709db7a1f --- /dev/null +++ b/cmd/config/init_nowait_test.go @@ -0,0 +1,488 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/errs" + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// roundTripFunc adapts a function to an http.RoundTripper. +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +// TestNoWait_InitiateThenResume_EndToEnd drives the full two-step flow against a +// real local HTTP server: initiate writes the on-disk cache, then a SEPARATE +// resume call polls the same server, succeeds, and persists the new app. Only +// the device_code + the cache bridge the two invocations — exactly as the two +// CLI commands would. (A black-box binary E2E of the success path is impossible +// without a human: endpoints are hardcoded HTTPS and the real device flow needs +// a browser scan, so this in-process run through httptest is the highest-fidelity +// autonomous end-to-end.) +func TestNoWait_InitiateThenResume_EndToEnd(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + switch r.FormValue("action") { + case "begin": + _, _ = w.Write([]byte(`{"device_code":"E2E-DEVICE-CODE","user_code":"E2E-UC","verification_uri":"https://example.test/verify","expires_in":600,"interval":1}`)) + case "poll": + _, _ = w.Write([]byte(`{"client_id":"cli_e2e","client_secret":"sec_e2e","user_info":{"tenant_brand":"feishu","open_id":"ou_e2e"}}`)) + default: + http.Error(w, "unexpected action "+r.FormValue("action"), http.StatusBadRequest) + } + })) + defer ts.Close() + tsURL, _ := url.Parse(ts.URL) + + // Redirect the registration client to the local test server. + orig := newRegistrationHTTPClient + newRegistrationHTTPClient = func() *http.Client { + return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + r.URL.Scheme, r.URL.Host = tsURL.Scheme, tsURL.Host + return http.DefaultTransport.RoundTrip(r) + })} + } + t.Cleanup(func() { newRegistrationHTTPClient = orig }) + + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + + // Step 1 — initiate: should print device_code and write the resume cache. + initOpts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), Brand: "feishu", New: true, NoWait: true} + if err := initiateNoWaitAppRegistration(initOpts, nil); err != nil { + t.Fatalf("initiate: %v", err) + } + var out map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &out); err != nil { + t.Fatalf("initiate stdout not JSON: %v; raw=%s", err, stdout.String()) + } + if out["device_code"] != "E2E-DEVICE-CODE" { + t.Fatalf("device_code = %v, want E2E-DEVICE-CODE", out["device_code"]) + } + if rec, _ := loadInitNoWaitRecord("E2E-DEVICE-CODE"); rec == nil { + t.Fatal("initiate did not write the resume cache") + } + + // Step 2 — resume (separate invocation; bridged only by device_code + cache). + stdout.Reset() + resumeOpts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: "E2E-DEVICE-CODE"} + if err := resumeAppRegistration(resumeOpts); err != nil { + t.Fatalf("resume: %v", err) + } + + // The new app must be persisted to config... + cfg, err := core.LoadMultiAppConfig() + if err != nil || cfg == nil { + t.Fatalf("config not persisted: %v", err) + } + if app := cfg.CurrentAppConfig(""); app == nil || app.AppId != "cli_e2e" { + t.Fatalf("persisted app = %+v, want AppId cli_e2e", app) + } + // ...the cache cleared after the successful save... + if rec, _ := loadInitNoWaitRecord("E2E-DEVICE-CODE"); rec != nil { + t.Error("resume should clear the cache after a successful save") + } + // ...and the success JSON emitted. + if !strings.Contains(stdout.String(), "cli_e2e") { + t.Errorf("resume stdout missing appId: %s", stdout.String()) + } +} + +// stubRT returns a single canned HTTP response for every request. +type stubRT struct { + status int + body string +} + +func (s stubRT) RoundTrip(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: s.status, Body: io.NopCloser(strings.NewReader(s.body)), Header: make(http.Header)}, nil +} + +// seqRT returns successive canned responses (last one repeats), for flows that +// poll more than once (e.g. the Lark-tenant re-poll). +type seqRT struct { + bodies []string + i int +} + +func (s *seqRT) RoundTrip(*http.Request) (*http.Response, error) { + idx := s.i + if idx >= len(s.bodies) { + idx = len(s.bodies) - 1 + } + s.i++ + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(s.bodies[idx])), Header: make(http.Header)}, nil +} + +// withStubRegistrationClient swaps the registration HTTP client for the test. +func withStubRegistrationClient(t *testing.T, rt http.RoundTripper) { + t.Helper() + orig := newRegistrationHTTPClient + newRegistrationHTTPClient = func() *http.Client { return &http.Client{Transport: rt} } + t.Cleanup(func() { newRegistrationHTTPClient = orig }) +} + +// --- cache round-trip --- + +func TestInitNoWaitCache_RoundTrip(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + rec := initNoWaitRecord{ + Version: initNoWaitCacheVersion, + Brand: "feishu", + ProfileName: "work", + Lang: "zh_cn", + LangExplicit: true, + Interval: 5, + ExpiresAt: time.Now().Unix() + 300, + ConfigDigest: "abc123", + } + const dc = "device-code-xyz" + + if err := saveInitNoWaitRecord(dc, rec); err != nil { + t.Fatalf("save: %v", err) + } + got, err := loadInitNoWaitRecord(dc) + if err != nil { + t.Fatalf("load: %v", err) + } + if got == nil { + t.Fatal("load returned nil for a saved record") + } + if *got != rec { + t.Errorf("round-trip mismatch:\n got %+v\n want %+v", *got, rec) + } + + if err := removeInitNoWaitRecord(dc); err != nil { + t.Fatalf("remove: %v", err) + } + got2, err := loadInitNoWaitRecord(dc) + if err != nil { + t.Fatalf("load after remove: %v", err) + } + if got2 != nil { + t.Errorf("expected nil after remove, got %+v", got2) + } + // Removing a non-existent record must be a no-op, not an error. + if err := removeInitNoWaitRecord(dc); err != nil { + t.Errorf("remove of missing record should be nil, got %v", err) + } +} + +func TestInitNoWaitCache_LoadMissing(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + got, err := loadInitNoWaitRecord("never-saved") + if err != nil { + t.Fatalf("load missing: %v", err) + } + if got != nil { + t.Errorf("expected nil for missing record, got %+v", got) + } +} + +func TestInitNoWaitCache_VersionMismatchIgnored(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + const dc = "stale-version" + rec := initNoWaitRecord{Version: initNoWaitCacheVersion + 1, ExpiresAt: time.Now().Unix() + 300} + if err := saveInitNoWaitRecord(dc, rec); err != nil { + t.Fatalf("save: %v", err) + } + got, err := loadInitNoWaitRecord(dc) + if err != nil { + t.Fatalf("load: %v", err) + } + if got != nil { + t.Errorf("expected nil for version mismatch, got %+v", got) + } + // The stale entry should have been discarded by the load. + got2, _ := loadInitNoWaitRecord(dc) + if got2 != nil { + t.Errorf("stale-version entry was not removed on load") + } +} + +func TestInitNoWaitCacheKey(t *testing.T) { + // Distinct device codes that a char-replacement sanitizer would collide + // ("a/b" and "a:b" -> "a_b") must map to distinct keys. + if initNoWaitCacheKey("a/b") == initNoWaitCacheKey("a:b") { + t.Error("distinct device codes must not collide on the cache key") + } + // Deterministic. + if initNoWaitCacheKey("xyz") != initNoWaitCacheKey("xyz") { + t.Error("cache key must be deterministic") + } + // sha256 hex: 64 chars, filesystem-safe regardless of input. + k := initNoWaitCacheKey("has /, :, ;, spaces and 'quotes'") + if len(k) != 64 { + t.Errorf("expected 64-char sha256 hex key, got %d: %q", len(k), k) + } +} + +// --- config digest --- + +func TestComputeConfigDigest(t *testing.T) { + if d := computeConfigDigest(nil); d != "" { + t.Errorf("nil digest = %q, want empty", d) + } + cfg1 := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_a", Brand: core.BrandFeishu}}} + cfg1Dup := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_a", Brand: core.BrandFeishu}}} + cfg2 := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_b", Brand: core.BrandFeishu}}} + + if computeConfigDigest(cfg1) == "" { + t.Error("non-nil config digest should be non-empty") + } + if computeConfigDigest(cfg1) != computeConfigDigest(cfg1Dup) { + t.Error("equal configs should produce equal digests") + } + if computeConfigDigest(cfg1) == computeConfigDigest(cfg2) { + t.Error("different configs should produce different digests") + } +} + +// --- failure classification for cache cleanup --- + +func TestAppRegShouldClearCache(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"success", nil, true}, + {"denied", larkauth.ErrAppRegDenied, true}, + {"expired", larkauth.ErrAppRegExpired, true}, + {"expired wrapped", fmt.Errorf("%w, please try again", larkauth.ErrAppRegExpired), true}, + {"timeout", larkauth.ErrAppRegTimeout, true}, + {"timeout wrapped", fmt.Errorf("%w, please try again", larkauth.ErrAppRegTimeout), true}, + {"cancelled", larkauth.ErrAppRegCancelled, false}, + {"transient generic", fmt.Errorf("network boom"), false}, + {"missing fields", fmt.Errorf("app registration succeeded but missing client_id or client_secret"), false}, + } + for _, c := range cases { + if got := appRegShouldClearCache(c.err); got != c.want { + t.Errorf("%s: appRegShouldClearCache = %v, want %v", c.name, got, c.want) + } + } +} + +// --- initiate (stubbed registration client) --- + +func TestInitiateNoWaitAppRegistration_WritesCacheAndJSON(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + withStubRegistrationClient(t, stubRT{200, `{"device_code":"dc-abc","user_code":"U-1","verification_uri":"https://open.feishu.cn","expires_in":3600,"interval":5}`}) + + opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), Brand: "feishu", New: true, NoWait: true, ForceInit: true} + if err := initiateNoWaitAppRegistration(opts, nil); err != nil { + t.Fatalf("initiate: %v", err) + } + + var out map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &out); err != nil { + t.Fatalf("stdout not JSON: %v; raw=%s", err, stdout.String()) + } + if out["device_code"] != "dc-abc" { + t.Errorf("device_code = %v, want dc-abc", out["device_code"]) + } + args, ok := out["resume_args"].([]interface{}) + if !ok || len(args) == 0 || args[len(args)-1] != "--force-init" { + t.Errorf("resume_args should end with --force-init, got %v", out["resume_args"]) + } + + rec, _ := loadInitNoWaitRecord("dc-abc") + if rec == nil { + t.Fatal("cache record not written") + } + if rec.Brand != "feishu" || rec.Version != initNoWaitCacheVersion { + t.Errorf("cache record = %+v", *rec) + } +} + +// --- pollAppRegistrationResume (stubbed client) --- + +func TestPollAppRegistrationResume_Success(t *testing.T) { + c := &http.Client{Transport: stubRT{200, `{"client_id":"cli_x","client_secret":"sec","user_info":{"tenant_brand":"feishu"}}`}} + res, err := pollAppRegistrationResume(context.Background(), c, "dc", 0, 60, io.Discard) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.ClientID != "cli_x" || res.ClientSecret != "sec" { + t.Errorf("got %+v", res) + } +} + +func TestPollAppRegistrationResume_MissingSecret(t *testing.T) { + c := &http.Client{Transport: stubRT{200, `{"client_id":"cli_x"}`}} + if _, err := pollAppRegistrationResume(context.Background(), c, "dc", 0, 60, io.Discard); err == nil { + t.Error("expected error when client_secret is missing") + } +} + +func TestPollAppRegistrationResume_LarkRetry(t *testing.T) { + // First poll (feishu endpoint): lark tenant, no secret -> triggers re-poll + // against the lark endpoint, which returns the secret. + rt := &seqRT{bodies: []string{ + `{"client_id":"cli_x","client_secret":"","user_info":{"tenant_brand":"lark"}}`, + `{"client_id":"cli_x","client_secret":"larksec","user_info":{"tenant_brand":"lark"}}`, + }} + res, err := pollAppRegistrationResume(context.Background(), &http.Client{Transport: rt}, "dc", 0, 60, io.Discard) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.ClientSecret != "larksec" { + t.Errorf("expected lark re-poll to yield the secret, got %+v", res) + } +} + +// Full resume happy path: stubbed poll succeeds, the app is persisted, and the +// cache is cleared. (runProbe hits the factory's mock client, which has no stub +// and returns an untyped error that runProbe swallows.) +func TestResumeAppRegistration_Success(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + withStubRegistrationClient(t, stubRT{200, `{"client_id":"cli_new","client_secret":"sec","user_info":{"tenant_brand":"feishu"}}`}) + + const dc = "resume-ok" + rec := initNoWaitRecord{ + Version: initNoWaitCacheVersion, + Brand: "feishu", + Interval: 1, // keep the single poll fast + ExpiresAt: time.Now().Unix() + 300, + ConfigDigest: computeConfigDigest(nil), + } + if err := saveInitNoWaitRecord(dc, rec); err != nil { + t.Fatalf("save: %v", err) + } + + opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc} + if err := resumeAppRegistration(opts); err != nil { + t.Fatalf("resume: %v", err) + } + + cfg, _ := core.LoadMultiAppConfig() + if cfg == nil || cfg.CurrentAppConfig("") == nil || cfg.CurrentAppConfig("").AppId != "cli_new" { + t.Errorf("config not persisted with new app id: %+v", cfg) + } + if got, _ := loadInitNoWaitRecord(dc); got != nil { + t.Error("cache should be cleared after a successful save") + } + if !strings.Contains(stdout.String(), "cli_new") { + t.Errorf("stdout missing new appId: %s", stdout.String()) + } +} + +// --- flag validation (returns before any network) --- + +func TestConfigInitRun_NoWaitAndDeviceCodeMutuallyExclusive(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, nil) + opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), NoWait: true, DeviceCode: "x"} + assertValidationParam(t, configInitRun(opts), "--device-code") +} + +func TestConfigInitRun_NoWaitWithAppIDRejected(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, nil) + opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), NoWait: true, AppID: "cli_x"} + assertValidationParam(t, configInitRun(opts), "--no-wait") +} + +// The conflict error must point at the flag the caller actually passed: with +// --device-code (not --no-wait) + --app-id, remediation should name --device-code. +func TestConfigInitRun_DeviceCodeWithAppIDReportsDeviceCode(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, nil) + opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: "dc", AppID: "cli_x"} + assertValidationParam(t, configInitRun(opts), "--device-code") +} + +// --- resume guards (return before any network) --- + +func TestResumeAppRegistration_NoCacheEntry(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, nil) + opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: "missing-dc"} + assertValidationParam(t, resumeAppRegistration(opts), "--device-code") +} + +func TestResumeAppRegistration_ExpiredClearsCache(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, nil) + const dc = "expired-dc" + rec := initNoWaitRecord{ + Version: initNoWaitCacheVersion, + Brand: "feishu", + Interval: 5, + ExpiresAt: time.Now().Unix() - 10, // already past + } + if err := saveInitNoWaitRecord(dc, rec); err != nil { + t.Fatalf("save: %v", err) + } + opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc} + assertValidationParam(t, resumeAppRegistration(opts), "--device-code") + + if got, _ := loadInitNoWaitRecord(dc); got != nil { + t.Error("expired cache entry should have been removed") + } +} + +// A cache file that exists but cannot be parsed is a storage failure, not a +// "no pending creation" validation error — the user should fix storage rather +// than assume the device code is bad. +func TestResumeAppRegistration_CorruptCacheIsStorageError(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, nil) + const dc = "corrupt-dc" + if err := os.MkdirAll(initNoWaitCacheDir(), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(initNoWaitCachePath(dc), []byte("{ not valid json"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc} + err := resumeAppRegistration(opts) + var intErr *errs.InternalError + if !errors.As(err, &intErr) { + t.Fatalf("expected *errs.InternalError for unreadable cache, got %T: %v", err, err) + } + if p, ok := errs.ProblemOf(err); !ok || p.Subtype != errs.SubtypeStorage { + t.Fatalf("expected subtype=%q, got problem=%+v", errs.SubtypeStorage, p) + } + if errors.Unwrap(err) == nil { + t.Fatal("expected the underlying cache-read failure to be preserved as a cause") + } +} + +func TestResumeAppRegistration_ConfigDrift(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, nil) + const dc = "drift-dc" + rec := initNoWaitRecord{ + Version: initNoWaitCacheVersion, + Brand: "feishu", + Interval: 5, + ExpiresAt: time.Now().Unix() + 300, + ConfigDigest: "stale-digest-that-will-not-match-current-config", + } + if err := saveInitNoWaitRecord(dc, rec); err != nil { + t.Fatalf("save: %v", err) + } + opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc} + assertValidationParam(t, resumeAppRegistration(opts), "--device-code") +} diff --git a/internal/auth/app_registration.go b/internal/auth/app_registration.go index 2bdf50fba..eb9d718ee 100644 --- a/internal/auth/app_registration.go +++ b/internal/auth/app_registration.go @@ -6,6 +6,7 @@ package auth import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -16,6 +17,20 @@ import ( "github.com/larksuite/cli/internal/core" ) +// Sentinel errors returned by PollAppRegistration so callers can classify a +// failure (e.g. to decide whether a cached device code should be discarded) +// via errors.Is without parsing message strings. +var ( + // ErrAppRegDenied means the user rejected the app registration. + ErrAppRegDenied = errors.New("app registration denied by user") + // ErrAppRegExpired means the device code is no longer valid. + ErrAppRegExpired = errors.New("device code expired") + // ErrAppRegCancelled means polling was cancelled via the context. + ErrAppRegCancelled = errors.New("polling was cancelled") + // ErrAppRegTimeout means the local polling deadline elapsed. + ErrAppRegTimeout = errors.New("app registration timed out") +) + // AppRegistrationResponse is the response from the app registration begin endpoint. type AppRegistrationResponse struct { DeviceCode string @@ -138,13 +153,13 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor for time.Now().Before(deadline) && attempts < maxPollAttempts { attempts++ if ctx.Err() != nil { - return nil, fmt.Errorf("polling was cancelled") + return nil, ErrAppRegCancelled } select { case <-time.After(time.Duration(currentInterval) * time.Second): case <-ctx.Done(): - return nil, fmt.Errorf("polling was cancelled") + return nil, ErrAppRegCancelled } form := url.Values{} @@ -205,9 +220,9 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor fmt.Fprintf(errOut, "[lark-cli] app-registration: slow_down, interval increased to %ds\n", currentInterval) continue case "access_denied": - return nil, fmt.Errorf("app registration denied by user") + return nil, ErrAppRegDenied case "expired_token", "invalid_grant": - return nil, fmt.Errorf("device code expired, please try again") + return nil, fmt.Errorf("%w, please try again", ErrAppRegExpired) } desc := getStr(data, "error_description") @@ -223,5 +238,5 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor if attempts >= maxPollAttempts { fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: max poll attempts (%d) reached\n", maxPollAttempts) } - return nil, fmt.Errorf("app registration timed out, please try again") + return nil, fmt.Errorf("%w, please try again", ErrAppRegTimeout) } diff --git a/internal/auth/app_registration_sentinel_test.go b/internal/auth/app_registration_sentinel_test.go new file mode 100644 index 000000000..529864e79 --- /dev/null +++ b/internal/auth/app_registration_sentinel_test.go @@ -0,0 +1,95 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/internal/core" +) + +// stubRoundTripper returns a canned response for every request. +type stubRoundTripper struct { + status int + body string +} + +func (s stubRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: s.status, + Body: io.NopCloser(strings.NewReader(s.body)), + Header: make(http.Header), + }, nil +} + +// TestAppRegSentinelMessages locks the user-facing message text so the +// interactive create flow (which renders these via "%v") does not regress when +// the errors gained errors.Is support. +func TestAppRegSentinelMessages(t *testing.T) { + cases := map[string]string{ + ErrAppRegDenied.Error(): "app registration denied by user", + ErrAppRegCancelled.Error(): "polling was cancelled", + fmt.Errorf("%w, please try again", ErrAppRegExpired).Error(): "device code expired, please try again", + fmt.Errorf("%w, please try again", ErrAppRegTimeout).Error(): "app registration timed out, please try again", + } + for got, want := range cases { + if got != want { + t.Errorf("message = %q, want %q", got, want) + } + } +} + +// TestPollAppRegistration_Classifies verifies that terminal poll outcomes are +// returned as the matching sentinel error (interval 0 keeps the test fast). +func TestPollAppRegistration_Classifies(t *testing.T) { + cases := []struct { + name string + body string + want error + }{ + {"access_denied", `{"error":"access_denied"}`, ErrAppRegDenied}, + {"expired_token", `{"error":"expired_token"}`, ErrAppRegExpired}, + {"invalid_grant", `{"error":"invalid_grant"}`, ErrAppRegExpired}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + client := &http.Client{Transport: stubRoundTripper{status: 200, body: c.body}} + _, err := PollAppRegistration(context.Background(), client, core.BrandFeishu, "dc", 0, 60, io.Discard) + if !errors.Is(err, c.want) { + t.Fatalf("err = %v, want errors.Is(%v)", err, c.want) + } + }) + } +} + +func TestPollAppRegistration_Success(t *testing.T) { + body := `{"client_id":"cli_x","client_secret":"sec","user_info":{"tenant_brand":"feishu","open_id":"ou_1"}}` + client := &http.Client{Transport: stubRoundTripper{status: 200, body: body}} + res, err := PollAppRegistration(context.Background(), client, core.BrandFeishu, "dc", 0, 60, io.Discard) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.ClientID != "cli_x" || res.ClientSecret != "sec" { + t.Errorf("got client_id=%q secret=%q, want cli_x/sec", res.ClientID, res.ClientSecret) + } + if res.UserInfo == nil || res.UserInfo.TenantBrand != "feishu" { + t.Errorf("user info not parsed: %+v", res.UserInfo) + } +} + +func TestPollAppRegistration_CancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel up front + client := &http.Client{Transport: stubRoundTripper{status: 200, body: `{"error":"authorization_pending"}`}} + _, err := PollAppRegistration(ctx, client, core.BrandFeishu, "dc", 0, 60, io.Discard) + if !errors.Is(err, ErrAppRegCancelled) { + t.Fatalf("err = %v, want errors.Is(ErrAppRegCancelled)", err) + } +}