mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 17:45:15 +08:00
* 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.
289 lines
9.7 KiB
Go
289 lines
9.7 KiB
Go
// 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)
|
|
}
|