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) +}