From 2bbab4d851241840ca53a27226d8bdfde76d57e8 Mon Sep 17 00:00:00 2001 From: dc-bytedance Date: Wed, 3 Jun 2026 11:44:04 +0800 Subject: [PATCH] feat: validate credentials after config init (#1151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: extract FetchTAT sharing the TAT-rejection classifier doResolveTAT minted the tenant access token inline. Extract the HTTP call into FetchTAT(ctx, httpClient, brand, appID, appSecret) so callers that already hold plaintext credentials — notably the post-config-init probe — can validate them without a second keychain round-trip. FetchTAT routes a non-zero TAT body code through the same classifyTATResponseCode the credential layer already uses, so a rejection is the canonical CategoryConfig / SubtypeInvalidClient (10003 / 10014) typed error — identical to what every token-resolving command returns. Transport, HTTP-status and JSON-parse failures stay raw (untyped) so callers can use errs.IsTyped to separate a deterministic credential rejection from upstream noise. doResolveTAT now delegates to FetchTAT; observable behavior unchanged. * feat: validate credentials after config init After config init saves the App ID / App Secret, fire a best-effort probe: mint a tenant access token with the just-saved credentials, then POST the application probe endpoint. When the credentials are deterministically rejected, FetchTAT returns a typed errs.* error and runProbe propagates it, so config init exits non-zero with the canonical ConfigError / invalid_client envelope (the same one every other command shows for the same bad creds) instead of letting the user discover the mistake on a later request. Ambiguous failures (transport, HTTP non-200, JSON parse, timeout, http-client init) come back untyped and are swallowed (errs.IsTyped is the discriminator), so a valid configuration is never blocked by upstream noise. The probe is wired into all four init paths and skipped when the user reused an existing secret. The saved config is not rolled back on rejection: stdout still records what was saved, stderr carries the typed error envelope. --- cmd/config/init.go | 16 ++ cmd/config/init_probe.go | 91 ++++++++ cmd/config/init_probe_test.go | 288 ++++++++++++++++++++++++ internal/credential/default_provider.go | 39 +--- internal/credential/tat_fetch.go | 70 ++++++ internal/credential/tat_fetch_test.go | 237 +++++++++++++++++++ 6 files changed, 704 insertions(+), 37 deletions(-) create mode 100644 cmd/config/init_probe.go create mode 100644 cmd/config/init_probe_test.go create mode 100644 internal/credential/tat_fetch.go create mode 100644 internal/credential/tat_fetch_test.go diff --git a/cmd/config/init.go b/cmd/config/init.go index caef603e..de8f7b35 100644 --- a/cmd/config/init.go +++ b/cmd/config/init.go @@ -341,6 +341,9 @@ func configInitRun(opts *ConfigInitOptions) error { output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) printLangPreferenceConfirmation(opts) output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand}) + if err := runProbe(opts.Ctx, f, opts.AppID, opts.appSecret, brand); err != nil { + return err + } return nil } @@ -380,6 +383,9 @@ func configInitRun(opts *ConfigInitOptions) error { } printLangPreferenceConfirmation(opts) output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand}) + if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil { + return err + } return nil } @@ -419,6 +425,11 @@ func configInitRun(opts *ConfigInitOptions) error { output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID)) } printLangPreferenceConfirmation(opts) + if result.AppSecret != "" { + if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil { + return err + } + } return nil } @@ -507,5 +518,10 @@ func configInitRun(opts *ConfigInitOptions) error { } output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) printLangPreferenceConfirmation(opts) + if appSecretInput != "" { + if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != nil { + return err + } + } return nil } diff --git a/cmd/config/init_probe.go b/cmd/config/init_probe.go new file mode 100644 index 00000000..e56aa144 --- /dev/null +++ b/cmd/config/init_probe.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/build" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/credential" +) + +// probeTimeout is the total wall-clock budget for the credential probe step +// (covering both TAT acquisition and the subsequent probe request). +const probeTimeout = 3 * time.Second + +// runProbe runs a best-effort credential validation after config init has +// persisted the App ID and App Secret. It returns a non-nil error only for a +// deterministic credential-rejection signal; every other outcome returns nil +// so that valid configurations and transient/upstream noise never block the +// command. +// +// The function performs up to two HTTP calls in series, bounded by +// probeTimeout: +// +// 1. A TAT request using the just-saved credentials. credential.FetchTAT +// returns a typed errs.* error (via the shared classifyTATResponseCode) +// only when the server deterministically rejected the credentials — a +// non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient +// (10003 / 10014) or whatever codemeta maps. That typed error is propagated +// so the root dispatcher renders the canonical envelope and `config init` +// exits non-zero — identical to how every other token-resolving command +// reports the same bad credentials. Ambiguous failures (transport errors, +// HTTP non-200, JSON parse errors, timeouts) come back as raw untyped +// errors and are swallowed (return nil), so valid configurations are never +// disturbed by upstream noise. errs.IsTyped is the discriminator. +// +// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of +// that call (success, server error, timeout, parse failure) is always +// ignored — return nil regardless. +func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand core.LarkBrand) error { + if factory == nil { + return nil + } + httpClient, err := factory.HttpClient() + if err != nil { + return nil + } + + ctx, cancel := context.WithTimeout(parent, probeTimeout) + defer cancel() + + token, err := credential.FetchTAT(ctx, httpClient, brand, appID, appSecret) + if err != nil { + // A typed error from FetchTAT is a deterministic credential rejection + // (classifyTATResponseCode). Propagate it so config init exits with the + // same envelope the rest of the CLI uses for bad credentials. Untyped + // errors are ambiguous (transport / HTTP / parse / timeout) — stay + // silent and let the command succeed. + if errs.IsTyped(err) { + return err + } + return nil + } + + // TAT succeeded — fire the probe call. Any outcome is ignored. + url := core.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe" + body := []byte(fmt.Sprintf(`{"from":"lark-cli/%s"}`, build.Version)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + return nil +} diff --git a/cmd/config/init_probe_test.go b/cmd/config/init_probe_test.go new file mode 100644 index 00000000..097817d7 --- /dev/null +++ b/cmd/config/init_probe_test.go @@ -0,0 +1,288 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/build" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +// fakeRT routes requests to per-path handlers and records what it saw. +type fakeRT struct { + tatHandler func(req *http.Request) (*http.Response, error) + probeHandler func(req *http.Request) (*http.Response, error) + tatCalls int + probeCalls int + probeReq *http.Request + probeBody string +} + +func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) { + switch { + case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"): + f.tatCalls++ + if f.tatHandler == nil { + return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil + } + return f.tatHandler(req) + case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"): + f.probeCalls++ + f.probeReq = req + if req.Body != nil { + b, _ := io.ReadAll(req.Body) + f.probeBody = string(b) + } + if f.probeHandler == nil { + return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil + } + return f.probeHandler(req) + } + return nil, errors.New("unexpected URL: " + req.URL.String()) +} + +func jsonResp(code int, body string) *http.Response { + return &http.Response{ + StatusCode: code, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + } +} + +// fakeFactory builds a test Factory whose HttpClient is overridden to use +// the caller-supplied RoundTripper. +// +// Wired through cmdutil.TestFactory(t, nil) so the canonical IOStreams, +// Credential, Keychain and FileIO wiring is in place (per repo test-factory +// guidance). The HttpClient is then swapped to our stub so we can drive +// exact HTTP responses for the probe. Config-dir isolation is set up via +// t.Setenv(LARKSUITE_CLI_CONFIG_DIR, t.TempDir()) so any incidental config +// touch lands in a temp dir rather than the developer's real config. +// +// The returned buffer is the Factory's stderr. runProbe never writes to +// stderr (it propagates a typed error or stays silent), so every test asserts +// this buffer stays empty as an invariant. +func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.Buffer) { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, errBuf, _ := cmdutil.TestFactory(t, nil) + f.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + } + return f, errBuf +} + +// assertConfigRejection asserts runProbe propagated a deterministic credential +// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient) with +// the expected upstream code. This is the same typed error every other +// token-resolving command returns for the same bad credentials, and nothing is +// written to stderr (the root dispatcher renders the envelope). +func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) { + t.Helper() + if err == nil { + t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode) + } + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err) + } + if cfgErr.Category != errs.CategoryConfig { + t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig) + } + if cfgErr.Subtype != errs.SubtypeInvalidClient { + t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient) + } + if cfgErr.Code != wantCode { + t.Errorf("Code = %d, want %d", cfgErr.Code, wantCode) + } + if errBuf.Len() != 0 { + t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String()) + } +} + +// assertSilent asserts runProbe stayed quiet: no propagated error and nothing +// written to stderr. Used for every ambiguous (non-credential) outcome. +func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) { + t.Helper() + if err != nil { + t.Errorf("expected nil (silent), got error: %v", err) + } + if errBuf.Len() != 0 { + t.Errorf("expected no stderr output, got: %q", errBuf.String()) + } +} + +// 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated. +func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) { + rt := &fakeRT{ + tatHandler: func(req *http.Request) (*http.Response, error) { + return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil + }, + } + f, errBuf := fakeFactory(t, rt) + + err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu) + + if rt.probeCalls != 0 { + t.Error("probe endpoint must not be called when TAT fails") + } + assertConfigRejection(t, err, errBuf, 10003) +} + +// 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta — +// the most common real-world rejection, propagated. +func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) { + rt := &fakeRT{ + tatHandler: func(req *http.Request) (*http.Response, error) { + return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil + }, + } + f, errBuf := fakeFactory(t, rt) + assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014) +} + +// Any non-zero body code is a deterministic rejection and propagates (typed). +// An unrecognized code falls back to *errs.APIError via BuildAPIError — still +// typed, so the probe still surfaces it rather than swallowing. +func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) { + rt := &fakeRT{ + tatHandler: func(req *http.Request) (*http.Response, error) { + return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil + }, + } + f, errBuf := fakeFactory(t, rt) + err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu) + if err == nil || !errs.IsTyped(err) { + t.Fatalf("expected a propagated typed error, got %T: %v", err, err) + } + if errBuf.Len() != 0 { + t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String()) + } +} + +// Non-200 HTTP at the TAT endpoint is ambiguous (not a payload credential +// rejection) → silent, exit 0. +func TestRunProbe_TATHTTPNon200_Silent(t *testing.T) { + for _, code := range []int{401, 403, 500} { + rt := &fakeRT{ + tatHandler: func(req *http.Request) (*http.Response, error) { + return jsonResp(code, `nope`), nil + }, + } + f, errBuf := fakeFactory(t, rt) + assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf) + } +} + +func TestRunProbe_TATTransportError_Silent(t *testing.T) { + rt := &fakeRT{ + tatHandler: func(req *http.Request) (*http.Response, error) { + return nil, errors.New("network down") + }, + } + f, errBuf := fakeFactory(t, rt) + assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf) +} + +func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) { + rt := &fakeRT{ + probeHandler: func(req *http.Request) (*http.Response, error) { + return jsonResp(500, `server error`), nil + }, + } + f, errBuf := fakeFactory(t, rt) + err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu) + if rt.probeCalls != 1 { + t.Errorf("probe should be called once, got %d", rt.probeCalls) + } + assertSilent(t, err, errBuf) +} + +func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) { + rt := &fakeRT{} + f, errBuf := fakeFactory(t, rt) + err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu) + if rt.tatCalls != 1 || rt.probeCalls != 1 { + t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls) + } + assertSilent(t, err, errBuf) +} + +func TestRunProbe_ProbeRequestShape(t *testing.T) { + rt := &fakeRT{} + f, _ := fakeFactory(t, rt) + if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if rt.probeReq == nil { + t.Fatal("probe request not captured") + } + if rt.probeReq.Method != http.MethodPost { + t.Errorf("probe method = %s, want POST", rt.probeReq.Method) + } + if got := rt.probeReq.URL.String(); got != "https://open.feishu.cn/open-apis/application/v6/larksuite_cli_app/probe" { + t.Errorf("probe URL = %s", got) + } + if got := rt.probeReq.Header.Get("Authorization"); got != "Bearer t-ok" { + t.Errorf("Authorization = %q, want Bearer t-ok", got) + } + if !strings.Contains(rt.probeBody, `"from":"lark-cli/`+build.Version+`"`) { + t.Errorf("probe body missing from field: %s", rt.probeBody) + } +} + +func TestRunProbe_LarkBrand_HostRoutedCorrectly(t *testing.T) { + rt := &fakeRT{} + f, _ := fakeFactory(t, rt) + if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandLark); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rt.probeReq == nil { + t.Fatal("probe request not captured") + } + if !strings.Contains(rt.probeReq.URL.Host, "larksuite.com") { + t.Errorf("probe host = %s, want larksuite.com", rt.probeReq.URL.Host) + } +} + +func TestRunProbe_HTTPClientError_Silent(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, errBuf, _ := cmdutil.TestFactory(t, nil) + f.HttpClient = func() (*http.Client, error) { + return nil, errors.New("client init failed") + } + assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf) +} + +func TestRunProbe_TimeoutHonored(t *testing.T) { + rt := &fakeRT{ + tatHandler: func(req *http.Request) (*http.Response, error) { + <-req.Context().Done() + return nil, req.Context().Err() + }, + } + f, errBuf := fakeFactory(t, rt) + + start := time.Now() + err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu) + elapsed := time.Since(start) + + if elapsed > 4*time.Second { + t.Errorf("runProbe took %v, expected <= ~3s", elapsed) + } + // A timeout is an ambiguous failure (context deadline → untyped), so it + // must stay silent and not block. + assertSilent(t, err, errBuf) +} diff --git a/internal/credential/default_provider.go b/internal/credential/default_provider.go index 1f8a4f78..9482b284 100644 --- a/internal/credential/default_provider.go +++ b/internal/credential/default_provider.go @@ -4,9 +4,7 @@ package credential import ( - "bytes" "context" - "encoding/json" "fmt" "io" "net/http" @@ -166,42 +164,9 @@ func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult, if err != nil { return nil, err } - ep := core.ResolveEndpoints(acct.Brand) - url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal" - - body, err := json.Marshal(map[string]string{ - "app_id": acct.AppID, - "app_secret": acct.AppSecret, - }) - if err != nil { - return nil, fmt.Errorf("failed to marshal TAT request: %w", err) - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + token, err := FetchTAT(ctx, httpClient, acct.Brand, acct.AppID, acct.AppSecret) if err != nil { return nil, err } - req.Header.Set("Content-Type", "application/json") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode) - } - - var result struct { - Code int `json:"code"` - Msg string `json:"msg"` - TenantAccessToken string `json:"tenant_access_token"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("failed to parse TAT response: %w", err) - } - if result.Code != 0 { - return nil, classifyTATResponseCode(result.Code, result.Msg, string(acct.Brand), acct.AppID) - } - return &TokenResult{Token: result.TenantAccessToken}, nil + return &TokenResult{Token: token}, nil } diff --git a/internal/credential/tat_fetch.go b/internal/credential/tat_fetch.go new file mode 100644 index 00000000..eecaed20 --- /dev/null +++ b/internal/credential/tat_fetch.go @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package credential + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/larksuite/cli/internal/core" +) + +// FetchTAT performs a single HTTP POST to mint a tenant access token with the +// given credentials. It does not read configuration or keychain, so callers +// that already hold plaintext credentials (e.g. the post-`config init` probe) +// can validate them without a second keychain round-trip. +// +// A non-zero TAT response code means the server inspected the payload and +// rejected the credentials; FetchTAT returns the canonical typed error from +// classifyTATResponseCode — the SAME classification doResolveTAT (and thus +// every token-resolving command) produces, so callers see one consistent +// envelope (CategoryConfig / SubtypeInvalidClient for 10003 / 10014, etc.). +// Transport, HTTP-status and JSON-parse failures are returned raw (untyped), +// leaving them ambiguous; a caller can use errs.IsTyped to tell a deterministic +// credential rejection apart from upstream/transport noise. +// +// The caller owns the context timeout. +func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) { + ep := core.ResolveEndpoints(brand) + url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal" + + body, err := json.Marshal(map[string]string{ + "app_id": appID, + "app_secret": appSecret, + }) + if err != nil { + return "", fmt.Errorf("failed to marshal TAT request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode) + } + + var result struct { + Code int `json:"code"` + Msg string `json:"msg"` + TenantAccessToken string `json:"tenant_access_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to parse TAT response: %w", err) + } + if result.Code != 0 { + return "", classifyTATResponseCode(result.Code, result.Msg, string(brand), appID) + } + return result.TenantAccessToken, nil +} diff --git a/internal/credential/tat_fetch_test.go b/internal/credential/tat_fetch_test.go new file mode 100644 index 00000000..686d98cc --- /dev/null +++ b/internal/credential/tat_fetch_test.go @@ -0,0 +1,237 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package credential + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/core" +) + +// stubRoundTripper lets us assert request shape and return canned responses. +type stubRoundTripper struct { + gotReq *http.Request + gotBody string + respCode int + respBody string + err error +} + +func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + s.gotReq = req + if req.Body != nil { + b, _ := io.ReadAll(req.Body) + s.gotBody = string(b) + } + if s.err != nil { + return nil, s.err + } + return &http.Response{ + StatusCode: s.respCode, + Body: io.NopCloser(strings.NewReader(s.respBody)), + Header: make(http.Header), + }, nil +} + +func TestFetchTAT_Success(t *testing.T) { + rt := &stubRoundTripper{ + respCode: 200, + respBody: `{"code":0,"tenant_access_token":"t-abc","msg":"ok"}`, + } + hc := &http.Client{Transport: rt} + + token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token != "t-abc" { + t.Errorf("token = %q, want t-abc", token) + } + if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" { + t.Errorf("url = %s", rt.gotReq.URL.String()) + } + if !strings.Contains(rt.gotBody, `"app_id":"cli_app"`) || !strings.Contains(rt.gotBody, `"app_secret":"secret_x"`) { + t.Errorf("request body missing credentials: %s", rt.gotBody) + } +} + +// 10003 (bad / non-existent app_id, "invalid param") is classified locally by +// classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same +// typed error doResolveTAT (and thus every token-resolving command) returns. +func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) { + rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10003,"msg":"invalid param"}`} + hc := &http.Client{Transport: rt} + + token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + if err == nil { + t.Fatal("expected error for code 10003") + } + if token != "" { + t.Errorf("token = %q, want empty", token) + } + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("error not *errs.ConfigError: %T %v", err, err) + } + if cfgErr.Category != errs.CategoryConfig { + t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig) + } + if cfgErr.Subtype != errs.SubtypeInvalidClient { + t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient) + } + if cfgErr.Code != 10003 { + t.Errorf("Code = %d, want 10003", cfgErr.Code) + } +} + +// 10014 ("app secret invalid") — the most common real-world rejection (real +// app_id + wrong secret) — is globally mapped in codemeta to +// CategoryConfig / SubtypeInvalidClient via BuildAPIError. +func TestFetchTAT_Code10014_ConfigInvalidClient(t *testing.T) { + rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10014,"msg":"app secret invalid"}`} + hc := &http.Client{Transport: rt} + + _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("error not *errs.ConfigError: %T %v", err, err) + } + if cfgErr.Subtype != errs.SubtypeInvalidClient || cfgErr.Code != 10014 { + t.Errorf("got Subtype=%q Code=%d, want invalid_client/10014", cfgErr.Subtype, cfgErr.Code) + } +} + +// Any non-zero body code is a deterministic server-side rejection, so it +// always yields a typed error (errs.IsTyped). An unrecognized code falls back +// to CategoryAPI / SubtypeUnknown via BuildAPIError — still typed, so a probe +// caller still surfaces it rather than silently swallowing. +func TestFetchTAT_UnknownBodyCode_Typed(t *testing.T) { + rt := &stubRoundTripper{respCode: 200, respBody: `{"code":99999,"msg":"future-unknown"}`} + hc := &http.Client{Transport: rt} + + _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + if err == nil { + t.Fatal("expected error for code 99999") + } + if !errs.IsTyped(err) { + t.Fatalf("expected a typed errs.* error, got %T %v", err, err) + } + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Errorf("unknown code should fall back to *errs.APIError, got %T", err) + } +} + +// Non-2xx HTTP is ambiguous (not a payload-level credential rejection) — it +// must stay UNTYPED so a probe caller treats it as upstream noise and stays +// silent. +func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) { + for _, code := range []int{401, 403, 500, 503} { + rt := &stubRoundTripper{respCode: code, respBody: `whatever`} + hc := &http.Client{Transport: rt} + _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + if err == nil { + t.Fatalf("HTTP %d: expected error", code) + } + if errs.IsTyped(err) { + t.Errorf("HTTP %d: must be UNTYPED (ambiguous), got typed %T %v", code, err, err) + } + } +} + +func TestFetchTAT_TransportError_Untyped(t *testing.T) { + sentinel := errors.New("network down") + rt := &stubRoundTripper{err: sentinel} + hc := &http.Client{Transport: rt} + + _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + if err == nil { + t.Fatal("expected error") + } + if errs.IsTyped(err) { + t.Errorf("transport error must be UNTYPED, got typed %T", err) + } + if !errors.Is(err, sentinel) { + t.Errorf("error chain missing sentinel: %v", err) + } +} + +func TestFetchTAT_ParseError_Untyped(t *testing.T) { + rt := &stubRoundTripper{respCode: 200, respBody: `not json`} + hc := &http.Client{Transport: rt} + + _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + if err == nil { + t.Fatal("expected parse error") + } + if errs.IsTyped(err) { + t.Errorf("parse error must be UNTYPED, got typed %T", err) + } +} + +func TestFetchTAT_BrandRouting(t *testing.T) { + tests := []struct { + brand core.LarkBrand + wantURL string + }{ + {core.BrandFeishu, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"}, + {core.BrandLark, "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"}, + } + for _, tc := range tests { + t.Run(string(tc.brand), func(t *testing.T) { + rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"tenant_access_token":"t"}`} + hc := &http.Client{Transport: rt} + if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil { + t.Fatal(err) + } + if got := rt.gotReq.URL.String(); got != tc.wantURL { + t.Errorf("url = %s, want %s", got, tc.wantURL) + } + }) + } +} + +func TestFetchTAT_ContextCanceled(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + defer srv.Close() + + rt := &urlRewriteRT{base: srv.URL} + hc := &http.Client{Transport: rt} + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // pre-canceled + + _, err := FetchTAT(ctx, hc, core.BrandFeishu, "a", "b") + if err == nil { + t.Fatal("expected error for canceled context") + } + if errs.IsTyped(err) { + t.Errorf("canceled context must be UNTYPED, got typed %T", err) + } + if !errors.Is(err, context.Canceled) { + t.Errorf("error chain missing context.Canceled: %v", err) + } +} + +// urlRewriteRT forwards requests to a fixed base URL (test server). +type urlRewriteRT struct{ base string } + +func (r *urlRewriteRT) RoundTrip(req *http.Request) (*http.Response, error) { + newURL := r.base + req.URL.Path + req2, err := http.NewRequestWithContext(req.Context(), req.Method, newURL, req.Body) + if err != nil { + return nil, err + } + req2.Header = req.Header + return http.DefaultTransport.RoundTrip(req2) +}