mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
48 Commits
feat/sessi
...
feat/frame
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e32d7cb42e | ||
|
|
40a0a9de66 | ||
|
|
a8ad44ba13 | ||
|
|
003d0f42f8 | ||
|
|
7946e5c81d | ||
|
|
5cf09ecfda | ||
|
|
41692b7041 | ||
|
|
b79827d60a | ||
|
|
0f35676a28 | ||
|
|
946964e093 | ||
|
|
cfe76ad56a | ||
|
|
fa9c30c690 | ||
|
|
ba95252019 | ||
|
|
4a16139348 | ||
|
|
6e5308af01 | ||
|
|
87be09ef5f | ||
|
|
a575a8ba60 | ||
|
|
1f565a290b | ||
|
|
68a77eee5c | ||
|
|
29a97dbde8 | ||
|
|
29a6a7b600 | ||
|
|
c167163d70 | ||
|
|
7988515e1c | ||
|
|
c7adff7a3b | ||
|
|
59237f3104 | ||
|
|
358cd06838 | ||
|
|
b0b1ca4b5d | ||
|
|
781d188a60 | ||
|
|
2e0fb9a880 | ||
|
|
927b37cd63 | ||
|
|
d2e22c5fca | ||
|
|
fdae560014 | ||
|
|
1b173e1953 | ||
|
|
57db1b3a8d | ||
|
|
4c1c5f5287 | ||
|
|
3d2c10cd0b | ||
|
|
03de81c5f3 | ||
|
|
7abcaa7f68 | ||
|
|
8fb2476985 | ||
|
|
56c9a2afd8 | ||
|
|
2029189809 | ||
|
|
ee427979a8 | ||
|
|
545abcbbde | ||
|
|
4a73e83f1e | ||
|
|
7496420fa8 | ||
|
|
43fabdf524 | ||
|
|
8c46c74105 | ||
|
|
70777c86c3 |
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -1,4 +1,7 @@
|
||||
/go.mod @liangshuo-1
|
||||
/go.sum @liangshuo-1
|
||||
/internal/ @liangshuo-1
|
||||
/shortcuts/common/ @liangshuo-1
|
||||
|
||||
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||
/skills/ @liangshuo-1
|
||||
|
||||
46
.github/workflows/semantic-review.yml
vendored
46
.github/workflows/semantic-review.yml
vendored
@@ -25,19 +25,16 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||
let workflowPath = run.path || "";
|
||||
if (!workflowPath) {
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
workflowPath = workflow.path || "";
|
||||
}
|
||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
|
||||
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
|
||||
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
|
||||
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
|
||||
@@ -253,19 +250,16 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
|
||||
let workflowPath = run.path || "";
|
||||
if (!workflowPath) {
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
workflowPath = workflow.path || "";
|
||||
}
|
||||
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
|
||||
const workflowId = Number(run.workflow_id || 0);
|
||||
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
|
||||
const { data: workflow } = await github.rest.actions.getWorkflow({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
|
||||
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
|
||||
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
|
||||
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
|
||||
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
|
||||
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
|
||||
|
||||
88
CHANGELOG.md
88
CHANGELOG.md
@@ -2,6 +2,90 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.81] - 2026-07-31
|
||||
|
||||
### Features
|
||||
|
||||
- support visible_rule for form questions (#1891)
|
||||
- **contact**: add bot search shortcut (#2083)
|
||||
- add SXSD schema validation to Slides lint (#2103)
|
||||
- **drive**: add comment-operation shortcuts (#1898)
|
||||
- **drive**: extend permission shortcuts for Miaoda (#2070)
|
||||
- **apps**: add cache debug commands (+cache-get/-delete/-clear) (#1896)
|
||||
- support source file preview artifacts (#2085)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **contact**: stop bot match segments carrying tags or empty entries (#2115)
|
||||
- **base**: resolve Base URL block types accurately (#2099)
|
||||
- **drive**: use title for default download filename (#2089)
|
||||
- drop stale target version from root upgrade prompt (#2100)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **calendar**: warn against container-default timezone in time conversion (#2104)
|
||||
- **calendar**: confirm scope before editing recurring events (#2119)
|
||||
- **base**: clarify form and file operation routing (#2110)
|
||||
|
||||
### Misc
|
||||
|
||||
- add protected public domain allowlists (#2111)
|
||||
|
||||
## [v1.0.80] - 2026-07-29
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: add +member-list shortcut (#1795)
|
||||
- **drive**: add +permission-get-setting shortcut (#1738)
|
||||
- propagate invocation metadata (#2097)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
|
||||
- **slides**: +create 的参数下沉到 create.md,主 skill 只留路由 (#2096)
|
||||
|
||||
### Tests
|
||||
|
||||
- **e2e**: wait for base role update visibility (#2087)
|
||||
|
||||
### Misc
|
||||
|
||||
- Feat/detect line text overlap (#2069)
|
||||
|
||||
## [v1.0.79] - 2026-07-28
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: update xsd (#2067)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **ci**: validate static workflow identity (#2015)
|
||||
- **sheets**: recognize OFL0X local office tokens (#2063)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **calendar**: clarify identity selection by event ownership (#2071)
|
||||
- **slides**: add formula inline element syntax to quick-ref (#2077)
|
||||
|
||||
## [v1.0.78] - 2026-07-27
|
||||
|
||||
### Features
|
||||
|
||||
- event description support rich text (#1975)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: restrict canvas overflow checks
|
||||
- **slides**: upgrade text overflow to error above 10px threshold
|
||||
- **slides**: detect letterSpacing-driven text overflow
|
||||
- **slides**: downgrade background-decoration text overflow to info
|
||||
- **slides**: allow chartParsedValues roundtrip tag
|
||||
- refine character width estimation for lark-slides text lint
|
||||
- **slides**: preserve info lint severity
|
||||
- **slides**: text may over flow shape
|
||||
- exempt ghost text from slides lint
|
||||
|
||||
## [v1.0.77] - 2026-07-24
|
||||
|
||||
### Features
|
||||
@@ -1667,6 +1751,10 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.81]: https://github.com/larksuite/cli/releases/tag/v1.0.81
|
||||
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
|
||||
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
|
||||
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
|
||||
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
|
||||
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
|
||||
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
|
||||
|
||||
@@ -310,10 +310,6 @@ lark-cli config risk-control default
|
||||
|
||||
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#larksuite/cli&Date)
|
||||
|
||||
## Contributing
|
||||
|
||||
Community contributions are welcome! If you find a bug or have feature suggestions, please submit an [Issue](https://github.com/larksuite/cli/issues) or [Pull Request](https://github.com/larksuite/cli/pulls).
|
||||
|
||||
@@ -311,10 +311,6 @@ lark-cli config risk-control default
|
||||
|
||||
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#larksuite/cli&Date)
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎社区贡献!如果你发现 bug 或有功能建议,请提交 [Issue](https://github.com/larksuite/cli/issues) 或 [Pull Request](https://github.com/larksuite/cli/pulls)。
|
||||
|
||||
@@ -23,6 +23,41 @@ lark-cli contact +search-user --query "alice" --as user
|
||||
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
|
||||
```
|
||||
|
||||
## +search-bot
|
||||
Search bots (apps) by keyword. Pass `--query` or `--queries`; use `--chat-ids` to search within specific chats.
|
||||
|
||||
### Skills
|
||||
- lark-contact/references/lark-contact-search-bot.md
|
||||
|
||||
### Avoid when
|
||||
- Looking for a person rather than a bot → use [[+search-user]]
|
||||
- Running as a bot — this shortcut is user-only
|
||||
|
||||
### Tips
|
||||
- `has_more=true` means the search is incomplete; refine the keyword or search scope instead of paginating
|
||||
|
||||
### Examples
|
||||
|
||||
**Find bots by keyword**
|
||||
```bash
|
||||
lark-cli contact +search-bot --query "会议助手" --as user
|
||||
```
|
||||
|
||||
**Search inside one chat**
|
||||
```bash
|
||||
lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as user
|
||||
```
|
||||
|
||||
**Find bots you've chatted with**
|
||||
```bash
|
||||
lark-cli contact +search-bot --query "助手" --has-chatted --as user
|
||||
```
|
||||
|
||||
**Search several bot keywords in one call**
|
||||
```bash
|
||||
lark-cli contact +search-bot --queries "会议助手,日报助手,审批助手" --as user
|
||||
```
|
||||
|
||||
## +get-user
|
||||
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.
|
||||
|
||||
|
||||
@@ -386,7 +386,7 @@ func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T)
|
||||
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
||||
})
|
||||
tokenResolver := &authScopesTokenResolver{}
|
||||
f.Credential = newAuthTestCredentialProvider("test-app", tokenResolver)
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
|
||||
|
||||
appInfoStub := &httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
@@ -442,7 +442,7 @@ func TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError(t *testing.T)
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
tokenResolver := &authScopesTokenResolver{}
|
||||
f.Credential = newAuthTestCredentialProvider("test-app", tokenResolver)
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
@@ -485,18 +485,6 @@ type authScopesTokenResolver struct {
|
||||
requests []credential.TokenSpec
|
||||
}
|
||||
|
||||
type authTestAccountResolver struct {
|
||||
appID string
|
||||
}
|
||||
|
||||
func (r authTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return &credential.Account{AppID: r.appID, Brand: core.BrandFeishu}, nil
|
||||
}
|
||||
|
||||
func newAuthTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
|
||||
return credential.NewCredentialProvider(nil, authTestAccountResolver{appID: appID}, tokenResolver, nil)
|
||||
}
|
||||
|
||||
func (r *authScopesTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
r.requests = append(r.requests, req)
|
||||
switch req.Type {
|
||||
|
||||
@@ -27,9 +27,6 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "View current auth status",
|
||||
Long: `Show OAuth user login, token validity, and granted scopes.
|
||||
For token-validity checks, run lark-cli auth status --json --verify.
|
||||
This is not profile/app selection diagnostics; use lark-cli whoami for the effective app/profile identity used by an invocation.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
|
||||
@@ -4,35 +4,15 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
envprovider "github.com/larksuite/cli/extension/credential/env"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAuthStatusHelpDistinguishesFromWhoami(t *testing.T) {
|
||||
cmd := NewCmdAuthStatus(nil, nil)
|
||||
for _, want := range []string{
|
||||
"OAuth user login",
|
||||
"auth status --json --verify",
|
||||
"not profile/app selection diagnostics",
|
||||
"lark-cli whoami",
|
||||
} {
|
||||
if !strings.Contains(cmd.Long, want) {
|
||||
t.Errorf("auth status --help Long missing %q; got:\n%s", want, cmd.Long)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
@@ -99,51 +79,6 @@ func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type fixedStatusAccountResolver struct {
|
||||
account *credential.Account
|
||||
}
|
||||
|
||||
func (r *fixedStatusAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return r.account, nil
|
||||
}
|
||||
|
||||
func TestAuthStatus_AllowsMatchingAppIDOnlySelectedProfile(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envvars.CliAppID, "cli_a")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "tenant_a",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "tenant_a",
|
||||
AppId: "cli_a",
|
||||
AppSecret: core.PlainSecret("test-secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
|
||||
config := &core.CliConfig{ProfileName: "tenant_a", AppID: "cli_a", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, config)
|
||||
f.Credential = credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&envprovider.Provider{}},
|
||||
&fixedStatusAccountResolver{account: credential.AccountFromCliConfig(config)},
|
||||
nil,
|
||||
nil,
|
||||
).WithProfileFromFlag("tenant_a")
|
||||
|
||||
cmd := NewCmdAuth(f)
|
||||
cmd.SetArgs([]string{"status", "--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth status should use the selected built-in profile: %v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "credentials are provided externally") {
|
||||
t.Fatalf("matching APP_ID-only env was misclassified as external:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
type statusOutput struct {
|
||||
Identity string `json:"identity"`
|
||||
Verified *bool `json:"verified"`
|
||||
|
||||
@@ -6,10 +6,8 @@ package cmd
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
@@ -28,13 +26,5 @@ func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error
|
||||
if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) {
|
||||
return cmdutil.InvocationContext{}, err
|
||||
}
|
||||
|
||||
profileFromFlag := fs.Changed("profile")
|
||||
if !profileFromFlag {
|
||||
globals.Profile = os.Getenv(envvars.CliProfile)
|
||||
}
|
||||
return cmdutil.InvocationContext{
|
||||
Profile: globals.Profile,
|
||||
ProfileFromFlag: profileFromFlag,
|
||||
}, nil
|
||||
return cmdutil.InvocationContext{Profile: globals.Profile}, nil
|
||||
}
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
|
||||
@@ -74,58 +70,3 @@ func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
|
||||
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapProfileEnvFallback(t *testing.T) {
|
||||
t.Run("flag wins over env", func(t *testing.T) {
|
||||
t.Setenv(envvars.CliProfile, "tenant_env")
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile", "tenant_flag", "whoami"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if inv.Profile != "tenant_flag" {
|
||||
t.Errorf("got %q, want tenant_flag", inv.Profile)
|
||||
}
|
||||
if !inv.ProfileFromFlag {
|
||||
t.Errorf("ProfileFromFlag = false, want true")
|
||||
}
|
||||
})
|
||||
t.Run("explicit empty flag clears env selection", func(t *testing.T) {
|
||||
t.Setenv(envvars.CliProfile, "tenant_env")
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile=", "whoami"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if inv.Profile != "" {
|
||||
t.Errorf("got %q, want empty", inv.Profile)
|
||||
}
|
||||
if !inv.ProfileFromFlag {
|
||||
t.Errorf("ProfileFromFlag = false, want true")
|
||||
}
|
||||
})
|
||||
t.Run("env used when flag absent", func(t *testing.T) {
|
||||
t.Setenv(envvars.CliProfile, "tenant_env")
|
||||
inv, err := BootstrapInvocationContext([]string{"whoami"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if inv.Profile != "tenant_env" {
|
||||
t.Errorf("got %q, want tenant_env", inv.Profile)
|
||||
}
|
||||
if inv.ProfileFromFlag {
|
||||
t.Errorf("ProfileFromFlag = true, want false")
|
||||
}
|
||||
})
|
||||
t.Run("empty when neither set", func(t *testing.T) {
|
||||
t.Setenv(envvars.CliProfile, "")
|
||||
inv, err := BootstrapInvocationContext([]string{"whoami"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if inv.Profile != "" {
|
||||
t.Errorf("got %q, want empty", inv.Profile)
|
||||
}
|
||||
if inv.ProfileFromFlag {
|
||||
t.Errorf("ProfileFromFlag = true, want false")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -84,16 +84,6 @@ func TestConfigShowCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowHelpClarifiesSavedConfig(t *testing.T) {
|
||||
cmd := NewCmdConfigShow(nil, nil)
|
||||
if !strings.Contains(cmd.Short, "saved config") {
|
||||
t.Errorf("config show short = %q, want saved config", cmd.Short)
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
|
||||
t.Errorf("config show help missing whoami route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
@@ -116,77 +106,6 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// config show promises "saved config, not current usage" (help + skill
|
||||
// routing): the session profile (--profile / LARKSUITE_CLI_PROFILE) must not
|
||||
// change what it shows.
|
||||
func TestConfigShowRun_IgnoresSessionProfile(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "tenant_a",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "tenant_a", AppId: "cli_a", AppSecret: core.PlainSecret("your-secret-a"), Brand: core.BrandFeishu},
|
||||
{Name: "tenant_b", AppId: "cli_b", AppSecret: core.PlainSecret("your-secret-b"), Brand: core.BrandFeishu},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Invocation.Profile = "tenant_b" // session selection must not leak in
|
||||
|
||||
if err := configShowRun(&ConfigShowOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("configShowRun: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"cli_a"`) || !strings.Contains(out, `"tenant_a"`) {
|
||||
t.Fatalf("output = %s, want the saved default tenant_a/cli_a", out)
|
||||
}
|
||||
if strings.Contains(out, `"cli_b"`) {
|
||||
t.Fatalf("output = %s, session profile tenant_b must not change saved-config view", out)
|
||||
}
|
||||
}
|
||||
|
||||
// engagedEnvStub simulates a fully engaged external credential provider.
|
||||
type engagedEnvStub struct{}
|
||||
|
||||
func (engagedEnvStub) Name() string { return "env" }
|
||||
func (engagedEnvStub) Priority() int { return 10 }
|
||||
func (engagedEnvStub) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||
return &extcred.Account{AppID: "cli_env", AppSecret: "your-password"}, nil // managed takeover
|
||||
}
|
||||
func (engagedEnvStub) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// config show inspects the SAVED config only, so the parent command's
|
||||
// external-credential gate must not apply: even with a fully engaged direct
|
||||
// env credential, `config show` still answers from the saved config.
|
||||
func TestConfigShow_BypassesExternalCredentialGate(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "tenant_a",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "tenant_a", AppId: "cli_a", AppSecret: core.PlainSecret("your-secret-a"), Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Credential = credential.NewCredentialProvider([]extcred.Provider{engagedEnvStub{}}, nil, nil, nil)
|
||||
|
||||
cmd := NewCmdConfig(f)
|
||||
cmd.SetArgs([]string{"show"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("config show must bypass the external-credential gate: %v", err)
|
||||
}
|
||||
if out := stdout.String(); !strings.Contains(out, `"cli_a"`) {
|
||||
t.Fatalf("output = %s, want the saved config shown", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
multi := &core.MultiAppConfig{
|
||||
@@ -562,8 +481,7 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
|
||||
}{
|
||||
{"init", []string{"init", "--app-id", "x", "--app-secret-stdin"}},
|
||||
{"remove", []string{"remove"}},
|
||||
// "show" is deliberately absent: it inspects the SAVED config only
|
||||
// and bypasses this gate (TestConfigShow_BypassesExternalCredentialGate).
|
||||
{"show", []string{"show"}},
|
||||
{"default-as", []string{"default-as", "user"}},
|
||||
{"strict-mode", []string{"strict-mode", "off"}},
|
||||
}
|
||||
|
||||
@@ -179,8 +179,8 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
}
|
||||
|
||||
// Step 1: Request app registration (begin)
|
||||
// Use the shared proxy-plugin-aware transport so registration traffic is not
|
||||
// a bypass of proxy plugin mode.
|
||||
// Registration is platform traffic, so it must use the provider-aware
|
||||
// transport as well as the shared proxy configuration.
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
authResp, err := larkauth.RequestAppRegistration(ctx, httpClient, larkBrand, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
|
||||
@@ -27,16 +27,7 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "show",
|
||||
Short: "Show saved config",
|
||||
Long: "Shows saved config. To see the app/profile lark-cli is using now, run `lark-cli whoami --json`.",
|
||||
// Override parent's RequireBuiltinCredentialProvider check: this
|
||||
// command reads the SAVED config only (its own help promises "saved
|
||||
// config, not current usage"), so the currently effective credential
|
||||
// source — external or otherwise — must not gate it.
|
||||
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
|
||||
c.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
Short: "Show current configuration",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
@@ -62,10 +53,7 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
if config == nil || len(config.Apps) == 0 {
|
||||
return core.NotConfiguredError()
|
||||
}
|
||||
// Saved config only: the session profile (--profile / LARKSUITE_CLI_PROFILE)
|
||||
// must not change what this command shows — the help and skill routing
|
||||
// promise "saved config, not current usage" (use whoami for that).
|
||||
app := config.CurrentAppConfig("")
|
||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
|
||||
}
|
||||
|
||||
@@ -157,8 +157,8 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints)
|
||||
}
|
||||
}
|
||||
|
||||
// Use the shared proxy-plugin-aware transport so connectivity checks reflect
|
||||
// the real egress path (and are blocked when proxy plugin fails closed).
|
||||
// Connectivity checks are platform traffic and must exercise the same
|
||||
// provider-aware route as real platform requests.
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
mcpURL := ep.MCP + "/mcp"
|
||||
|
||||
|
||||
@@ -110,20 +110,8 @@ func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSp
|
||||
return nil, errors.New("backend unavailable")
|
||||
}
|
||||
|
||||
type eventTestAccountResolver struct {
|
||||
appID string
|
||||
}
|
||||
|
||||
func (r eventTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return &credential.Account{AppID: r.appID}, nil
|
||||
}
|
||||
|
||||
func newEventTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
|
||||
return credential.NewCredentialProvider(nil, eventTestAccountResolver{appID: appID}, tokenResolver, nil)
|
||||
}
|
||||
|
||||
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
|
||||
return &cmdutil.Factory{Credential: newEventTestCredentialProvider("cli_x", r)}
|
||||
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
|
||||
}
|
||||
|
||||
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
|
||||
|
||||
@@ -44,7 +44,7 @@ func newTestConsumeRuntime(rt http.RoundTripper) *consumeRuntime {
|
||||
client: &client.APIClient{
|
||||
SDK: sdk,
|
||||
ErrOut: io.Discard,
|
||||
Credential: newEventTestCredentialProvider("test-app", &staticTokenResolver{}),
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
},
|
||||
accessIdentity: core.AsBot,
|
||||
|
||||
@@ -17,14 +17,11 @@ import (
|
||||
)
|
||||
|
||||
// profileListItem is the JSON output for a single profile entry.
|
||||
// `default` (formerly `active`, renamed in this feature as a declared
|
||||
// breaking change) marks the saved default profile — never the identity
|
||||
// effective for the current invocation; that is whoami's job.
|
||||
type profileListItem struct {
|
||||
Name string `json:"name"`
|
||||
AppID string `json:"appId"`
|
||||
Brand core.LarkBrand `json:"brand"`
|
||||
Default bool `json:"default"`
|
||||
Active bool `json:"active"`
|
||||
User string `json:"user,omitempty"`
|
||||
TokenStatus string `json:"tokenStatus,omitempty"`
|
||||
}
|
||||
@@ -33,8 +30,7 @@ type profileListItem struct {
|
||||
func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List saved profiles",
|
||||
Long: "Lists saved profiles. To see the app/profile lark-cli is using now, run `lark-cli whoami --json`.",
|
||||
Short: "List all profiles",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return profileListRun(f)
|
||||
},
|
||||
@@ -57,7 +53,7 @@ func profileListRun(f *cmdutil.Factory) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Intentionally uses "" to show the saved default profile, not the ephemeral --profile override.
|
||||
// Intentionally uses "" to show the persistent active profile, not the ephemeral --profile override.
|
||||
currentApp := multi.CurrentAppConfig("")
|
||||
currentName := ""
|
||||
if currentApp != nil {
|
||||
@@ -70,10 +66,10 @@ func profileListRun(f *cmdutil.Factory) error {
|
||||
name := app.ProfileName()
|
||||
|
||||
item := profileListItem{
|
||||
Name: name,
|
||||
AppID: app.AppId,
|
||||
Brand: app.Brand,
|
||||
Default: name == currentName,
|
||||
Name: name,
|
||||
AppID: app.AppId,
|
||||
Brand: app.Brand,
|
||||
Active: name == currentName,
|
||||
}
|
||||
|
||||
if len(app.Users) > 0 {
|
||||
|
||||
@@ -14,17 +14,6 @@ func NewCmdProfile(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "profile",
|
||||
Short: "Manage configuration profiles",
|
||||
Long: `Profiles are named app identities managed by lark-cli.
|
||||
|
||||
Identity diagnostics and profile selection:
|
||||
lark-cli whoami --json Show the app/profile lark-cli is using now.
|
||||
lark-cli auth status --json --verify Verify OAuth login and token state.
|
||||
--profile <name> Use a profile for this command only.
|
||||
LARKSUITE_CLI_PROFILE Use a profile for the current shell / agent session.
|
||||
config show / profile list Inspect saved config, not current usage.
|
||||
unset LARKSUITE_CLI_PROFILE Clear the session profile and fall back to direct app env or configured default.
|
||||
|
||||
A selected profile takes precedence over matching direct env credentials and tokens.`,
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.SetTips(cmd, []string{
|
||||
|
||||
@@ -306,24 +306,14 @@ func TestProfileListRun_OutputsProfiles(t *testing.T) {
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v; output=%s", err, stdout.String())
|
||||
}
|
||||
raw := stdout.String()
|
||||
// `active` is renamed to `default` as a declared breaking change: keeping
|
||||
// a permanently mirrored alias would keep misleading agents into reading
|
||||
// it as the currently effective identity (whoami's job).
|
||||
if strings.Contains(raw, `"active"`) {
|
||||
t.Fatalf("profile list output contains renamed active field: %s", raw)
|
||||
}
|
||||
if !strings.Contains(raw, `"default"`) {
|
||||
t.Fatalf("profile list output missing default field: %s", raw)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(got) = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Name != "default" || !got[0].Default {
|
||||
t.Fatalf("got[0] = %#v, want configured default profile", got[0])
|
||||
if got[0].Name != "default" || !got[0].Active {
|
||||
t.Fatalf("got[0] = %#v, want active default profile", got[0])
|
||||
}
|
||||
if got[1].Name != "target" || got[1].Default {
|
||||
t.Fatalf("got[1] = %#v, want non-default target profile", got[1])
|
||||
if got[1].Name != "target" || got[1].Active {
|
||||
t.Fatalf("got[1] = %#v, want inactive target profile", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,39 +627,6 @@ func TestProfileRemoveRun_ValidationErrors(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestProfileHelpHasSelectionSection asserts `profile --help` documents the
|
||||
// per-invocation flag and session-scoped env var for selecting a profile, so
|
||||
// users and AI agents can find LARKSUITE_CLI_PROFILE without reading source.
|
||||
func TestProfileHelpHasSelectionSection(t *testing.T) {
|
||||
cmd := NewCmdProfile(nil)
|
||||
if !strings.Contains(cmd.Long, "Identity diagnostics and profile selection:") {
|
||||
t.Errorf("profile --help missing identity diagnostics and profile selection section")
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "LARKSUITE_CLI_PROFILE") {
|
||||
t.Errorf("profile --help missing LARKSUITE_CLI_PROFILE")
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
|
||||
t.Errorf("profile --help missing whoami identity route")
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "config show / profile list") {
|
||||
t.Errorf("profile --help missing saved-config boundary")
|
||||
}
|
||||
const precedence = "A selected profile takes precedence over matching direct env credentials and tokens."
|
||||
if !strings.Contains(cmd.Long, precedence) {
|
||||
t.Errorf("profile --help missing precedence statement %q", precedence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListHelpClarifiesSavedProfiles(t *testing.T) {
|
||||
cmd := NewCmdProfileList(nil)
|
||||
if !strings.Contains(cmd.Short, "saved profiles") {
|
||||
t.Errorf("profile list short = %q, want saved profiles", cmd.Short)
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "lark-cli whoami --json") {
|
||||
t.Errorf("profile list help missing whoami route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileListRun_InvalidConfigReturnsValidationError(t *testing.T) {
|
||||
dir := setupProfileConfigDir(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {
|
||||
|
||||
@@ -65,7 +65,17 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
|
||||
// Deliberately no target version here: info.Latest comes from the on-disk
|
||||
// cache, which has no expiry (the 24h TTL only throttles refreshes, and a
|
||||
// failed refresh leaves the old value in place), so it can name a version
|
||||
// that is no longer the one npm would install. The version actually
|
||||
// installed is resolved live by the update subcommand, which prints
|
||||
// "Updating lark-cli <cur> -> <latest> via <pm> ..." before installing —
|
||||
// that is where the user sees the real target. Keep going through the
|
||||
// update subcommand rather than calling RunNpmInstall directly, otherwise
|
||||
// that line disappears and the user approves a global install without ever
|
||||
// being told what gets installed.
|
||||
fmt.Fprintf(ios.ErrOut, "A newer lark-cli is available (current %s). Upgrade now? [y/N]: ", info.Current)
|
||||
if !readYes(ios.In) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -128,6 +128,17 @@ func TestOfferRootUpgrade(t *testing.T) {
|
||||
if gotPrompt != tc.wantPrompt {
|
||||
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
|
||||
}
|
||||
// The prompt must not name a target version: info.Latest comes from
|
||||
// the on-disk cache and can be stale, while the version actually
|
||||
// installed is resolved live by the update subcommand.
|
||||
if tc.wantPrompt {
|
||||
if strings.Contains(errBuf.String(), tc.latest) {
|
||||
t.Errorf("prompt must not name the cached target version %q (stderr=%q)", tc.latest, errBuf.String())
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), build.Version) {
|
||||
t.Errorf("prompt must name the current version %q (stderr=%q)", build.Version, errBuf.String())
|
||||
}
|
||||
}
|
||||
if called != tc.wantRun {
|
||||
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
@@ -34,15 +33,6 @@ type whoamiResult struct {
|
||||
TokenStatus string `json:"tokenStatus"`
|
||||
OnBehalfOf *delegatedUser `json:"onBehalfOf,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
|
||||
// CredentialSource, Explicit, and DirectCredentialEnv surface the cached
|
||||
// credential.IdentitySelection computed during resolution (not re-inferred
|
||||
// here). On the non-env extension-provider path CredentialSource is
|
||||
// "extension:<provider>" (e.g. "extension:sidecar"); an empty value only
|
||||
// means the selection was never resolved.
|
||||
CredentialSource string `json:"credentialSource"`
|
||||
Explicit bool `json:"explicit"`
|
||||
DirectCredentialEnv credential.DirectCredentialEnv `json:"directCredentialEnv"`
|
||||
}
|
||||
|
||||
// delegatedUser is the user a user-identity acts on behalf of.
|
||||
@@ -68,10 +58,6 @@ func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "whoami",
|
||||
Short: "Show the current effective identity, app, profile, and token status (JSON)",
|
||||
Long: `Show the effective app identity used by this invocation. This is not OAuth login status;
|
||||
use ` + "`lark-cli auth status --json`" + ` for OAuth user/token state.
|
||||
The JSON output includes credentialSource, appId, brand, and whether direct app credential
|
||||
env is present and matches the selected profile.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return whoamiRun(cmd, opts)
|
||||
},
|
||||
@@ -111,17 +97,7 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
||||
f.ResolveStrictMode(ctx).ForcedIdentity(),
|
||||
)
|
||||
diag := identitydiag.Diagnose(ctx, f, cfg, false)
|
||||
// Read the cached selection computed during resolution; never re-infer it
|
||||
// here. A resolution failure (e.g. under a non-env extension provider that
|
||||
// doesn't populate a selection) degrades to the zero value rather than
|
||||
// regressing whoami's own error/diagnostic path above.
|
||||
var selection credential.IdentitySelection
|
||||
if f.Credential != nil {
|
||||
if sel, err := f.Credential.Selection(ctx); err == nil {
|
||||
selection = sel
|
||||
}
|
||||
}
|
||||
res := buildResult(cfg, as, source, diag, selection)
|
||||
res := buildResult(cfg, as, source, diag)
|
||||
output.PrintJson(f.IOStreams.Out, res)
|
||||
return nil
|
||||
}
|
||||
@@ -146,23 +122,18 @@ func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, stri
|
||||
|
||||
// buildResult maps the resolved identity and local diagnostics into the output.
|
||||
// ResolveAs only ever returns user or bot, so the default branch handles user.
|
||||
// selection is the cached credential.IdentitySelection from resolution; it is
|
||||
// read as-is, never recomputed.
|
||||
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result, selection credential.IdentitySelection) *whoamiResult {
|
||||
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult {
|
||||
defaultAs := cfg.DefaultAs
|
||||
if defaultAs == "" {
|
||||
defaultAs = core.AsAuto
|
||||
}
|
||||
res := &whoamiResult{
|
||||
Profile: cfg.ProfileName,
|
||||
AppID: cfg.AppID,
|
||||
Brand: cfg.Brand,
|
||||
DefaultAs: string(defaultAs),
|
||||
Identity: string(as),
|
||||
IdentitySource: source,
|
||||
CredentialSource: string(selection.Source),
|
||||
Explicit: selection.Explicit(),
|
||||
DirectCredentialEnv: selection.DirectCredentialEnv,
|
||||
Profile: cfg.ProfileName,
|
||||
AppID: cfg.AppID,
|
||||
Brand: cfg.Brand,
|
||||
DefaultAs: string(defaultAs),
|
||||
Identity: string(as),
|
||||
IdentitySource: source,
|
||||
}
|
||||
// Use the diagnosed hint as-is: it is tailored to the credential source, so
|
||||
// it never says "auth login" when that is blocked under an external provider.
|
||||
|
||||
@@ -15,13 +15,10 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
envprovider "github.com/larksuite/cli/extension/credential/env"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
)
|
||||
|
||||
func TestResolveSource(t *testing.T) {
|
||||
@@ -55,7 +52,7 @@ func TestBuildResult_UserValid(t *testing.T) {
|
||||
diag := identitydiag.Result{
|
||||
User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag, credential.IdentitySelection{})
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
|
||||
|
||||
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
@@ -80,7 +77,7 @@ func TestBuildResult_UserMissingToken(t *testing.T) {
|
||||
diag := identitydiag.Result{
|
||||
User: identitydiag.Identity{Available: false, Status: "missing", Hint: "run: lark-cli auth login --help"}, // never logged in
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag, credential.IdentitySelection{})
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
@@ -103,7 +100,7 @@ func TestBuildResult_BotReady(t *testing.T) {
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: true, Status: "ready"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "default_as", diag, credential.IdentitySelection{})
|
||||
r := buildResult(cfg, core.AsBot, "default_as", diag)
|
||||
|
||||
if r.Identity != "bot" || r.IdentitySource != "default_as" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
@@ -124,7 +121,7 @@ func TestBuildResult_BotNotConfigured(t *testing.T) {
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "auto_detect", diag, credential.IdentitySelection{})
|
||||
r := buildResult(cfg, core.AsBot, "auto_detect", diag)
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
@@ -321,94 +318,3 @@ func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) {
|
||||
t.Fatalf("hint should explain external management: %q", got.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// noopWhoamiKeychain is a no-op KeychainAccess; the profile below uses a
|
||||
// plaintext secret, so no keychain lookup is actually required.
|
||||
type noopWhoamiKeychain struct{}
|
||||
|
||||
func (noopWhoamiKeychain) Get(service, account string) (string, error) { return "", nil }
|
||||
func (noopWhoamiKeychain) Set(service, account, value string) error { return nil }
|
||||
func (noopWhoamiKeychain) Remove(service, account string) error { return nil }
|
||||
|
||||
// credentialSourceSecret is the profile secret written to config for
|
||||
// TestWhoamiIncludesCredentialSource. It must never leak into whoami's output
|
||||
// (security: never leak a secret).
|
||||
const credentialSourceSecret = "test-secret"
|
||||
|
||||
// profileSelectionFactory builds a Factory whose CredentialProvider resolves
|
||||
// an explicit profile ("tenant_a") supplied via the LARKSUITE_CLI_PROFILE env
|
||||
// fallback (not --profile), so Selection().Source resolves to
|
||||
// env:LARKSUITE_CLI_PROFILE and Explicit() is true, with no direct
|
||||
// app-credential env vars present.
|
||||
func profileSelectionFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "tenant_a",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "tenant_a",
|
||||
AppId: "cli_a",
|
||||
AppSecret: core.PlainSecret(credentialSourceSecret),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
|
||||
defaultAcct := credential.NewDefaultAccountProvider(func() keychain.KeychainAccess { return noopWhoamiKeychain{} }, "tenant_a")
|
||||
cred := credential.NewCredentialProvider([]extcred.Provider{&envprovider.Provider{}}, defaultAcct, nil, nil)
|
||||
cred.WithProfileFromEnv("tenant_a")
|
||||
|
||||
cfg := &core.CliConfig{ProfileName: "tenant_a", AppID: "cli_a", AppSecret: credentialSourceSecret, Brand: core.BrandFeishu}
|
||||
out := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
||||
Credential: cred,
|
||||
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
|
||||
}
|
||||
return f, out
|
||||
}
|
||||
|
||||
// TestWhoamiIncludesCredentialSource locks in the diagnostic fields surfaced
|
||||
// from the cached credential.IdentitySelection: credentialSource,
|
||||
// explicit, and directCredentialEnv. whoami must read the cached selection
|
||||
// as-is, not re-infer it.
|
||||
func TestWhoamiIncludesCredentialSource(t *testing.T) {
|
||||
f, out := profileSelectionFactory(t)
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
raw := out.String()
|
||||
if strings.Contains(raw, credentialSourceSecret) {
|
||||
t.Fatalf("whoami output leaked the profile secret: %s", raw)
|
||||
}
|
||||
|
||||
var got whoamiResult
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\n%s", err, raw)
|
||||
}
|
||||
if got.CredentialSource != string(credential.SourceEnvProfile) {
|
||||
t.Fatalf("credentialSource = %q, want %q", got.CredentialSource, credential.SourceEnvProfile)
|
||||
}
|
||||
if !got.Explicit {
|
||||
t.Fatalf("explicit = false, want true")
|
||||
}
|
||||
if got.DirectCredentialEnv.Present {
|
||||
t.Fatalf("directCredentialEnv.present = true, want false: %#v", got.DirectCredentialEnv)
|
||||
}
|
||||
if !strings.Contains(raw, `"credentialSource": "env:LARKSUITE_CLI_PROFILE"`) {
|
||||
t.Fatalf("raw JSON missing credentialSource literal: %s", raw)
|
||||
}
|
||||
if got.DirectCredentialEnv.Present || len(got.DirectCredentialEnv.Keys) != 0 ||
|
||||
got.DirectCredentialEnv.AppID != "" || got.DirectCredentialEnv.Matched || got.DirectCredentialEnv.ConflictsWithProfile {
|
||||
t.Fatalf("directCredentialEnv = %#v, want only present:false set", got.DirectCredentialEnv)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,17 +67,6 @@ Typed errors render to **stderr** as one JSON object per process exit:
|
||||
| `error.params` | per-Subtype-stable | per-parameter validation detail array (`ValidationError`); see **Validation parameters** |
|
||||
| per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` |
|
||||
|
||||
Credential/identity-selection extension fields (per-Subtype-stable):
|
||||
|
||||
| Field | Carrier | Subtypes | Notes |
|
||||
|-------|---------|----------|-------|
|
||||
| `missing_keys` | `ConfigError` | `app_credential_incomplete` | env var NAMES that must all be set; never values |
|
||||
| `required_any_of` | `ConfigError` | `app_credential_incomplete` | env var NAMES where any one completes the credential; mutually exclusive with `missing_keys` |
|
||||
| `profile` | `ConfigError` | `profile_not_found`, `profile_secret_invalid` | requested profile name |
|
||||
| `app_id` | `ConfigError` | `profile_secret_invalid` | plaintext app id; never a secret |
|
||||
| `credential_source` | `ConfigError` | `profile_not_found`, `no_active_profile` | how the identity was (not) chosen: `flag:--profile` \| `env:LARKSUITE_CLI_PROFILE` \| `config` |
|
||||
| `profile_app_id`, `env_app_id` | `ValidationError` | `profile_app_credential_conflict` | the two conflicting plaintext app ids |
|
||||
|
||||
`SecurityPolicyError` renders through the same typed envelope as every
|
||||
other category. `error.type` is `"policy"`, `error.subtype` is one of
|
||||
`challenge_required` / `access_denied`, and process exit is `6` via
|
||||
|
||||
@@ -136,79 +136,6 @@ func TestConfigError_MarshalJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigError_ProfileFieldsMarshalJSON(t *testing.T) {
|
||||
ce := NewConfigError(SubtypeAppCredentialIncomplete, "incomplete").
|
||||
WithMissingKeys("LARKSUITE_CLI_APP_ID", "LARKSUITE_CLI_APP_SECRET").
|
||||
WithRequiredAnyOf("LARKSUITE_CLI_APP_SECRET", "LARKSUITE_CLI_USER_ACCESS_TOKEN").
|
||||
WithProfile("work").
|
||||
WithAppID("cli_abc").
|
||||
WithCredentialSource("flag:--profile")
|
||||
b, err := json.Marshal(ce)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"config"`,
|
||||
`"subtype":"app_credential_incomplete"`,
|
||||
`"missing_keys":["LARKSUITE_CLI_APP_ID","LARKSUITE_CLI_APP_SECRET"]`,
|
||||
`"required_any_of":["LARKSUITE_CLI_APP_SECRET","LARKSUITE_CLI_USER_ACCESS_TOKEN"]`,
|
||||
`"profile":"work"`,
|
||||
`"app_id":"cli_abc"`,
|
||||
`"credential_source":"flag:--profile"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
|
||||
// omitempty: unset fields must not appear on the wire.
|
||||
empty := NewConfigError(SubtypeProfileNotFound, "x")
|
||||
b2, err := json.Marshal(empty)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2 := string(b2)
|
||||
for _, notWant := range []string{`"missing_keys"`, `"required_any_of"`, `"profile"`, `"app_id"`, `"credential_source"`} {
|
||||
if strings.Contains(s2, notWant) {
|
||||
t.Errorf("%q should be omitted when empty; got %s", notWant, s2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationError_ProfileConflictMarshalJSON(t *testing.T) {
|
||||
ve := NewValidationError(SubtypeProfileAppCredentialConflict, "conflict").
|
||||
WithProfileAppConflict("cli_profile", "cli_env")
|
||||
b, err := json.Marshal(ve)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"validation"`,
|
||||
`"subtype":"profile_app_credential_conflict"`,
|
||||
`"profile_app_id":"cli_profile"`,
|
||||
`"env_app_id":"cli_env"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
|
||||
// omitempty: unset conflict fields must not appear on the wire.
|
||||
empty := NewValidationError(SubtypeInvalidArgument, "x")
|
||||
b2, err := json.Marshal(empty)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2 := string(b2)
|
||||
for _, notWant := range []string{`"profile_app_id"`, `"env_app_id"`} {
|
||||
if strings.Contains(s2, notWant) {
|
||||
t.Errorf("%q should be omitted when empty; got %s", notWant, s2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkError_MarshalJSON(t *testing.T) {
|
||||
ne := &NetworkError{
|
||||
Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTimeout, Message: "dial timeout"},
|
||||
|
||||
@@ -12,9 +12,8 @@ const (
|
||||
|
||||
// CategoryValidation subtypes
|
||||
const (
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
|
||||
SubtypeProfileAppCredentialConflict Subtype = "profile_app_credential_conflict" // profile and direct app env both set but app_id differs
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
|
||||
)
|
||||
|
||||
// CategoryAuthentication subtypes
|
||||
@@ -42,13 +41,9 @@ const (
|
||||
|
||||
// CategoryConfig subtypes
|
||||
const (
|
||||
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
|
||||
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
|
||||
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
|
||||
SubtypeProfileNotFound Subtype = "profile_not_found" // --profile / LARKSUITE_CLI_PROFILE points to a nonexistent profile
|
||||
SubtypeNoActiveProfile Subtype = "no_active_profile" // no active identity input and no usable default profile
|
||||
SubtypeAppCredentialIncomplete Subtype = "app_credential_incomplete" // direct app env missing app_id or app_secret
|
||||
SubtypeProfileSecretInvalid Subtype = "profile_secret_invalid" // profile exists but its secret cannot be resolved locally
|
||||
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
|
||||
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
|
||||
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
|
||||
)
|
||||
|
||||
// CategoryNetwork subtypes
|
||||
|
||||
@@ -61,11 +61,9 @@ type TypedError interface {
|
||||
// it is intentionally not serialized.
|
||||
type ValidationError struct {
|
||||
Problem
|
||||
Param string `json:"param,omitempty"`
|
||||
Params []InvalidParam `json:"params,omitempty"`
|
||||
ProfileAppID string `json:"profile_app_id,omitempty"`
|
||||
EnvAppID string `json:"env_app_id,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
Param string `json:"param,omitempty"`
|
||||
Params []InvalidParam `json:"params,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// InvalidParam is one structured validation diagnostic: the parameter that
|
||||
@@ -152,12 +150,6 @@ func (e *ValidationError) WithCause(cause error) *ValidationError {
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ValidationError) WithProfileAppConflict(profileAppID, envAppID string) *ValidationError {
|
||||
e.ProfileAppID = profileAppID
|
||||
e.EnvAppID = envAppID
|
||||
return e
|
||||
}
|
||||
|
||||
// =========================== AuthenticationError =============================
|
||||
|
||||
// AuthenticationError is the typed error for CategoryAuthentication.
|
||||
@@ -323,18 +315,8 @@ func (e *PermissionError) WithCause(cause error) *PermissionError {
|
||||
// intentionally not serialized.
|
||||
type ConfigError struct {
|
||||
Problem
|
||||
Field string `json:"field,omitempty"`
|
||||
MissingKeys []string `json:"missing_keys,omitempty"`
|
||||
RequiredAnyOf []string `json:"required_any_of,omitempty"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
AppID string `json:"app_id,omitempty"`
|
||||
// CredentialSource is the machine-readable App/credential selection source
|
||||
// that produced this config error (e.g. "flag:--profile",
|
||||
// "env:LARKSUITE_CLI_PROFILE", "config"). It is required on
|
||||
// profile_not_found and no_active_profile so an agent can branch
|
||||
// on how the identity was (or was not) chosen. It is never a secret.
|
||||
CredentialSource string `json:"credential_source,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
Field string `json:"field,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||
@@ -388,34 +370,6 @@ func (e *ConfigError) WithField(field string) *ConfigError {
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithMissingKeys(keys ...string) *ConfigError {
|
||||
e.MissingKeys = slices.Clone(keys)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithRequiredAnyOf(keys ...string) *ConfigError {
|
||||
e.RequiredAnyOf = slices.Clone(keys)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithProfile(name string) *ConfigError {
|
||||
e.Profile = name
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithAppID(appID string) *ConfigError {
|
||||
e.AppID = appID
|
||||
return e
|
||||
}
|
||||
|
||||
// WithCredentialSource records the machine-readable credential-selection source
|
||||
// on the wire (snake_case credential_source). The value is an enum string
|
||||
// (e.g. "flag:--profile", "config"), never a secret.
|
||||
func (e *ConfigError) WithCredentialSource(source string) *ConfigError {
|
||||
e.CredentialSource = source
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithCause(cause error) *ConfigError {
|
||||
e.Cause = cause
|
||||
return e
|
||||
|
||||
@@ -643,29 +643,3 @@ func TestBuilderSetter_DefensiveCopy(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ======================= Profile selection error subtypes =======================
|
||||
|
||||
func TestConfigErrorProfileFields(t *testing.T) {
|
||||
e := errs.NewConfigError(errs.SubtypeAppCredentialIncomplete, "incomplete").
|
||||
WithMissingKeys("LARKSUITE_CLI_APP_ID").
|
||||
WithCredentialSource("env:LARKSUITE_CLI_PROFILE")
|
||||
p, ok := errs.ProblemOf(e)
|
||||
if !ok || p.Subtype != errs.SubtypeAppCredentialIncomplete {
|
||||
t.Fatalf("subtype mismatch: %+v", p)
|
||||
}
|
||||
if len(e.MissingKeys) != 1 || e.MissingKeys[0] != "LARKSUITE_CLI_APP_ID" {
|
||||
t.Errorf("missing_keys not set: %v", e.MissingKeys)
|
||||
}
|
||||
if e.CredentialSource != "env:LARKSUITE_CLI_PROFILE" {
|
||||
t.Errorf("credential_source not set: %q", e.CredentialSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationErrorProfileConflict(t *testing.T) {
|
||||
e := errs.NewValidationError(errs.SubtypeProfileAppCredentialConflict, "conflict").
|
||||
WithProfileAppConflict("cli_profile", "cli_env")
|
||||
if e.ProfileAppID != "cli_profile" || e.EnvAppID != "cli_env" {
|
||||
t.Errorf("conflict fields not set: %q %q", e.ProfileAppID, e.EnvAppID)
|
||||
}
|
||||
}
|
||||
|
||||
123
extension/credential/env/env.go
vendored
123
extension/credential/env/env.go
vendored
@@ -23,89 +23,63 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
|
||||
appSecret := os.Getenv(envvars.CliAppSecret)
|
||||
hasUAT := os.Getenv(envvars.CliUserAccessToken) != ""
|
||||
hasTAT := os.Getenv(envvars.CliTenantAccessToken) != ""
|
||||
presentKeys := presentCredentialEnvKeys(appID, appSecret, hasUAT, hasTAT)
|
||||
if len(presentKeys) == 0 {
|
||||
return nil, nil
|
||||
if appID == "" && appSecret == "" {
|
||||
switch {
|
||||
case hasUAT:
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliUserAccessToken + " is set but " + envvars.CliAppID + " is missing"}
|
||||
case hasTAT:
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliTenantAccessToken + " is set but " + envvars.CliAppID + " is missing"}
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
if appID == "" {
|
||||
return nil, &credential.BlockError{Provider: "env", Reason: envvars.CliAppSecret + " is set but " + envvars.CliAppID + " is missing"}
|
||||
}
|
||||
if appSecret == "" && !hasUAT && !hasTAT {
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
|
||||
}
|
||||
}
|
||||
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
|
||||
acct := &credential.Account{AppID: appID, AppSecret: appSecret, Brand: brand}
|
||||
|
||||
// Identity policy variables are validated whenever a direct credential
|
||||
// input is present. Their errors must not be hidden by a later credential
|
||||
// completeness check or profile arbitration.
|
||||
defaultAs := credential.Identity(os.Getenv(envvars.CliDefaultAs))
|
||||
switch defaultAs {
|
||||
case "", credential.IdentityAuto, credential.IdentityUser, credential.IdentityBot:
|
||||
switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id {
|
||||
case "", credential.IdentityAuto:
|
||||
acct.DefaultAs = id
|
||||
case credential.IdentityUser, credential.IdentityBot:
|
||||
acct.DefaultAs = id
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, defaultAs),
|
||||
Code: credential.BlockReasonInvalidPolicy,
|
||||
Param: envvars.CliDefaultAs,
|
||||
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
|
||||
}
|
||||
}
|
||||
|
||||
strictMode := os.Getenv(envvars.CliStrictMode)
|
||||
var supported credential.IdentitySupport
|
||||
switch strictMode {
|
||||
// Explicit strict mode policy takes priority
|
||||
switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode {
|
||||
case "bot":
|
||||
supported = credential.SupportsBot
|
||||
acct.SupportedIdentities = credential.SupportsBot
|
||||
case "user":
|
||||
supported = credential.SupportsUser
|
||||
acct.SupportedIdentities = credential.SupportsUser
|
||||
case "off":
|
||||
supported = credential.SupportsAll
|
||||
acct.SupportedIdentities = credential.SupportsAll
|
||||
case "":
|
||||
// Infer from available tokens
|
||||
if hasUAT {
|
||||
supported |= credential.SupportsUser
|
||||
acct.SupportedIdentities |= credential.SupportsUser
|
||||
}
|
||||
if hasTAT {
|
||||
supported |= credential.SupportsBot
|
||||
acct.SupportedIdentities |= credential.SupportsBot
|
||||
}
|
||||
default:
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
|
||||
Code: credential.BlockReasonInvalidPolicy,
|
||||
Param: envvars.CliStrictMode,
|
||||
}
|
||||
}
|
||||
|
||||
if appID == "" && appSecret == "" {
|
||||
switch {
|
||||
case hasUAT:
|
||||
return nil, incompleteCredentialError(
|
||||
appID,
|
||||
envvars.CliUserAccessToken+" is set but "+envvars.CliAppID+" is missing",
|
||||
[]string{envvars.CliAppID}, nil, presentKeys)
|
||||
case hasTAT:
|
||||
return nil, incompleteCredentialError(
|
||||
appID,
|
||||
envvars.CliTenantAccessToken+" is set but "+envvars.CliAppID+" is missing",
|
||||
[]string{envvars.CliAppID}, nil, presentKeys)
|
||||
}
|
||||
}
|
||||
if appID == "" {
|
||||
return nil, incompleteCredentialError(
|
||||
appID,
|
||||
envvars.CliAppSecret+" is set but "+envvars.CliAppID+" is missing",
|
||||
[]string{envvars.CliAppID}, nil, presentKeys)
|
||||
}
|
||||
if appSecret == "" && !hasUAT && !hasTAT {
|
||||
return nil, incompleteCredentialError(
|
||||
appID,
|
||||
envvars.CliAppID+" is set but no app secret or access token is available",
|
||||
nil,
|
||||
[]string{envvars.CliAppSecret, envvars.CliUserAccessToken, envvars.CliTenantAccessToken},
|
||||
presentKeys)
|
||||
}
|
||||
brand := credential.Brand(core.ParseBrand(os.Getenv(envvars.CliBrand)))
|
||||
acct := &credential.Account{
|
||||
AppID: appID,
|
||||
AppSecret: appSecret,
|
||||
Brand: brand,
|
||||
DefaultAs: defaultAs,
|
||||
SupportedIdentities: supported,
|
||||
Kind: credential.AccountDirect,
|
||||
}
|
||||
|
||||
if acct.DefaultAs == "" {
|
||||
switch {
|
||||
case hasUAT:
|
||||
@@ -118,35 +92,6 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
func incompleteCredentialError(appID, reason string, missingKeys, requiredAnyOf, presentKeys []string) *credential.BlockError {
|
||||
return &credential.BlockError{
|
||||
Provider: "env",
|
||||
Reason: reason,
|
||||
Code: credential.BlockReasonCredentialIncomplete,
|
||||
MissingKeys: missingKeys,
|
||||
RequiredAnyOf: requiredAnyOf,
|
||||
PresentKeys: presentKeys,
|
||||
AppID: appID,
|
||||
}
|
||||
}
|
||||
|
||||
func presentCredentialEnvKeys(appID, appSecret string, hasUAT, hasTAT bool) []string {
|
||||
var keys []string
|
||||
if appID != "" {
|
||||
keys = append(keys, envvars.CliAppID)
|
||||
}
|
||||
if appSecret != "" {
|
||||
keys = append(keys, envvars.CliAppSecret)
|
||||
}
|
||||
if hasUAT {
|
||||
keys = append(keys, envvars.CliUserAccessToken)
|
||||
}
|
||||
if hasTAT {
|
||||
keys = append(keys, envvars.CliTenantAccessToken)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
|
||||
var envKey string
|
||||
switch req.Type {
|
||||
|
||||
100
extension/credential/env/env_test.go
vendored
100
extension/credential/env/env_test.go
vendored
@@ -6,7 +6,6 @@ package env
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -48,22 +47,6 @@ func TestResolveAccount_OnlyIDSet(t *testing.T) {
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
if blockErr.Code != credential.BlockReasonCredentialIncomplete {
|
||||
t.Fatalf("Code = %q, want %q", blockErr.Code, credential.BlockReasonCredentialIncomplete)
|
||||
}
|
||||
want := []string{envvars.CliAppSecret, envvars.CliUserAccessToken, envvars.CliTenantAccessToken}
|
||||
if !slices.Equal(blockErr.RequiredAnyOf, want) {
|
||||
t.Fatalf("RequiredAnyOf = %v, want %v", blockErr.RequiredAnyOf, want)
|
||||
}
|
||||
if len(blockErr.MissingKeys) != 0 {
|
||||
t.Fatalf("MissingKeys = %v, want empty", blockErr.MissingKeys)
|
||||
}
|
||||
if !slices.Equal(blockErr.PresentKeys, []string{envvars.CliAppID}) {
|
||||
t.Fatalf("PresentKeys = %v, want [%s]", blockErr.PresentKeys, envvars.CliAppID)
|
||||
}
|
||||
if blockErr.AppID != "cli_test" {
|
||||
t.Fatalf("AppID = %q, want cli_test", blockErr.AppID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_AppIDAndUserTokenWithoutSecret(t *testing.T) {
|
||||
@@ -92,81 +75,18 @@ func TestResolveAccount_OnlySecretSet(t *testing.T) {
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
if blockErr.Code != credential.BlockReasonCredentialIncomplete ||
|
||||
!slices.Equal(blockErr.MissingKeys, []string{envvars.CliAppID}) ||
|
||||
!slices.Equal(blockErr.PresentKeys, []string{envvars.CliAppSecret}) {
|
||||
t.Fatalf("BlockError = %+v, want incomplete with missing APP_ID and present APP_SECRET", blockErr)
|
||||
}
|
||||
if len(blockErr.RequiredAnyOf) != 0 {
|
||||
t.Fatalf("RequiredAnyOf = %v, want empty for APP_SECRET-only", blockErr.RequiredAnyOf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_OnlyTokenSetWithoutAppID(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
key string
|
||||
}{
|
||||
{name: "UAT", key: envvars.CliUserAccessToken},
|
||||
{name: "TAT", key: envvars.CliTenantAccessToken},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv(tt.key, "token_test")
|
||||
t.Setenv(envvars.CliUserAccessToken, "uat_test")
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliAppID) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliAppID)
|
||||
}
|
||||
if blockErr.Code != credential.BlockReasonCredentialIncomplete ||
|
||||
!slices.Equal(blockErr.MissingKeys, []string{envvars.CliAppID}) ||
|
||||
!slices.Equal(blockErr.PresentKeys, []string{tt.key}) {
|
||||
t.Fatalf("BlockError = %+v, want incomplete for %s", blockErr, tt.key)
|
||||
}
|
||||
if len(blockErr.RequiredAnyOf) != 0 {
|
||||
t.Fatalf("RequiredAnyOf = %v, want empty for %s-only", blockErr.RequiredAnyOf, tt.name)
|
||||
}
|
||||
})
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InvalidPolicyRejectedBeforeIncomplete(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
key string
|
||||
}{
|
||||
{name: "DEFAULT_AS", key: envvars.CliDefaultAs},
|
||||
{name: "STRICT_MODE", key: envvars.CliStrictMode},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "cli_test")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv(tt.key, "banana")
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("error = %T %v, want BlockError", err, err)
|
||||
}
|
||||
if blockErr.Code != credential.BlockReasonInvalidPolicy {
|
||||
t.Fatalf("Code = %q, want %q", blockErr.Code, credential.BlockReasonInvalidPolicy)
|
||||
}
|
||||
if blockErr.Param != tt.key {
|
||||
t.Fatalf("Param = %q, want %q", blockErr.Param, tt.key)
|
||||
}
|
||||
if !strings.Contains(blockErr.Reason, tt.key) {
|
||||
t.Fatalf("reason = %q, want %s", blockErr.Reason, tt.key)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(err.Error(), envvars.CliAppID) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliAppID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,9 +258,6 @@ func TestResolveAccount_InvalidStrictModeRejected(t *testing.T) {
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %T", err)
|
||||
}
|
||||
if blockErr.Code != credential.BlockReasonInvalidPolicy || blockErr.Param != envvars.CliStrictMode {
|
||||
t.Fatalf("BlockError = %+v, want invalid_policy with Param %s", blockErr, envvars.CliStrictMode)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliStrictMode) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliStrictMode)
|
||||
}
|
||||
@@ -359,9 +276,6 @@ func TestResolveAccount_InvalidDefaultAsRejected(t *testing.T) {
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("expected BlockError, got %T", err)
|
||||
}
|
||||
if blockErr.Code != credential.BlockReasonInvalidPolicy || blockErr.Param != envvars.CliDefaultAs {
|
||||
t.Fatalf("BlockError = %+v, want invalid_policy with Param %s", blockErr, envvars.CliDefaultAs)
|
||||
}
|
||||
if !strings.Contains(err.Error(), envvars.CliDefaultAs) {
|
||||
t.Fatalf("error = %v, want mention of %s", err, envvars.CliDefaultAs)
|
||||
}
|
||||
|
||||
@@ -77,8 +77,6 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id),
|
||||
Code: credential.BlockReasonInvalidPolicy,
|
||||
Param: envvars.CliDefaultAs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,8 +92,6 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err
|
||||
return nil, &credential.BlockError{
|
||||
Provider: "sidecar",
|
||||
Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode),
|
||||
Code: credential.BlockReasonInvalidPolicy,
|
||||
Param: envvars.CliStrictMode,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,7 @@ package sidecar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/credential"
|
||||
@@ -148,57 +146,6 @@ func TestResolveAccount_StrictMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAccount_InvalidPolicyClassified(t *testing.T) {
|
||||
setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
setEnv(t, envvars.CliProxyKey, "test-key")
|
||||
setEnv(t, envvars.CliAppID, "cli_test")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
value string
|
||||
supportedText string
|
||||
}{
|
||||
{
|
||||
name: "default as",
|
||||
key: envvars.CliDefaultAs,
|
||||
value: "banana",
|
||||
supportedText: "want user, bot, or auto",
|
||||
},
|
||||
{
|
||||
name: "strict mode",
|
||||
key: envvars.CliStrictMode,
|
||||
value: "banana",
|
||||
supportedText: "want bot, user, or off",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
unsetEnv(t, envvars.CliDefaultAs)
|
||||
unsetEnv(t, envvars.CliStrictMode)
|
||||
setEnv(t, tt.key, tt.value)
|
||||
|
||||
_, err := (&Provider{}).ResolveAccount(context.Background())
|
||||
var blockErr *credential.BlockError
|
||||
if !errors.As(err, &blockErr) {
|
||||
t.Fatalf("error = %T %v, want BlockError", err, err)
|
||||
}
|
||||
if blockErr.Code != credential.BlockReasonInvalidPolicy {
|
||||
t.Fatalf("Code = %q, want %q", blockErr.Code, credential.BlockReasonInvalidPolicy)
|
||||
}
|
||||
if blockErr.Param != tt.key {
|
||||
t.Fatalf("Param = %q, want %q", blockErr.Param, tt.key)
|
||||
}
|
||||
if !strings.Contains(blockErr.Reason, tt.key) ||
|
||||
!strings.Contains(blockErr.Reason, tt.value) ||
|
||||
!strings.Contains(blockErr.Reason, tt.supportedText) {
|
||||
t.Fatalf("Reason = %q, want variable, invalid value, and supported values", blockErr.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveToken_NotActive(t *testing.T) {
|
||||
unsetEnv(t, envvars.CliAuthProxy)
|
||||
|
||||
|
||||
@@ -44,27 +44,6 @@ func (s IdentitySupport) UserOnly() bool { return s == SupportsUser }
|
||||
// BotOnly returns true if only bot identity is supported.
|
||||
func (s IdentitySupport) BotOnly() bool { return s == SupportsBot }
|
||||
|
||||
// AccountKind declares how an account participates in credential arbitration.
|
||||
type AccountKind int
|
||||
|
||||
const (
|
||||
// AccountManaged means the provider owns the whole identity; winning it
|
||||
// ends arbitration outright. The zero value, so existing providers are
|
||||
// unchanged.
|
||||
AccountManaged AccountKind = iota
|
||||
// AccountDirect marks an actively supplied raw credential (the env
|
||||
// provider's LARKSUITE_CLI_* variables). It participates in profile
|
||||
// arbitration and conflict detection instead of winning outright.
|
||||
//
|
||||
// RESERVED: only the builtin env provider may declare AccountDirect
|
||||
// today — the arbitration's direct-credential diagnostics are defined in
|
||||
// terms of the process environment, and the caller rejects AccountDirect
|
||||
// from any other provider. Third-party providers must return
|
||||
// AccountManaged until the SPI carries provider-reported input
|
||||
// descriptors.
|
||||
AccountDirect
|
||||
)
|
||||
|
||||
// Account holds resolved app credentials and configuration.
|
||||
type Account struct {
|
||||
AppID string
|
||||
@@ -74,7 +53,6 @@ type Account struct {
|
||||
ProfileName string
|
||||
OpenID string // optional; if UAT is available, API result takes precedence
|
||||
SupportedIdentities IdentitySupport // zero = provider did not declare; treat as no restriction
|
||||
Kind AccountKind // AccountManaged (default) or AccountDirect
|
||||
}
|
||||
|
||||
// Token holds a resolved access token and optional metadata.
|
||||
@@ -98,38 +76,11 @@ type TokenSpec struct {
|
||||
AppID string
|
||||
}
|
||||
|
||||
// BlockReason classifies provider-originated block conditions that callers may
|
||||
// safely map to a more specific public error contract.
|
||||
type BlockReason string
|
||||
|
||||
const (
|
||||
// BlockReasonCredentialIncomplete marks incomplete inputs from the builtin
|
||||
// process-env credential provider. It is reserved for that provider because
|
||||
// direct-credential arbitration and diagnostics currently name the fixed
|
||||
// LARKSUITE_CLI_* env surface. Third-party providers must return an
|
||||
// unclassified BlockError until the SPI carries provider-owned input
|
||||
// descriptors. Blocks without a Code propagate unchanged.
|
||||
BlockReasonCredentialIncomplete BlockReason = "credential_incomplete"
|
||||
|
||||
// BlockReasonInvalidPolicy marks a user-supplied policy input (e.g.
|
||||
// LARKSUITE_CLI_DEFAULT_AS / LARKSUITE_CLI_STRICT_MODE) that failed
|
||||
// validation. The caller maps it to a typed validation error carrying
|
||||
// Param and a repair hint, so user input mistakes never surface as
|
||||
// internal errors.
|
||||
BlockReasonInvalidPolicy BlockReason = "invalid_policy"
|
||||
)
|
||||
|
||||
// BlockError is returned by a Provider to actively reject a request
|
||||
// and prevent subsequent providers in the chain from being consulted.
|
||||
type BlockError struct {
|
||||
Provider string
|
||||
Reason string
|
||||
Code BlockReason
|
||||
MissingKeys []string // environment variable names only; never values
|
||||
RequiredAnyOf []string // environment variable names only; never values
|
||||
PresentKeys []string // environment variable names only; never values
|
||||
AppID string // plaintext app identifier used only for source comparison; never a secret
|
||||
Param string // name of the invalid input variable on invalid_policy blocks; never a value
|
||||
Provider string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *BlockError) Error() string {
|
||||
|
||||
@@ -12,9 +12,18 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
// failingBody is a ReadCloser that errors on Read and tracks Close calls.
|
||||
type failingBody struct {
|
||||
err error
|
||||
@@ -263,3 +272,55 @@ func TestInterceptor_EmptyBody(t *testing.T) {
|
||||
t.Errorf("body SHA256 = %q, want empty-string SHA256 %q", sha, expectedEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacySidecarProviderStillHandlesForcedExternalRequests(t *testing.T) {
|
||||
t.Setenv(envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
t.Setenv(envvars.CliProxyKey, "test-key")
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(&Provider{})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
seen := make(chan *http.Request, 2)
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
seen <- req.Clone(req.Context())
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
})
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: internaltransport.NewHTTPPolicyRouter(base, base)},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
|
||||
withSentinel, err := http.NewRequest(http.MethodGet, "https://external.example/protected", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withSentinel.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||
resp, err := client.Do(withSentinel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
withoutSentinel, err := http.NewRequest(http.MethodGet, "https://external.example/public", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err = client.Do(withoutSentinel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
proxied := <-seen
|
||||
if proxied.URL.Scheme != "http" || proxied.URL.Host != "127.0.0.1:16384" {
|
||||
t.Fatalf("sentinel request URL = %s, want sidecar route", proxied.URL)
|
||||
}
|
||||
if got := proxied.Header.Get(sidecar.HeaderProxyTarget); got != "https://external.example" {
|
||||
t.Fatalf("sentinel request proxy target = %q", got)
|
||||
}
|
||||
|
||||
passthrough := <-seen
|
||||
if got := passthrough.URL.String(); got != "https://external.example/public" {
|
||||
t.Fatalf("non-sentinel request URL = %q, want unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,27 @@ type Provider interface {
|
||||
ResolveInterceptor(ctx context.Context) Interceptor
|
||||
}
|
||||
|
||||
// RequestClass describes the trust boundary of an outbound HTTP request.
|
||||
// Platform requests target endpoints owned by the CLI's endpoint resolver;
|
||||
// external requests target user-provided, pre-signed, CDN, registry, or other
|
||||
// non-platform URLs. Redirect targets are classified again from each hop's
|
||||
// logical URL; rewriting a host in an interceptor does not add that host to
|
||||
// the platform endpoint catalog.
|
||||
type RequestClass string
|
||||
|
||||
const (
|
||||
RequestClassPlatform RequestClass = "platform"
|
||||
RequestClassExternal RequestClass = "external"
|
||||
)
|
||||
|
||||
// ScopedProvider optionally limits a Provider to selected request classes.
|
||||
// Providers that do not implement this interface retain the original
|
||||
// behavior and apply to every request class.
|
||||
type ScopedProvider interface {
|
||||
Provider
|
||||
SupportsRequestClass(RequestClass) bool
|
||||
}
|
||||
|
||||
// Interceptor defines network-layer customization via a pre/post hook pair.
|
||||
// The built-in transport chain always executes between PreRoundTrip and the
|
||||
// returned post function, and cannot be skipped or overridden by the extension.
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
var _ transport.RoundTripperDecorator = (*SecurityPolicyTransport)(nil)
|
||||
|
||||
// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
|
||||
// and checks for security policy errors.
|
||||
type SecurityPolicyTransport struct {
|
||||
@@ -31,6 +33,16 @@ func (t *SecurityPolicyTransport) base() http.RoundTripper {
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *SecurityPolicyTransport) BaseRoundTripper() http.RoundTripper {
|
||||
return t.base()
|
||||
}
|
||||
|
||||
func (t *SecurityPolicyTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *t
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
func (t *SecurityPolicyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := t.base().RoundTrip(req)
|
||||
|
||||
@@ -212,6 +212,9 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
cancel()
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err)
|
||||
}
|
||||
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}
|
||||
|
||||
@@ -48,18 +48,6 @@ func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.Token
|
||||
return &credential.TokenResult{Token: "test-token"}, nil
|
||||
}
|
||||
|
||||
type clientTestAccountResolver struct {
|
||||
appID string
|
||||
}
|
||||
|
||||
func (r clientTestAccountResolver) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return &credential.Account{AppID: r.appID, Brand: core.BrandFeishu}, nil
|
||||
}
|
||||
|
||||
func newClientTestCredentialProvider(appID string, tokenResolver credential.DefaultTokenResolver) *credential.CredentialProvider {
|
||||
return credential.NewCredentialProvider(nil, clientTestAccountResolver{appID: appID}, tokenResolver, nil)
|
||||
}
|
||||
|
||||
// newTestAPIClient creates an APIClient with a mock HTTP transport.
|
||||
func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
@@ -70,7 +58,7 @@ func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Bu
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHttpClient(httpClient),
|
||||
)
|
||||
testCred := newClientTestCredentialProvider("test-app", &staticTokenResolver{})
|
||||
testCred := credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil)
|
||||
cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
|
||||
return &APIClient{
|
||||
SDK: sdk,
|
||||
@@ -475,7 +463,7 @@ func TestDoStream_IgnoresBaseHTTPClientTimeout(t *testing.T) {
|
||||
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{Timeout: 5 * time.Millisecond},
|
||||
Credential: newClientTestCredentialProvider("test-app", &staticTokenResolver{}),
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
@@ -510,7 +498,7 @@ func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) {
|
||||
})
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{Transport: rt},
|
||||
Credential: newClientTestCredentialProvider("test-app", &staticTokenResolver{}),
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
@@ -530,6 +518,29 @@ func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoStream_PreservesTypedTransportError(t *testing.T) {
|
||||
policyErr := errs.NewSecurityPolicyError(errs.SubtypeAccessDenied, "blocked redirect")
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, policyErr
|
||||
})},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
_, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/drive/v1/files/file_token/download",
|
||||
}, core.AsBot)
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryPolicy || problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("DoStream() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if !errors.Is(err, policyErr) {
|
||||
t.Fatal("DoStream() did not preserve the typed transport error")
|
||||
}
|
||||
}
|
||||
|
||||
// failingTokenResolver always returns TokenUnavailableError, exercising the
|
||||
// auth/credential failure path through resolveAccessToken.
|
||||
type failingTokenResolver struct{}
|
||||
@@ -544,7 +555,7 @@ func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.T
|
||||
func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T) {
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{},
|
||||
Credential: newClientTestCredentialProvider("test-app", &failingTokenResolver{}),
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
@@ -584,7 +595,7 @@ func (f *needAuthTokenResolver) ResolveToken(_ context.Context, _ credential.Tok
|
||||
func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *testing.T) {
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{},
|
||||
Credential: newClientTestCredentialProvider("test-app", &needAuthTokenResolver{userOpenID: "ou_test_user"}),
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &needAuthTokenResolver{userOpenID: "ou_test_user"}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
@@ -624,7 +635,7 @@ func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *t
|
||||
func TestDoSDKRequest_AuthFailureSurfacesTypedAuthenticationError(t *testing.T) {
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{},
|
||||
Credential: newClientTestCredentialProvider("test-app", &failingTokenResolver{}),
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,12 @@ import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
// Factory holds shared dependencies injected into every command.
|
||||
@@ -27,16 +29,11 @@ import (
|
||||
// In tests, replace any field to stub out external dependencies.
|
||||
type InvocationContext struct {
|
||||
Profile string
|
||||
// ProfileFromFlag is true when Profile was set via the --profile flag,
|
||||
// and false when it came from the LARKSUITE_CLI_PROFILE env fallback
|
||||
// (or neither was set). Downstream credential resolution uses this to
|
||||
// report the correct profile source.
|
||||
ProfileFromFlag bool
|
||||
}
|
||||
|
||||
type Factory struct {
|
||||
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
|
||||
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
|
||||
HttpClient func() (*http.Client, error) // policy-routed HTTP client for direct requests
|
||||
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
|
||||
IOStreams *IOStreams // stdin/stdout/stderr streams
|
||||
|
||||
@@ -53,6 +50,18 @@ type Factory struct {
|
||||
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
|
||||
}
|
||||
|
||||
// ExternalHTTPClient returns a clone of the existing Factory client whose
|
||||
// requests are explicitly classified as external. The underlying client,
|
||||
// redirect policy, timeout, proxy configuration, and legacy transport provider
|
||||
// behavior are preserved.
|
||||
func (f *Factory) ExternalHTTPClient() (*http.Client, error) {
|
||||
client, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transport.ClientForRequestClass(client, exttransport.RequestClassExternal), nil
|
||||
}
|
||||
|
||||
// ResolveFileIO resolves a FileIO instance using the current execution context.
|
||||
// The provider controls whether the returned instance is fresh or cached.
|
||||
func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {
|
||||
|
||||
@@ -5,16 +5,18 @@ package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
@@ -48,6 +50,19 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
// workspace-scoped. Default is WorkspaceLocal — existing behavior unchanged.
|
||||
ws := core.DetectWorkspaceFromEnv(os.Getenv)
|
||||
core.SetCurrentWorkspace(ws)
|
||||
workspaceConfig := core.NewConfigSnapshot()
|
||||
bootstrapHostSignalSource := sync.OnceValue(func() riskcontrol.Source {
|
||||
return resolveSDKHostSignalSource(workspaceConfig)
|
||||
})
|
||||
// Install after workspace selection so the dependency bootstrap bridge uses
|
||||
// the correct shared proxy configuration. NewDefault is also used by cmd.Build
|
||||
// consumers, so this keeps their request routing identical to cmd.Execute.
|
||||
transport.InstallSDKTransportBridge(func(base http.RoundTripper) http.RoundTripper {
|
||||
return buildSDKPlatformTransportWithBase(
|
||||
base,
|
||||
bootstrapHostSignalSource(),
|
||||
)
|
||||
})
|
||||
|
||||
// Inject workspace-aware dir into keychain's log system.
|
||||
// This breaks the core↔keychain import cycle by using a function variable.
|
||||
@@ -55,7 +70,6 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
|
||||
// Phase 0: FileIO provider (no dependency)
|
||||
f.FileIOProvider = fileio.GetProvider()
|
||||
workspaceConfig := core.NewConfigSnapshot()
|
||||
|
||||
// Phase 1: HttpClient (no credential dependency)
|
||||
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
|
||||
@@ -63,11 +77,10 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
// Phase 2: Credential (sole data source)
|
||||
// Keychain is read via closure so callers can replace f.Keychain after construction.
|
||||
f.Credential = buildCredentialProvider(credentialDeps{
|
||||
Keychain: func() keychain.KeychainAccess { return f.Keychain },
|
||||
Profile: inv.Profile,
|
||||
ProfileFromFlag: inv.ProfileFromFlag,
|
||||
HttpClient: f.HttpClient,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
Keychain: func() keychain.KeychainAccess { return f.Keychain },
|
||||
Profile: inv.Profile,
|
||||
HttpClient: f.HttpClient,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
})
|
||||
|
||||
// Phase 3: Runtime config contains resolved account data only.
|
||||
@@ -88,15 +101,45 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
|
||||
return f
|
||||
}
|
||||
|
||||
// safeRedirectPolicy prevents credential headers from being forwarded
|
||||
// when a response redirects to a different host (e.g. Lark API 302 → CDN).
|
||||
// Strips Authorization, X-Lark-MCP-UAT, and X-Lark-MCP-TAT on cross-host
|
||||
// redirects; other headers like X-Cli-* pass through.
|
||||
// safeRedirectPolicy permits cross-origin redirects only for bodyless GET and
|
||||
// HEAD requests. This allows API download redirects while preventing OAuth or
|
||||
// other credential-bearing request bodies from being replayed to another
|
||||
// origin. HTTPS requests can never be downgraded to HTTP.
|
||||
func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "too many redirects")
|
||||
}
|
||||
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
|
||||
if len(via) == 0 {
|
||||
return nil
|
||||
}
|
||||
original := via[0]
|
||||
previous := via[len(via)-1]
|
||||
if previous.URL != nil && req.URL != nil && strings.EqualFold(previous.URL.Scheme, "https") && !strings.EqualFold(req.URL.Scheme, "https") {
|
||||
return errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"redirect from HTTPS to %s is not allowed",
|
||||
req.URL.Scheme,
|
||||
)
|
||||
}
|
||||
if !sameRedirectOrigin(previous.URL, req.URL) {
|
||||
if req.Method != http.MethodGet && req.Method != http.MethodHead {
|
||||
return errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"cross-origin redirect for HTTP method %s is not allowed",
|
||||
req.Method,
|
||||
)
|
||||
}
|
||||
if req.Body != nil || req.GetBody != nil {
|
||||
return errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"cross-origin redirect with a request body is not allowed",
|
||||
)
|
||||
}
|
||||
}
|
||||
// net/http copies initial headers onto every redirect request. Continue
|
||||
// stripping credentials for every hop outside the initial origin, even when
|
||||
// two consecutive redirect targets share an origin.
|
||||
if !sameRedirectOrigin(original.URL, req.URL) {
|
||||
req.Header.Del("Authorization")
|
||||
req.Header.Del("X-Lark-MCP-UAT")
|
||||
req.Header.Del("X-Lark-MCP-TAT")
|
||||
@@ -104,6 +147,29 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameRedirectOrigin(left, right *url.URL) bool {
|
||||
if left == nil || right == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(left.Scheme, right.Scheme) &&
|
||||
strings.EqualFold(left.Hostname(), right.Hostname()) &&
|
||||
effectivePort(left) == effectivePort(right)
|
||||
}
|
||||
|
||||
func effectivePort(candidate *url.URL) string {
|
||||
if port := candidate.Port(); port != "" {
|
||||
return port
|
||||
}
|
||||
switch strings.ToLower(candidate.Scheme) {
|
||||
case "http":
|
||||
return "80"
|
||||
case "https":
|
||||
return "443"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// warnIfProxied is a test seam for the proxy-warning gate. Production wires it
|
||||
// to transport.WarnIfProxied; tests swap in a spy to count invocations. It is
|
||||
// needed because the real function is guarded by an internal sync.Once, so
|
||||
@@ -119,15 +185,12 @@ func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
|
||||
}
|
||||
|
||||
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
|
||||
|
||||
var rt http.RoundTripper = transport.Shared()
|
||||
rt = riskcontrol.NewTransport(rt, hostSignalSource)
|
||||
rt = &RetryTransport{Base: rt}
|
||||
rt = &SecurityHeaderTransport{Base: rt}
|
||||
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
|
||||
rt = wrapWithExtension(rt)
|
||||
shared := transport.Shared()
|
||||
outbound := riskcontrol.NewTransport(shared, hostSignalSource)
|
||||
platform := buildDirectHTTPTransport(outbound, true)
|
||||
external := buildDirectHTTPTransport(outbound, false)
|
||||
client := &http.Client{
|
||||
Transport: rt,
|
||||
Transport: transport.NewHTTPPolicyRouter(platform, external),
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}
|
||||
@@ -135,6 +198,15 @@ func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
|
||||
})
|
||||
}
|
||||
|
||||
func buildDirectHTTPTransport(base http.RoundTripper, platform bool) http.RoundTripper {
|
||||
var builtIn http.RoundTripper = &RetryTransport{Base: base}
|
||||
builtIn = &SecurityHeaderTransport{Base: builtIn}
|
||||
if platform {
|
||||
builtIn = &auth.SecurityPolicyTransport{Base: builtIn}
|
||||
}
|
||||
return builtIn
|
||||
}
|
||||
|
||||
func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
|
||||
return sync.OnceValues(func() (*lark.Client, error) {
|
||||
acct, err := f.Credential.ResolveAccount(context.Background())
|
||||
@@ -150,14 +222,8 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
|
||||
warnIfProxied(f.IOStreams.ErrOut)
|
||||
}
|
||||
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
|
||||
var sdkBase http.RoundTripper = transport.Shared()
|
||||
// The innermost SDK boundary always strips reserved host-signal headers;
|
||||
// a nil source makes it strip-only when workspace policy disables signal
|
||||
// collection.
|
||||
sdkBase = riskcontrol.NewTransport(sdkBase, hostSignalSource)
|
||||
sdkTransport := wrapSDKTransport(sdkBase)
|
||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||
Transport: sdkTransport,
|
||||
Transport: buildSDKTransport(hostSignalSource),
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}))
|
||||
ep := core.ResolveEndpoints(acct.Brand)
|
||||
@@ -166,20 +232,48 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
|
||||
})
|
||||
}
|
||||
|
||||
func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
|
||||
var sdkTransport http.RoundTripper = &RetryTransport{Base: next}
|
||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
|
||||
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
|
||||
return wrapWithExtension(sdkTransport)
|
||||
func buildSDKTransport(hostSignalSource riskcontrol.Source) http.RoundTripper {
|
||||
return buildSDKTransportWithBase(transport.Shared(), hostSignalSource)
|
||||
}
|
||||
|
||||
func buildSDKPlatformTransportWithBase(
|
||||
base http.RoundTripper,
|
||||
hostSignalSource riskcontrol.Source,
|
||||
) http.RoundTripper {
|
||||
outbound := riskcontrol.NewTransport(base, hostSignalSource)
|
||||
return buildSDKHTTPTransport(outbound, true)
|
||||
}
|
||||
|
||||
func buildSDKTransportWithBase(
|
||||
base http.RoundTripper,
|
||||
hostSignalSource riskcontrol.Source,
|
||||
) http.RoundTripper {
|
||||
// Risk control is the innermost trusted boundary for both request classes.
|
||||
// It therefore observes the final URL and strips extension-supplied reserved
|
||||
// headers immediately before the network transport.
|
||||
outbound := riskcontrol.NewTransport(base, hostSignalSource)
|
||||
return transport.NewHTTPPolicyRouter(
|
||||
buildSDKHTTPTransport(outbound, true),
|
||||
buildSDKHTTPTransport(outbound, false),
|
||||
)
|
||||
}
|
||||
|
||||
func buildSDKHTTPTransport(base http.RoundTripper, platform bool) http.RoundTripper {
|
||||
var builtIn http.RoundTripper = &RetryTransport{Base: base}
|
||||
builtIn = &UserAgentTransport{Base: builtIn}
|
||||
builtIn = &BuildHeaderTransport{Base: builtIn}
|
||||
builtIn = &SecurityHeaderTransport{Base: builtIn}
|
||||
if platform {
|
||||
builtIn = &auth.SecurityPolicyTransport{Base: builtIn}
|
||||
}
|
||||
return builtIn
|
||||
}
|
||||
|
||||
type credentialDeps struct {
|
||||
Keychain func() keychain.KeychainAccess
|
||||
Profile string
|
||||
ProfileFromFlag bool
|
||||
HttpClient func() (*http.Client, error)
|
||||
ErrOut io.Writer
|
||||
Keychain func() keychain.KeychainAccess
|
||||
Profile string
|
||||
HttpClient func() (*http.Client, error)
|
||||
ErrOut io.Writer
|
||||
}
|
||||
|
||||
func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider {
|
||||
@@ -192,13 +286,5 @@ func buildCredentialProvider(deps credentialDeps) *credential.CredentialProvider
|
||||
// depend on. enrichUserInfo failures are already non-fatal (the
|
||||
// provider clears unverified identity fields), so silencing the
|
||||
// warning is safe.
|
||||
cred := credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
|
||||
if deps.Profile == "" {
|
||||
// No profile selected — don't record a phantom env source.
|
||||
return cred
|
||||
}
|
||||
if deps.ProfileFromFlag {
|
||||
return cred.WithProfileFromFlag(deps.Profile)
|
||||
}
|
||||
return cred.WithProfileFromEnv(deps.Profile)
|
||||
return credential.NewCredentialProvider(providers, defaultAcct, defaultToken, deps.HttpClient)
|
||||
}
|
||||
|
||||
@@ -4,13 +4,20 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
func TestCachedHTTPClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
@@ -33,7 +40,7 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
|
||||
func TestCachedHTTPClientFunc_HasTimeout(t *testing.T) {
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
@@ -44,7 +51,7 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
|
||||
func TestCachedHTTPClientFunc_HasRedirectPolicy(t *testing.T) {
|
||||
isEnabled := false
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
|
||||
f.IOStreams.ErrOut = io.Discard
|
||||
@@ -54,3 +61,283 @@ func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
|
||||
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryExternalHTTPClientClonesExistingClient(t *testing.T) {
|
||||
base := &http.Client{Timeout: 17, CheckRedirect: safeRedirectPolicy}
|
||||
factory := &Factory{HttpClient: func() (*http.Client, error) { return base, nil }}
|
||||
|
||||
external, err := factory.ExternalHTTPClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if external == base {
|
||||
t.Fatal("ExternalHTTPClient returned the cached client instead of a clone")
|
||||
}
|
||||
if external.Timeout != base.Timeout || external.CheckRedirect == nil {
|
||||
t.Fatal("ExternalHTTPClient did not preserve client policy")
|
||||
}
|
||||
if base.Transport != nil {
|
||||
t.Fatal("ExternalHTTPClient mutated the cached client's transport")
|
||||
}
|
||||
}
|
||||
|
||||
type platformOnlyStubProvider struct {
|
||||
*stubTransportProvider
|
||||
}
|
||||
|
||||
func (*platformOnlyStubProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
|
||||
return class == exttransport.RequestClassPlatform
|
||||
}
|
||||
|
||||
func TestFactoryHTTPClientRoutesPoliciesByRequestClass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_NO_PROXY", "1")
|
||||
|
||||
interceptor := &headerCapturingInterceptor{}
|
||||
exttransport.Register(&platformOnlyStubProvider{stubTransportProvider: &stubTransportProvider{interceptor: interceptor}})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
received := make(chan http.Header, 2)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
received <- req.Header.Clone()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
factory := &Factory{IOStreams: &IOStreams{ErrOut: io.Discard}}
|
||||
client, err := cachedHttpClientFunc(factory, nil)()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
factory.HttpClient = func() (*http.Client, error) { return client, nil }
|
||||
platformClient := internaltransport.ClientForRequestClass(client, exttransport.RequestClassPlatform)
|
||||
externalClient, err := factory.ExternalHTTPClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, client := range []*http.Client{platformClient, externalClient} {
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
platformHeaders := <-received
|
||||
if got := platformHeaders.Get("X-Custom-Trace"); got != "ext-trace-123" {
|
||||
t.Fatalf("platform extension header = %q, want ext-trace-123", got)
|
||||
}
|
||||
if got := platformHeaders.Get(HeaderSource); got != SourceValue {
|
||||
t.Fatalf("platform security header = %q, want %q", got, SourceValue)
|
||||
}
|
||||
|
||||
externalHeaders := <-received
|
||||
if got := externalHeaders.Get("X-Custom-Trace"); got != "" {
|
||||
t.Fatalf("external request leaked extension header %q", got)
|
||||
}
|
||||
for header, values := range BaseSecurityHeaders() {
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
want := values[len(values)-1]
|
||||
if got := externalHeaders.Get(header); got != want {
|
||||
t.Fatalf("external security header %s = %q, want preserved value %q", header, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryExternalHTTPClientDoesNotParsePlatformErrorProtocol(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_NO_PROXY", "1")
|
||||
exttransport.Register(nil)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"code":21000,"msg":"application-defined external response","data":{"cli_hint":"external-defined"}}`)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
factory := &Factory{IOStreams: &IOStreams{ErrOut: io.Discard}}
|
||||
client, err := cachedHttpClientFunc(factory, nil)()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
factory.HttpClient = func() (*http.Client, error) { return client, nil }
|
||||
|
||||
platform := internaltransport.ClientForRequestClass(client, exttransport.RequestClassPlatform)
|
||||
if _, err := platform.Get(server.URL); err == nil {
|
||||
t.Fatal("platform request error = nil, want security policy classification")
|
||||
} else {
|
||||
var policyErr *errs.SecurityPolicyError
|
||||
if !errors.As(err, &policyErr) {
|
||||
t.Fatalf("platform request error type = %T, want *errs.SecurityPolicyError", err)
|
||||
}
|
||||
}
|
||||
|
||||
external, err := factory.ExternalHTTPClient()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := external.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("external request parsed platform error protocol: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyAllowsBodylessCrossOriginGetAndStripsCredentials(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, header := range []string{"Authorization", "X-Lark-MCP-UAT", "X-Lark-MCP-TAT"} {
|
||||
redirect.Header.Set(header, "secret")
|
||||
}
|
||||
|
||||
if err := safeRedirectPolicy(redirect, []*http.Request{original}); err != nil {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want allowed GET redirect", err)
|
||||
}
|
||||
for _, header := range []string{"Authorization", "X-Lark-MCP-UAT", "X-Lark-MCP-TAT"} {
|
||||
if got := redirect.Header.Get(header); got != "" {
|
||||
t.Fatalf("redirect retained %s=%q", header, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyRejectsHTTPSDowngrade(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodGet, "http://open.feishu.cn/next", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = safeRedirectPolicy(redirect, []*http.Request{original})
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTPS") {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want HTTPS downgrade rejection", err)
|
||||
}
|
||||
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyRejectsCrossOriginMethod(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodPost, "https://accounts.feishu.cn/token", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodPost, "https://external.example/token", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = safeRedirectPolicy(redirect, []*http.Request{original})
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP method POST") {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want cross-origin method rejection", err)
|
||||
}
|
||||
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyRejectsCrossOriginRequestBody(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodGet, "https://accounts.feishu.cn/token", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodGet, "https://external.example/token", strings.NewReader("client_secret=secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = safeRedirectPolicy(redirect, []*http.Request{original})
|
||||
if err == nil || !strings.Contains(err.Error(), "request body") {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want cross-origin body rejection", err)
|
||||
}
|
||||
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyRejectsTooManyRedirects(t *testing.T) {
|
||||
err := safeRedirectPolicy(&http.Request{}, make([]*http.Request, 10))
|
||||
if err == nil || err.Error() != "too many redirects" {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want redirect limit rejection", err)
|
||||
}
|
||||
requireRedirectProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyTreatsDefaultHTTPSPortAsSameOrigin(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodPost, "https://accounts.feishu.cn/token", strings.NewReader("secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodPost, "https://accounts.feishu.cn:443/token-next", strings.NewReader("secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := safeRedirectPolicy(redirect, []*http.Request{original}); err != nil {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want same-origin redirect", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyKeepsCredentialsStrippedAcrossExternalHops(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/first", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/second", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect.Header.Set("Authorization", "Bearer copied-from-initial-request")
|
||||
|
||||
if err := safeRedirectPolicy(redirect, []*http.Request{original, previous}); err != nil {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want same-CDN redirect", err)
|
||||
}
|
||||
if got := redirect.Header.Get("Authorization"); got != "" {
|
||||
t.Fatalf("redirect retained Authorization=%q outside the initial origin", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeRedirectPolicyRejectsDowngradeOnLaterHop(t *testing.T) {
|
||||
original, err := http.NewRequest(http.MethodGet, "http://source.example/start", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/secure", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
redirect, err := http.NewRequest(http.MethodGet, "http://cdn.example.com/plain", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = safeRedirectPolicy(redirect, []*http.Request{original, previous})
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTPS") {
|
||||
t.Fatalf("safeRedirectPolicy() error = %v, want later-hop HTTPS downgrade rejection", err)
|
||||
}
|
||||
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
|
||||
}
|
||||
|
||||
func requireRedirectProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
|
||||
t.Helper()
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want typed error", err)
|
||||
}
|
||||
if problem.Category != category || problem.Subtype != subtype {
|
||||
t.Fatalf(
|
||||
"error category/subtype = %s/%s, want %s/%s",
|
||||
problem.Category,
|
||||
problem.Subtype,
|
||||
category,
|
||||
subtype,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,9 @@ var proxyWarnGateCases = []struct {
|
||||
{"non-terminal stderr stays silent", false, 0},
|
||||
}
|
||||
|
||||
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
|
||||
// TestCachedHTTPClientFunc_ProxyWarnGate verifies the HTTP client init path
|
||||
// invokes WarnIfProxied only when stderr is an interactive terminal.
|
||||
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
|
||||
func TestCachedHTTPClientFunc_ProxyWarnGate(t *testing.T) {
|
||||
isEnabled := false
|
||||
for _, tc := range proxyWarnGateCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
envprovider "github.com/larksuite/cli/extension/credential/env"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
@@ -406,14 +405,6 @@ type stubExtProvider struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type stubDefaultAccountResolver struct {
|
||||
acct *credential.Account
|
||||
}
|
||||
|
||||
func (s *stubDefaultAccountResolver) ResolveAccount(_ context.Context) (*credential.Account, error) {
|
||||
return s.acct, nil
|
||||
}
|
||||
|
||||
func (s *stubExtProvider) Name() string { return s.name }
|
||||
func (s *stubExtProvider) ResolveAccount(_ context.Context) (*extcred.Account, error) {
|
||||
return s.acct, s.err
|
||||
@@ -457,86 +448,6 @@ func TestRequireBuiltinCredentialProvider_AllowsBuiltinProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireBuiltinCredentialProvider_AllowsMatchingAppIDOnlyProfile(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envvars.CliAppID, "cli_a")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "tenant_a",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "tenant_a",
|
||||
AppId: "cli_a",
|
||||
AppSecret: core.PlainSecret("test-secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&envprovider.Provider{}},
|
||||
&stubDefaultAccountResolver{acct: &credential.Account{AppID: "cli_a", AppSecret: "test-secret"}},
|
||||
nil,
|
||||
nil,
|
||||
).WithProfileFromFlag("tenant_a")
|
||||
f, _, _, _ := TestFactory(t, nil)
|
||||
f.Credential = cred
|
||||
|
||||
if err := f.RequireBuiltinCredentialProvider(context.Background(), "auth"); err != nil {
|
||||
t.Fatalf("matching APP_ID-only profile should use builtin credentials: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A stale LARKSUITE_CLI_PROFILE (profile that cannot resolve) must not lock
|
||||
// the user out of the builtin setup/repair commands this gate guards: the
|
||||
// probe falls back to provider engagement and lets the command run.
|
||||
func TestRequireBuiltinCredentialProvider_StaleProfileDoesNotLockOut(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) // no config -> "ghost" cannot resolve
|
||||
|
||||
stub := &stubExtProvider{name: "env"} // not engaged: returns nil, nil
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{stub},
|
||||
&stubDefaultAccountResolver{},
|
||||
nil,
|
||||
nil,
|
||||
).WithProfileFromEnv("ghost")
|
||||
f, _, _, _ := TestFactory(t, nil)
|
||||
f.Credential = cred
|
||||
|
||||
if err := f.RequireBuiltinCredentialProvider(context.Background(), "config"); err != nil {
|
||||
t.Fatalf("stale profile must not lock out builtin auth/config commands: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An invalid policy variable (e.g. LARKSUITE_CLI_DEFAULT_AS=banana) is a user
|
||||
// input error, not an external credential takeover: the gate surfaces the
|
||||
// same typed validation error as formal arbitration instead of a misleading
|
||||
// "provided externally" refusal.
|
||||
func TestRequireBuiltinCredentialProvider_InvalidPolicySurfacesTypedError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
stub := &stubExtProvider{name: "env", err: &extcred.BlockError{
|
||||
Provider: "env",
|
||||
Reason: "invalid LARKSUITE_CLI_DEFAULT_AS \"banana\" (want user, bot, or auto)",
|
||||
Code: extcred.BlockReasonInvalidPolicy,
|
||||
Param: envvars.CliDefaultAs,
|
||||
}}
|
||||
cred := credential.NewCredentialProvider([]extcred.Provider{stub}, &stubDefaultAccountResolver{}, nil, nil)
|
||||
f, _, _, _ := TestFactory(t, nil)
|
||||
f.Credential = cred
|
||||
|
||||
err := f.RequireBuiltinCredentialProvider(context.Background(), "auth")
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok || prob.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("err = %v, want typed invalid_argument (same as formal arbitration)", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "provided externally") {
|
||||
t.Fatalf("err = %v, must not read as external takeover", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireBuiltinCredentialProvider_NilCredential(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, nil)
|
||||
f.Credential = nil
|
||||
|
||||
@@ -26,6 +26,7 @@ const (
|
||||
HeaderShortcut = "X-Cli-Shortcut"
|
||||
HeaderExecutionId = "X-Cli-Execution-Id"
|
||||
HeaderAgentTrace = "X-Agent-Trace"
|
||||
HeaderAgentName = "X-Agent-Name"
|
||||
|
||||
SourceValue = "lark-cli"
|
||||
|
||||
@@ -55,6 +56,9 @@ func BaseSecurityHeaders() http.Header {
|
||||
if v := envvars.AgentTrace(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
if v := envvars.AgentName(); v != "" {
|
||||
h.Set(HeaderAgentName, v)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
|
||||
@@ -263,9 +263,34 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||
// Agent headers injected via BaseSecurityHeaders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentName, "")
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != "" {
|
||||
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentName, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_IncludesAgentNameHeaderWhenEnvSet(t *testing.T) {
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(envvars.CliAgentName, agentName)
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != agentName {
|
||||
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentName, v, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvInvalid(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentName, "agent\r\nX-Evil: attack")
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != "" {
|
||||
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent for invalid input", HeaderAgentName, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "")
|
||||
h := BaseSecurityHeaders()
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestTestFactory_ReplacesGlobals(t *testing.T) {
|
||||
URL: "/test",
|
||||
Body: "ok",
|
||||
})
|
||||
// Use the stub via Factory HttpClient
|
||||
// Use the stub via Factory HttpClient.
|
||||
httpClient, err := f.HttpClient()
|
||||
if err != nil {
|
||||
t.Fatalf("HttpClient() error: %v", err)
|
||||
|
||||
@@ -4,14 +4,19 @@
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
var (
|
||||
_ transport.RoundTripperDecorator = (*RetryTransport)(nil)
|
||||
_ transport.RoundTripperDecorator = (*UserAgentTransport)(nil)
|
||||
_ transport.RoundTripperDecorator = (*BuildHeaderTransport)(nil)
|
||||
_ transport.RoundTripperDecorator = (*SecurityHeaderTransport)(nil)
|
||||
)
|
||||
|
||||
// RetryTransport is an http.RoundTripper that retries on 5xx responses
|
||||
// and network errors. MaxRetries defaults to 0 (no retries).
|
||||
type RetryTransport struct {
|
||||
@@ -27,6 +32,16 @@ func (t *RetryTransport) base() http.RoundTripper {
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *RetryTransport) BaseRoundTripper() http.RoundTripper {
|
||||
return t.base()
|
||||
}
|
||||
|
||||
func (t *RetryTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *t
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (t *RetryTransport) delay() time.Duration {
|
||||
if t.Delay > 0 {
|
||||
return t.Delay
|
||||
@@ -63,6 +78,19 @@ type UserAgentTransport struct {
|
||||
Base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *UserAgentTransport) BaseRoundTripper() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *UserAgentTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *t
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set(HeaderUserAgent, UserAgentValue())
|
||||
@@ -73,14 +101,25 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
|
||||
}
|
||||
|
||||
// BuildHeaderTransport is an http.RoundTripper that force-writes the
|
||||
// X-Cli-Build header before every request. Used in the SDK transport chain,
|
||||
// where SecurityHeaderTransport is not installed, to prevent extensions from
|
||||
// tampering with the build classification. The direct HTTP chain is already
|
||||
// covered by SecurityHeaderTransport iterating BaseSecurityHeaders.
|
||||
// X-Cli-Build header before every request. It remains in the SDK transport
|
||||
// chain as a narrow defense-in-depth layer alongside SecurityHeaderTransport.
|
||||
type BuildHeaderTransport struct {
|
||||
Base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *BuildHeaderTransport) BaseRoundTripper() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *BuildHeaderTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *t
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (t *BuildHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set(HeaderBuild, DetectBuildKind())
|
||||
@@ -103,6 +142,16 @@ func (t *SecurityHeaderTransport) base() http.RoundTripper {
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *SecurityHeaderTransport) BaseRoundTripper() http.RoundTripper {
|
||||
return t.base()
|
||||
}
|
||||
|
||||
func (t *SecurityHeaderTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *t
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
@@ -120,67 +169,3 @@ func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response,
|
||||
}
|
||||
return t.base().RoundTrip(req)
|
||||
}
|
||||
|
||||
// extensionMiddleware wraps the built-in transport chain with pre/post hooks.
|
||||
// The built-in chain always executes unless the extension is an
|
||||
// exttransport.AbortableInterceptor and its PreRoundTripE returns a non-nil
|
||||
// error; it cannot otherwise be skipped or overridden.
|
||||
//
|
||||
// The original request context is restored after the pre hook to prevent
|
||||
// extensions from tampering with cancellation, deadlines, or built-in values.
|
||||
// Cloning the request isolates header/URL/etc. mutations from the caller's
|
||||
// request object; req.Body is intentionally shared — extensions that consume
|
||||
// it are responsible for rewinding (see Interceptor doc).
|
||||
type extensionMiddleware struct {
|
||||
Base http.RoundTripper
|
||||
Ext exttransport.Interceptor
|
||||
ExtName string // Provider.Name(), captured at wrap time for *AbortError.Extension
|
||||
}
|
||||
|
||||
// RoundTrip invokes the interceptor pre hook, restores the original context,
|
||||
// executes the built-in chain (unless aborted), then calls the post hook if
|
||||
// non-nil. When the extension implements AbortableInterceptor and returns a
|
||||
// non-nil error from PreRoundTripE, the built-in chain is skipped and an
|
||||
// *exttransport.AbortError is returned; the post hook is still invoked with
|
||||
// (nil, reason) so extensions can unwind resources.
|
||||
func (m *extensionMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
origCtx := req.Context()
|
||||
req = req.Clone(origCtx)
|
||||
|
||||
var (
|
||||
post func(*http.Response, error)
|
||||
abortEr error
|
||||
)
|
||||
if a, ok := m.Ext.(exttransport.AbortableInterceptor); ok {
|
||||
post, abortEr = a.PreRoundTripE(req)
|
||||
} else {
|
||||
post = m.Ext.PreRoundTrip(req)
|
||||
}
|
||||
if abortEr != nil {
|
||||
if post != nil {
|
||||
post(nil, abortEr)
|
||||
}
|
||||
return nil, &exttransport.AbortError{Extension: m.ExtName, Reason: abortEr}
|
||||
}
|
||||
|
||||
req = req.WithContext(origCtx) // restore original context
|
||||
resp, err := m.Base.RoundTrip(req)
|
||||
if post != nil {
|
||||
post(resp, err)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// wrapWithExtension wraps transport with the registered extension middleware.
|
||||
// If no extension is registered, returns transport unchanged.
|
||||
func wrapWithExtension(transport http.RoundTripper) http.RoundTripper {
|
||||
p := exttransport.GetProvider()
|
||||
if p == nil {
|
||||
return transport
|
||||
}
|
||||
tr := p.ResolveInterceptor(context.Background())
|
||||
if tr == nil {
|
||||
return transport
|
||||
}
|
||||
return &extensionMiddleware{Base: transport, Ext: tr, ExtName: p.Name()}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/riskcontrol"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
@@ -91,94 +91,107 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wrapSDKTransport chain composition
|
||||
// buildSDKTransport policy behavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
func TestBuildSDKTransportAppliesSecurityHeadersToEveryRequestClass(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
received := make(chan http.Header, 2)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
received <- req.Header.Clone()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
}
|
||||
bh, ok := sec.Base.(*BuildHeaderTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
|
||||
}
|
||||
ua, ok := bh.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
retry, ok := ua.Base.(*RetryTransport)
|
||||
if !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
|
||||
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
|
||||
for _, class := range []exttransport.RequestClass{
|
||||
exttransport.RequestClassPlatform,
|
||||
exttransport.RequestClassExternal,
|
||||
} {
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: buildSDKTransport(nil)},
|
||||
class,
|
||||
)
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
headers := <-received
|
||||
for header, values := range BaseSecurityHeaders() {
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
want := values[len(values)-1]
|
||||
if got := headers.Get(header); got != want {
|
||||
t.Fatalf("SDK %s header %s = %q, want %q", class, header, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapSDKTransport_WithExtension(t *testing.T) {
|
||||
func TestBuildSDKTransport_WithExtension(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(&stubTransportProvider{})
|
||||
interceptor := &headerCapturingInterceptor{}
|
||||
exttransport.Register(&platformOnlyStubProvider{
|
||||
stubTransportProvider: &stubTransportProvider{interceptor: interceptor},
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
|
||||
mid, ok := transport.(*extensionMiddleware)
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: buildSDKTransport(nil)},
|
||||
exttransport.RequestClassPlatform,
|
||||
)
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sec, ok := mid.Base.(*internalauth.SecurityPolicyTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *auth.SecurityPolicyTransport", mid.Base)
|
||||
}
|
||||
bh, ok := sec.Base.(*BuildHeaderTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
|
||||
}
|
||||
ua, ok := bh.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
retry, ok := ua.Base.(*RetryTransport)
|
||||
if !ok {
|
||||
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
|
||||
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
|
||||
resp.Body.Close()
|
||||
if !interceptor.preCalled || !interceptor.postCalled {
|
||||
t.Fatal("SDK platform request did not execute extension pre/post hooks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
|
||||
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(nil)
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
|
||||
if _, ok := buildSDKTransport(nil).(*internaltransport.HTTPPolicyRouter); !ok {
|
||||
t.Fatalf(
|
||||
"buildSDKTransport() type = %T, want *transport.HTTPPolicyRouter",
|
||||
buildSDKTransport(nil),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
|
||||
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
|
||||
func TestBuildSDKTransportSupportsPolicyLeafCloning(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(nil)
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
base := &http.Transport{}
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: buildSDKTransportWithBase(base, nil)},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
source, ok := client.Transport.(interface {
|
||||
CloneHTTPTransport() (http.RoundTripper, *http.Transport, bool)
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
|
||||
t.Fatalf("SDK request-class transport type = %T, want clone capability", client.Transport)
|
||||
}
|
||||
bh, ok := sec.Base.(*BuildHeaderTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
|
||||
rebuilt, concrete, ok := source.CloneHTTPTransport()
|
||||
if !ok || rebuilt == nil || concrete == nil {
|
||||
t.Fatal("SDK policy graph could not clone its HTTP transport leaf")
|
||||
}
|
||||
ua, ok := bh.Base.(*UserAgentTransport)
|
||||
if !ok {
|
||||
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
|
||||
}
|
||||
retry, ok := ua.Base.(*RetryTransport)
|
||||
if !ok {
|
||||
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
|
||||
}
|
||||
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
|
||||
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
|
||||
if concrete == base {
|
||||
t.Fatal("SDK policy graph reused the original HTTP transport")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +251,7 @@ func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
|
||||
var base http.RoundTripper = http.DefaultTransport
|
||||
base = &RetryTransport{Base: base}
|
||||
base = &SecurityHeaderTransport{Base: base}
|
||||
transport := wrapWithExtension(base)
|
||||
transport := internaltransport.WrapWithExtension(base)
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequest("GET", srv.URL, nil)
|
||||
@@ -266,14 +279,16 @@ func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// buildTamperingInterceptor tries to delete and spoof X-Cli-Build via
|
||||
// PreRoundTrip. The SDK chain's BuildHeaderTransport must restore the real
|
||||
// value before the request leaves the process.
|
||||
// buildTamperingInterceptor tries to delete and spoof security headers via
|
||||
// PreRoundTrip. The SDK built-in chain must restore the real values before the
|
||||
// request leaves the process.
|
||||
type buildTamperingInterceptor struct{}
|
||||
|
||||
func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
req.Header.Del(HeaderBuild)
|
||||
req.Header.Set(HeaderBuild, "ext-tampered-build")
|
||||
req.Header.Del(HeaderSource)
|
||||
req.Header.Set(HeaderSource, "ext-tampered-source")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -285,7 +300,74 @@ func (riskHeaderTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
|
||||
type bootstrapPolicyTamperingInterceptor struct{}
|
||||
|
||||
func (bootstrapPolicyTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
req.Header.Set(HeaderSource, "extension-value")
|
||||
req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNewDefaultInstallsSDKBootstrapSecurityPolicy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
oldTransport := http.DefaultClient.Transport
|
||||
oldCheckRedirect := http.DefaultClient.CheckRedirect
|
||||
t.Cleanup(func() {
|
||||
http.DefaultClient.Transport = oldTransport
|
||||
http.DefaultClient.CheckRedirect = oldCheckRedirect
|
||||
})
|
||||
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(&platformOnlyStubProvider{
|
||||
stubTransportProvider: &stubTransportProvider{
|
||||
interceptor: bootstrapPolicyTamperingInterceptor{},
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
|
||||
var received http.Header
|
||||
network := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
received = req.Header.Clone()
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
http.DefaultClient.Transport = network
|
||||
http.DefaultClient.CheckRedirect = nil
|
||||
_ = NewDefault(nil, InvocationContext{})
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://open.feishu.cn/callback/ws/endpoint",
|
||||
strings.NewReader(`{"app_secret":"secret"}`),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if got := received.Get(HeaderSource); got != SourceValue {
|
||||
t.Fatalf("%s = %q, want trusted value %q", HeaderSource, got, SourceValue)
|
||||
}
|
||||
if got := received.Get(riskcontrol.HeaderOSType); got != "" {
|
||||
t.Fatalf("%s = %q, want extension value stripped", riskcontrol.HeaderOSType, got)
|
||||
}
|
||||
if got := received.Get(HeaderBuild); got != DetectBuildKind() {
|
||||
t.Fatalf("%s = %q, want %q", HeaderBuild, got, DetectBuildKind())
|
||||
}
|
||||
if got := received.Get(HeaderUserAgent); got != UserAgentValue() {
|
||||
t.Fatalf("%s = %q, want %q", HeaderUserAgent, got, UserAgentValue())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
|
||||
previous := exttransport.GetProvider()
|
||||
exttransport.Register(&stubTransportProvider{interceptor: riskHeaderTamperingInterceptor{}})
|
||||
t.Cleanup(func() { exttransport.Register(previous) })
|
||||
@@ -301,7 +383,11 @@ func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer token")
|
||||
|
||||
resp, err := wrapSDKTransport(riskcontrol.NewTransport(network, nil)).RoundTrip(req)
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: buildSDKTransportWithBase(network, nil)},
|
||||
exttransport.RequestClassPlatform,
|
||||
)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -312,14 +398,13 @@ func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
|
||||
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
|
||||
// transport chain, even when an extension tries to delete or spoof it. This
|
||||
// closes the gap where the SDK chain had no equivalent of
|
||||
// SecurityHeaderTransport (see design doc §3.3.3).
|
||||
// SDK chain restores both the build classification and the full security
|
||||
// header set after an extension runs.
|
||||
func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
|
||||
var receivedBuild string
|
||||
var receivedBuild, receivedSource string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedBuild = r.Header.Get(HeaderBuild)
|
||||
receivedSource = r.Header.Get(HeaderSource)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
@@ -327,12 +412,13 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
|
||||
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
// Replicate the SDK chain layering used by wrapSDKTransport.
|
||||
// Replicate the SDK built-in chain inside buildSDKTransport.
|
||||
var base http.RoundTripper = http.DefaultTransport
|
||||
base = &RetryTransport{Base: base}
|
||||
base = &UserAgentTransport{Base: base}
|
||||
base = &BuildHeaderTransport{Base: base}
|
||||
transport := wrapWithExtension(base)
|
||||
base = &SecurityHeaderTransport{Base: base}
|
||||
transport := internaltransport.WrapWithExtension(base)
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
req, _ := http.NewRequest("GET", srv.URL, nil)
|
||||
@@ -349,6 +435,9 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
|
||||
if receivedBuild != want {
|
||||
t.Fatalf("%s = %q, want %q", HeaderBuild, receivedBuild, want)
|
||||
}
|
||||
if receivedSource != SourceValue {
|
||||
t.Fatalf("%s = %q, want %q", HeaderSource, receivedSource, SourceValue)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildHeaderTransport_OverridesEvenWithoutTamper verifies that even if
|
||||
@@ -438,7 +527,7 @@ func TestExtensionInterceptor_ContextTamperPrevented(t *testing.T) {
|
||||
return nil
|
||||
})
|
||||
|
||||
mid := &extensionMiddleware{Base: capturer, Ext: tamperIC}
|
||||
mid := &internaltransport.ExtensionMiddleware{Base: capturer, Ext: tamperIC}
|
||||
|
||||
origCtx := context.WithValue(context.Background(), testKey, "original")
|
||||
req, _ := http.NewRequestWithContext(origCtx, "GET", srv.URL, nil)
|
||||
@@ -500,7 +589,7 @@ func TestExtensionMiddleware_PreRoundTripEAbort(t *testing.T) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
|
||||
})
|
||||
|
||||
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
|
||||
resp, err := mid.RoundTrip(req)
|
||||
|
||||
@@ -541,7 +630,7 @@ func TestExtensionMiddleware_PreRoundTripEAbort(t *testing.T) {
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
|
||||
_, err := mid.RoundTrip(req)
|
||||
|
||||
@@ -560,7 +649,7 @@ func TestExtensionMiddleware_PreRoundTripEHappyPath(t *testing.T) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
|
||||
})
|
||||
|
||||
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
|
||||
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
|
||||
resp, err := mid.RoundTrip(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -255,11 +255,7 @@ func ResolveConfigFromMulti(raw *MultiAppConfig, kc keychain.KeychainAccess, pro
|
||||
}
|
||||
|
||||
if err := ValidateSecretKeyMatch(app.AppId, app.AppSecret); err != nil {
|
||||
// invalid_config, not not_configured: the config exists but is
|
||||
// internally inconsistent. not_configured would let callers degrade
|
||||
// this into a generic "secret invalid" answer and destroy the precise
|
||||
// repair hint (which names the expected keychain key — never a value).
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "appId and appSecret keychain key are out of sync").
|
||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "appId and appSecret keychain key are out of sync").
|
||||
WithHint("%s", err.Error()).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
@@ -36,13 +36,16 @@ func LoadOrNotConfigured() (*MultiAppConfig, error) {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, NotConfiguredError()
|
||||
}
|
||||
// Surface the real cause so the user can fix the broken file. Every
|
||||
// non-ENOENT load failure — malformed JSON, permission denied, I/O
|
||||
// error — means a config EXISTS but cannot be used: invalid_config.
|
||||
// Only a genuinely absent config is not_configured; anything else
|
||||
// classified as not_configured would let callers degrade it into
|
||||
// profile_not_found / no_active_profile and hide the real cause.
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to load config: %v", err).WithCause(err)
|
||||
// Surface the real cause (parse error, permission denied, etc.)
|
||||
// so the user can fix the broken file. A malformed file is
|
||||
// invalid_config; anything else (permission denied, etc.) is
|
||||
// not_configured. Both stay on the typed structured-envelope path
|
||||
// at the root command's error sink.
|
||||
subtype := errs.SubtypeNotConfigured
|
||||
if isMalformedConfigError(err) {
|
||||
subtype = errs.SubtypeInvalidConfig
|
||||
}
|
||||
return nil, errs.NewConfigError(subtype, "failed to load config: %v", err).WithCause(err)
|
||||
}
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
return nil, NotConfiguredError()
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
package core
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LarkBrand represents the Lark platform brand.
|
||||
// "feishu" targets China-mainland, "lark" targets international.
|
||||
@@ -63,3 +66,39 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
|
||||
func ResolveOpenBaseURL(brand LarkBrand) string {
|
||||
return ResolveEndpoints(brand).Open
|
||||
}
|
||||
|
||||
var platformEndpointHosts = func() map[string]struct{} {
|
||||
hosts := make(map[string]struct{})
|
||||
for _, brand := range []LarkBrand{BrandFeishu, BrandLark} {
|
||||
endpoints := ResolveEndpoints(brand)
|
||||
for _, rawURL := range []string{endpoints.Open, endpoints.Accounts, endpoints.MCP, endpoints.AppLink} {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err == nil && parsed.Hostname() != "" {
|
||||
hosts[strings.ToLower(parsed.Hostname())] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return hosts
|
||||
}()
|
||||
|
||||
// IsPlatformEndpointHost reports whether hostname exactly matches one of the
|
||||
// endpoint hosts produced by ResolveEndpoints. It intentionally does not use a
|
||||
// suffix match: lookalike external domains must never enter the platform
|
||||
// transport extension.
|
||||
func IsPlatformEndpointHost(hostname string) bool {
|
||||
_, ok := platformEndpointHosts[strings.ToLower(hostname)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsPlatformEndpointURL reports whether candidate uses a secure origin for a
|
||||
// configured platform endpoint. Non-TLS and non-standard-port lookalikes are
|
||||
// excluded even when their hostname matches.
|
||||
func IsPlatformEndpointURL(candidate *url.URL) bool {
|
||||
if candidate == nil || !strings.EqualFold(candidate.Scheme, "https") {
|
||||
return false
|
||||
}
|
||||
if port := candidate.Port(); port != "" && port != "443" {
|
||||
return false
|
||||
}
|
||||
return IsPlatformEndpointHost(candidate.Hostname())
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveEndpoints_Feishu(t *testing.T) {
|
||||
ep := ResolveEndpoints(BrandFeishu)
|
||||
@@ -91,3 +95,85 @@ func TestResolveEndpoints_NormalizesBrand(t *testing.T) {
|
||||
t.Errorf("ResolveEndpoints(unexpected).Open = %q, want the feishu default", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPlatformEndpointHost_ExactMatchOnly(t *testing.T) {
|
||||
for _, host := range []string{
|
||||
"open.feishu.cn",
|
||||
"accounts.feishu.cn",
|
||||
"mcp.feishu.cn",
|
||||
"applink.feishu.cn",
|
||||
"open.larksuite.com",
|
||||
"accounts.larksuite.com",
|
||||
"mcp.larksuite.com",
|
||||
"applink.larksuite.com",
|
||||
} {
|
||||
if !IsPlatformEndpointHost(host) {
|
||||
t.Errorf("IsPlatformEndpointHost(%q) = false, want true", host)
|
||||
}
|
||||
}
|
||||
|
||||
for _, host := range []string{
|
||||
"example.com",
|
||||
"open.feishu.cn.example.com",
|
||||
"notopen.feishu.cn",
|
||||
"",
|
||||
} {
|
||||
if IsPlatformEndpointHost(host) {
|
||||
t.Errorf("IsPlatformEndpointHost(%q) = true, want false", host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPlatformEndpointHost_CoversEveryResolvedEndpoint(t *testing.T) {
|
||||
for _, brand := range []LarkBrand{BrandFeishu, BrandLark} {
|
||||
endpoints := reflect.ValueOf(ResolveEndpoints(brand))
|
||||
for i := 0; i < endpoints.NumField(); i++ {
|
||||
rawURL := endpoints.Field(i).String()
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveEndpoints(%q) field %d URL %q: %v", brand, i, rawURL, err)
|
||||
}
|
||||
if !IsPlatformEndpointHost(parsed.Hostname()) {
|
||||
t.Errorf("ResolveEndpoints(%q) field %d host %q is missing from the platform transport boundary", brand, i, parsed.Hostname())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPlatformEndpointURL_RequiresSecureStandardOrigin(t *testing.T) {
|
||||
if IsPlatformEndpointURL(nil) {
|
||||
t.Error("IsPlatformEndpointURL(nil) = true, want false")
|
||||
}
|
||||
uppercaseScheme := &url.URL{Scheme: "HTTPS", Host: "open.feishu.cn", Path: "/path"}
|
||||
if !IsPlatformEndpointURL(uppercaseScheme) {
|
||||
t.Error("IsPlatformEndpointURL() rejected uppercase HTTPS scheme")
|
||||
}
|
||||
|
||||
for _, rawURL := range []string{
|
||||
"http://open.feishu.cn/path",
|
||||
"https://open.feishu.cn:8443/path",
|
||||
"https://open.feishu.cn.example.com/path",
|
||||
} {
|
||||
candidate, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if IsPlatformEndpointURL(candidate) {
|
||||
t.Errorf("IsPlatformEndpointURL(%q) = true, want false", rawURL)
|
||||
}
|
||||
}
|
||||
|
||||
for _, rawURL := range []string{
|
||||
"https://open.feishu.cn/path",
|
||||
"https://open.feishu.cn:443/path",
|
||||
"https://OPEN.FEISHU.CN/path",
|
||||
} {
|
||||
candidate, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !IsPlatformEndpointURL(candidate) {
|
||||
t.Errorf("IsPlatformEndpointURL(%q) = false, want true", rawURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build authsidecar
|
||||
|
||||
package credential_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
sidecarprovider "github.com/larksuite/cli/extension/credential/sidecar"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/sidecar"
|
||||
)
|
||||
|
||||
func newRealSidecarCredentialProvider(t *testing.T) *credential.CredentialProvider {
|
||||
t.Helper()
|
||||
t.Setenv(envvars.CliAuthProxy, "http://127.0.0.1:16384")
|
||||
t.Setenv(envvars.CliProxyKey, "test-key")
|
||||
t.Setenv(envvars.CliAppID, "cli_sidecar")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
t.Setenv(envvars.CliUserAccessToken, "")
|
||||
t.Setenv(envvars.CliTenantAccessToken, "")
|
||||
t.Setenv(envvars.CliDefaultAs, "")
|
||||
t.Setenv(envvars.CliStrictMode, "")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
return credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&sidecarprovider.Provider{}},
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func TestAuthSidecarInvalidPolicyUsesValidationContract(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
key string
|
||||
}{
|
||||
{name: "default as", key: envvars.CliDefaultAs},
|
||||
{name: "strict mode", key: envvars.CliStrictMode},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cp := newRealSidecarCredentialProvider(t)
|
||||
t.Setenv(tt.key, "banana")
|
||||
|
||||
_, err := cp.ResolveAccount(context.Background())
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %T %v, want typed validation error", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want ValidationError", err, err)
|
||||
}
|
||||
if validationErr.Param != tt.key {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, tt.key)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, tt.key) {
|
||||
t.Fatalf("hint = %q, want variable name %s", problem.Hint, tt.key)
|
||||
}
|
||||
var blockErr *extcred.BlockError
|
||||
if !errors.As(err, &blockErr) ||
|
||||
blockErr.Code != extcred.BlockReasonInvalidPolicy ||
|
||||
blockErr.Param != tt.key {
|
||||
t.Fatalf("cause = %T %v, want classified BlockError for %s", err, err, tt.key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSidecarGateProbeUsesValidationContract(t *testing.T) {
|
||||
cp := newRealSidecarCredentialProvider(t)
|
||||
t.Setenv(envvars.CliStrictMode, "banana")
|
||||
|
||||
name, err := cp.ActiveExtensionProviderName(context.Background())
|
||||
if name != "" {
|
||||
t.Fatalf("provider name = %q, want empty on invalid policy", name)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %T %v, want typed validation error", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want ValidationError", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation ||
|
||||
problem.Subtype != errs.SubtypeInvalidArgument ||
|
||||
validationErr.Param != envvars.CliStrictMode {
|
||||
t.Fatalf("problem = %+v param = %q, want validation/invalid_argument param %s", problem, validationErr.Param, envvars.CliStrictMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSidecarTokenHonorsSelectedAppID(t *testing.T) {
|
||||
t.Run("matching app returns sentinel", func(t *testing.T) {
|
||||
cp := newRealSidecarCredentialProvider(t)
|
||||
|
||||
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
|
||||
Type: credential.TokenTypeUAT,
|
||||
AppID: "cli_sidecar",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveToken: %v", err)
|
||||
}
|
||||
if result == nil || result.Token != sidecar.SentinelUAT {
|
||||
t.Fatalf("result = %+v, want sidecar UAT sentinel", result)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
appID string
|
||||
}{
|
||||
{name: "empty app id", appID: ""},
|
||||
{name: "conflicting app id", appID: "cli_other"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cp := newRealSidecarCredentialProvider(t)
|
||||
|
||||
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
|
||||
Type: credential.TokenTypeUAT,
|
||||
AppID: tt.appID,
|
||||
})
|
||||
if result != nil {
|
||||
t.Fatalf("result = %+v, want no sidecar sentinel", result)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error = %T %v, want typed internal error", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryInternal, errs.SubtypeUnknown)
|
||||
}
|
||||
if strings.Contains(err.Error(), sidecar.SentinelUAT) {
|
||||
t.Fatalf("error leaked sidecar sentinel: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,11 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
envprovider "github.com/larksuite/cli/extension/credential/env"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// DefaultAccountResolver is implemented by the default account provider.
|
||||
@@ -142,21 +136,10 @@ type CredentialProvider struct {
|
||||
httpClient func() (*http.Client, error)
|
||||
warnOut io.Writer
|
||||
|
||||
// profile is the active profile (from --profile or LARKSUITE_CLI_PROFILE);
|
||||
// profileSrc records which of the two supplied it, for the reported
|
||||
// selection and error attribution.
|
||||
profile string
|
||||
profileSrc CredentialSourceKind
|
||||
|
||||
accountOnce sync.Once
|
||||
account *Account
|
||||
accountErr error
|
||||
selectedSource credentialSource
|
||||
// selection is the explainable credential-selection result, populated by
|
||||
// doResolveAccount under accountOnce. It never carries a secret.
|
||||
selection IdentitySelection
|
||||
|
||||
enrichOnce sync.Once
|
||||
|
||||
hintOnce sync.Once
|
||||
hint *IdentityHint
|
||||
@@ -178,521 +161,49 @@ func (p *CredentialProvider) SetWarnOut(warnOut io.Writer) *CredentialProvider {
|
||||
return p
|
||||
}
|
||||
|
||||
// WithProfileFromFlag records the --profile flag value as the active profile.
|
||||
// It governs credential arbitration and the reported selection source.
|
||||
func (p *CredentialProvider) WithProfileFromFlag(profile string) *CredentialProvider {
|
||||
p.profile = profile
|
||||
p.profileSrc = SourceFlagProfile
|
||||
return p
|
||||
}
|
||||
|
||||
// WithProfileFromEnv records the LARKSUITE_CLI_PROFILE env fallback as the
|
||||
// active profile. It governs credential arbitration and the reported
|
||||
// selection source.
|
||||
func (p *CredentialProvider) WithProfileFromEnv(profile string) *CredentialProvider {
|
||||
p.profile = profile
|
||||
p.profileSrc = SourceEnvProfile
|
||||
return p
|
||||
}
|
||||
|
||||
// ResolveAccount resolves app credentials. Result is cached after first call.
|
||||
// NOTE: Uses sync.Once — only the context from the first call is used for resolution.
|
||||
// Subsequent calls return the cached result regardless of their context.
|
||||
// This is acceptable for CLI (single invocation per process) but not for long-running servers.
|
||||
func (p *CredentialProvider) ResolveAccount(ctx context.Context) (*Account, error) {
|
||||
acct, err := p.resolveAccountSelection(ctx)
|
||||
if err != nil || acct == nil {
|
||||
return acct, err
|
||||
}
|
||||
if _, ok := p.selectedSource.(extensionTokenSource); ok {
|
||||
p.enrichOnce.Do(func() {
|
||||
p.enrichOrClearIdentity(ctx, acct, p.selectedSource)
|
||||
})
|
||||
}
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
// resolveAccountSelection performs and caches only credential selection. It
|
||||
// deliberately does not resolve tokens or user_info, so callers can validate
|
||||
// the selected app before any token work begins.
|
||||
func (p *CredentialProvider) resolveAccountSelection(ctx context.Context) (*Account, error) {
|
||||
p.accountOnce.Do(func() {
|
||||
p.account, p.accountErr = p.doResolveAccount(ctx)
|
||||
})
|
||||
return p.account, p.accountErr
|
||||
}
|
||||
|
||||
// doResolveAccount arbitrates the credential/App selection in three phases:
|
||||
// gather all arbitration inputs in a single I/O pass, decide the route with a
|
||||
// pure function, then execute the remaining I/O for the chosen route.
|
||||
//
|
||||
// Resolution order (encoded in decideIdentity): a managed extension provider
|
||||
// (e.g. sidecar) wins outright; then an explicit profile (--profile /
|
||||
// LARKSUITE_CLI_PROFILE) arbitrates against the direct env credential
|
||||
// (matching app_id → profile supplies credential and tokens; mismatch → hard
|
||||
// conflict; incomplete env without a usable app_id → repair error); then a
|
||||
// complete direct env credential; then the config default (currentApp →
|
||||
// firstApp).
|
||||
//
|
||||
// It populates p.selection (never carries a secret) and p.selectedSource on
|
||||
// every success path.
|
||||
func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, error) {
|
||||
in, err := p.gatherIdentityInputs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, err := decideIdentity(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
acct, source, err := p.execute(ctx, d, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.selectedSource = source
|
||||
// Assigned only after full success: error paths can never leave a
|
||||
// partial selection behind.
|
||||
p.selection = d.selection
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
// providerAccount pairs an extension-provider account with its token source.
|
||||
type providerAccount struct {
|
||||
acct *Account
|
||||
source extensionTokenSource
|
||||
}
|
||||
|
||||
// identityInputs is one invocation's complete arbitration input, gathered in
|
||||
// a single pass by gatherIdentityInputs. It is read-only after gathering;
|
||||
// decideIdentity consumes it without further I/O.
|
||||
type identityInputs struct {
|
||||
profile string
|
||||
profileSrc CredentialSourceKind
|
||||
|
||||
managed *providerAccount // managed extension account; wins arbitration outright
|
||||
direct *providerAccount // complete direct env credential
|
||||
// directBlock is a provider's explicit incomplete-direct-credential
|
||||
// classification (BlockError.Code == credential_incomplete). It
|
||||
// participates in profile arbitration instead of failing outright.
|
||||
directBlock *extcred.BlockError
|
||||
|
||||
// directKeys / conflictKeys describe the BUILTIN process-env direct
|
||||
// credential surface (LARKSUITE_CLI_* variable NAMES, never values).
|
||||
// They annotate DirectCredentialEnv and conflict hints; a third-party
|
||||
// AccountDirect provider reports its own inputs via BlockError metadata
|
||||
// (PresentKeys/AppID), not through these.
|
||||
directKeys []string
|
||||
conflictKeys []string
|
||||
|
||||
config *core.MultiAppConfig
|
||||
configErr error
|
||||
}
|
||||
|
||||
// gatherIdentityInputs performs the arbitration's read phase: it consults the
|
||||
// extension providers and snapshots the config. Providers classify their own
|
||||
// failures at the source (BlockError.Code); this layer must not infer them by
|
||||
// re-reading environment variables or parsing Reason.
|
||||
func (p *CredentialProvider) gatherIdentityInputs(ctx context.Context) (identityInputs, error) {
|
||||
in := identityInputs{
|
||||
profile: p.profile,
|
||||
profileSrc: p.profileSrc,
|
||||
directKeys: presentDirectCredentialKeys(),
|
||||
conflictKeys: presentDirectCredentialInputKeys(),
|
||||
}
|
||||
for _, prov := range p.providers {
|
||||
acct, err := prov.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
var blockErr *extcred.BlockError
|
||||
if errors.As(err, &blockErr) {
|
||||
switch blockErr.Code {
|
||||
case extcred.BlockReasonCredentialIncomplete:
|
||||
// app_credential_incomplete, profile matching, and
|
||||
// DirectCredentialEnv diagnostics are defined in terms of
|
||||
// the builtin LARKSUITE_CLI_* env surface. Until the SPI
|
||||
// carries provider-owned input descriptors, accepting this
|
||||
// classification from another provider would produce
|
||||
// contradictory arbitration and repair hints.
|
||||
if _, builtin := prov.(*envprovider.Provider); !builtin {
|
||||
return in, newCredentialIncompleteProviderContractError(prov)
|
||||
}
|
||||
in.directBlock = blockErr
|
||||
case extcred.BlockReasonInvalidPolicy:
|
||||
// A user-supplied policy value failed validation; that is
|
||||
// a validation error, never an internal one.
|
||||
return in, newInvalidPolicyError(blockErr)
|
||||
default:
|
||||
// Blocks without a recognized Code preserve their
|
||||
// original attribution.
|
||||
return in, err
|
||||
return nil, err
|
||||
}
|
||||
if acct != nil {
|
||||
internal := convertAccount(acct)
|
||||
source := extensionTokenSource{provider: prov}
|
||||
if err := p.enrichUserInfo(ctx, internal, source); err != nil {
|
||||
if p.warnOut != nil {
|
||||
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
|
||||
}
|
||||
break
|
||||
// enrichUserInfo failure is non-fatal: SupportedIdentities
|
||||
// (used for strict mode) is already set by the provider.
|
||||
// Clear unverified user identity for safety.
|
||||
internal.UserOpenId = ""
|
||||
internal.UserName = ""
|
||||
}
|
||||
// Any other provider error preserves its original attribution.
|
||||
return in, err
|
||||
}
|
||||
if acct == nil {
|
||||
continue
|
||||
}
|
||||
pa := &providerAccount{acct: convertAccount(acct), source: extensionTokenSource{provider: prov}}
|
||||
switch acct.Kind {
|
||||
case extcred.AccountDirect:
|
||||
// The arbitration's direct-credential surface — DirectCredentialEnv,
|
||||
// the env:LARKSUITE_CLI_APP_ID selection source, conflict-hint
|
||||
// keys — is defined in terms of the builtin process-env variables.
|
||||
// Until the SPI carries provider-reported input descriptors, only
|
||||
// the builtin env provider may declare AccountDirect; accepting it
|
||||
// from anyone else would produce self-contradictory diagnostics
|
||||
// (e.g. credentialSource "env:LARKSUITE_CLI_APP_ID" with
|
||||
// directCredentialEnv.present=false). The check is by concrete
|
||||
// type: the registry reserves neither names nor uniqueness, so a
|
||||
// Name() comparison would be forgeable.
|
||||
if _, builtin := prov.(*envprovider.Provider); !builtin {
|
||||
return in, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"credential provider %q declared AccountDirect, which is reserved for the builtin env provider", prov.Name())
|
||||
}
|
||||
in.direct = pa
|
||||
case extcred.AccountManaged:
|
||||
in.managed = pa
|
||||
default:
|
||||
return in, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"credential provider %q returned unknown AccountKind %d", prov.Name(), acct.Kind)
|
||||
}
|
||||
break // the first engaged provider ends the scan (registry priority order)
|
||||
}
|
||||
// The config snapshot backs profile lookup, the config-default route, and
|
||||
// config-default failure attribution. A winning managed or direct-env
|
||||
// identity without a profile never needs it — and managed identities must
|
||||
// keep working when the config is absent or malformed.
|
||||
if in.managed == nil && (in.profile != "" || in.direct == nil) {
|
||||
in.config, in.configErr = core.LoadOrNotConfigured()
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// credentialRoute names which source serves the selected account and tokens.
|
||||
type credentialRoute int
|
||||
|
||||
const (
|
||||
routeManaged credentialRoute = iota
|
||||
routeProfile
|
||||
routeDirectEnv
|
||||
routeConfigDefault
|
||||
)
|
||||
|
||||
// decision is decideIdentity's complete verdict. Nothing in it touched I/O.
|
||||
type decision struct {
|
||||
route credentialRoute
|
||||
selection IdentitySelection
|
||||
// profileAppID is set on routeProfile; app_id is plaintext and safe to
|
||||
// echo in the secret-invalid error.
|
||||
profileAppID string
|
||||
}
|
||||
|
||||
// decideIdentity holds every selection rule in one place: precedence
|
||||
// (managed > profile > direct env > config default), profile/direct-env
|
||||
// conflict detection, and error attribution. It is pure — same inputs, same
|
||||
// verdict — so the full selection matrix is table-testable without env vars
|
||||
// or config fixtures.
|
||||
func decideIdentity(in identityInputs) (decision, error) {
|
||||
// DirectCredentialEnv reports the direct env vars truthfully on every
|
||||
// route: Present always means "direct credential env vars are set".
|
||||
directEnv := DirectCredentialEnv{Present: len(in.directKeys) > 0, Keys: in.directKeys}
|
||||
if in.direct != nil {
|
||||
directEnv.AppID = in.direct.acct.AppID
|
||||
}
|
||||
switch {
|
||||
case in.managed != nil:
|
||||
return decision{route: routeManaged, selection: IdentitySelection{
|
||||
Source: SourceExtension(in.managed.source.Name()),
|
||||
DirectCredentialEnv: directEnv,
|
||||
}}, nil
|
||||
case in.profile != "":
|
||||
return decideProfile(in, directEnv)
|
||||
case in.directBlock != nil:
|
||||
return decision{}, newAppCredentialIncompleteError(in.directBlock, false)
|
||||
case in.direct != nil:
|
||||
return decision{route: routeDirectEnv, selection: IdentitySelection{
|
||||
Source: SourceEnvAppID,
|
||||
DirectCredentialEnv: directEnv,
|
||||
}}, nil
|
||||
default:
|
||||
return decision{route: routeConfigDefault, selection: IdentitySelection{
|
||||
Source: selectionSourceForDefault(in.config),
|
||||
DirectCredentialEnv: directEnv,
|
||||
}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// decideProfile arbitrates an explicit profile against the direct env
|
||||
// credential state.
|
||||
func decideProfile(in identityInputs, directEnv DirectCredentialEnv) (decision, error) {
|
||||
app, err := findProfile(in)
|
||||
if err != nil {
|
||||
return decision{}, err
|
||||
}
|
||||
if in.directBlock != nil {
|
||||
// APP_ID-only is sufficient to compare sources: a matching selected
|
||||
// profile supplies the credential and tokens; a mismatch is the same
|
||||
// hard conflict as a complete direct env. Anything less than a usable
|
||||
// app_id keeps the provider's repair error, extended with the
|
||||
// unset-to-use-the-profile path.
|
||||
if in.directBlock.AppID == "" || !slices.Contains(in.directBlock.PresentKeys, envvars.CliAppID) {
|
||||
return decision{}, newAppCredentialIncompleteError(in.directBlock, true)
|
||||
}
|
||||
if app.AppId != in.directBlock.AppID {
|
||||
return decision{}, newProfileAppCredentialConflict(
|
||||
in.profile, app.AppId, in.directBlock.AppID, in.directBlock.PresentKeys)
|
||||
}
|
||||
directEnv.AppID = in.directBlock.AppID
|
||||
directEnv.Matched = true
|
||||
}
|
||||
if in.direct != nil {
|
||||
// E == complete: the direct env app_id must match the profile.
|
||||
if app.AppId != in.direct.acct.AppID {
|
||||
return decision{}, newProfileAppCredentialConflict(
|
||||
in.profile, app.AppId, in.direct.acct.AppID, in.conflictKeys)
|
||||
}
|
||||
directEnv.Matched = true
|
||||
}
|
||||
return decision{
|
||||
route: routeProfile,
|
||||
selection: IdentitySelection{Source: in.profileSrc, DirectCredentialEnv: directEnv},
|
||||
profileAppID: app.AppId,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// findProfile resolves the requested profile against the config snapshot.
|
||||
// A malformed config must surface its real typed cause (invalid_config):
|
||||
// reporting it as profile_not_found would send the user to `profile list`
|
||||
// and hide the broken file. Only a genuinely absent config degrades to
|
||||
// profile_not_found, because the profile then cannot exist anywhere. Both
|
||||
// deliberately outrank an incomplete direct env: fixing the profile side is
|
||||
// what makes the selected profile usable.
|
||||
func findProfile(in identityInputs) (*core.AppConfig, error) {
|
||||
if in.configErr != nil {
|
||||
if prob, ok := errs.ProblemOf(in.configErr); !ok || prob.Subtype != errs.SubtypeNotConfigured {
|
||||
return nil, in.configErr
|
||||
p.selectedSource = source
|
||||
return internal, nil
|
||||
}
|
||||
}
|
||||
if in.config != nil {
|
||||
if app := in.config.FindApp(in.profile); app != nil {
|
||||
return app, nil
|
||||
}
|
||||
}
|
||||
return nil, errs.NewConfigError(errs.SubtypeProfileNotFound,
|
||||
"profile %q not found", in.profile).
|
||||
WithProfile(in.profile).
|
||||
WithCredentialSource(string(in.profileSrc)).
|
||||
WithHint("run `lark-cli profile list` to see available profiles.")
|
||||
}
|
||||
|
||||
// execute performs the remaining I/O for the decided route and returns the
|
||||
// account together with its token source.
|
||||
func (p *CredentialProvider) execute(ctx context.Context, d decision, in identityInputs) (*Account, credentialSource, error) {
|
||||
switch d.route {
|
||||
case routeManaged:
|
||||
return in.managed.acct, in.managed.source, nil
|
||||
case routeDirectEnv:
|
||||
return in.direct.acct, in.direct.source, nil
|
||||
case routeProfile:
|
||||
// Resolve the profile's own (keychain-backed) credential locally.
|
||||
if p.defaultAcct != nil {
|
||||
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
// A typed failure other than not_configured carries its own
|
||||
// precise, secret-free diagnosis (typed errors never embed secret
|
||||
// material per the error contract) — pass it through instead of
|
||||
// flattening it into the generic secret error. Untyped failures
|
||||
// and a config that vanished mid-resolution stay masked: their
|
||||
// content is not guaranteed secret-free.
|
||||
if prob, ok := errs.ProblemOf(err); ok && prob.Subtype != errs.SubtypeNotConfigured {
|
||||
return nil, nil, err
|
||||
}
|
||||
return nil, nil, newProfileSecretInvalidError(in.profile, d.profileAppID)
|
||||
return nil, err
|
||||
}
|
||||
// The resolver re-reads the config; a concurrent profile edit between
|
||||
// gather and here could hand back a different app. Refuse the mismatch
|
||||
// instead of silently using credentials the arbitration never checked.
|
||||
if acct.AppID != d.profileAppID {
|
||||
return nil, nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"config changed during resolution: profile %q resolved to a different app", in.profile).
|
||||
WithHint("retry the command.")
|
||||
}
|
||||
return acct, defaultTokenSource{resolver: p.defaultToken}, nil
|
||||
default: // routeConfigDefault
|
||||
if p.defaultAcct == nil {
|
||||
return nil, nil, core.NotConfiguredError()
|
||||
}
|
||||
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, translateConfigDefaultFailure(err, in.config)
|
||||
}
|
||||
return acct, defaultTokenSource{resolver: p.defaultToken}, nil
|
||||
p.selectedSource = defaultTokenSource{resolver: p.defaultToken}
|
||||
return acct, nil
|
||||
}
|
||||
}
|
||||
|
||||
// translateConfigDefaultFailure attributes a config-default failure from the
|
||||
// snapshot: a default profile that EXISTS (has an app_id) but whose secret
|
||||
// cannot be resolved locally is profile_secret_invalid — "identity is
|
||||
// configured, its secret is broken" is more actionable than "no active
|
||||
// profile". Only when there is genuinely no usable default profile do we
|
||||
// report no_active_profile. Other typed failures pass through unchanged.
|
||||
func translateConfigDefaultFailure(err error, multi *core.MultiAppConfig) error {
|
||||
if prob, ok := errs.ProblemOf(err); !ok || prob.Subtype != errs.SubtypeNotConfigured {
|
||||
return err
|
||||
}
|
||||
if multi != nil {
|
||||
if app := multi.CurrentAppConfig(""); app != nil && app.AppId != "" {
|
||||
return newProfileSecretInvalidError(app.ProfileName(), app.AppId)
|
||||
}
|
||||
}
|
||||
return errs.NewConfigError(errs.SubtypeNoActiveProfile, "no active profile").
|
||||
WithCredentialSource(noActiveProfileCredentialSource).
|
||||
WithHint("run `lark-cli config init` / `lark-cli profile add`, or set %s.", envvars.CliProfile)
|
||||
}
|
||||
|
||||
func newProfileAppCredentialConflict(profile, profileAppID, envAppID string, presentKeys []string) error {
|
||||
err := errs.NewValidationError(errs.SubtypeProfileAppCredentialConflict,
|
||||
"profile %q app_id does not match %s", profile, envvars.CliAppID).
|
||||
WithProfileAppConflict(profileAppID, envAppID)
|
||||
if len(presentKeys) > 0 {
|
||||
return err.WithHint("unset %s, or select a profile whose app_id matches the environment.",
|
||||
humanList(presentKeys, "and"))
|
||||
}
|
||||
return err.WithHint("unset the direct credential environment variables, or select a profile whose app_id matches the environment.")
|
||||
}
|
||||
|
||||
func newAppCredentialIncompleteError(blockErr *extcred.BlockError, selectedProfileAvailable bool) *errs.ConfigError {
|
||||
err := errs.NewConfigError(errs.SubtypeAppCredentialIncomplete, "%s", blockErr.Reason).
|
||||
WithCause(blockErr)
|
||||
if len(blockErr.MissingKeys) > 0 {
|
||||
err.WithMissingKeys(blockErr.MissingKeys...)
|
||||
}
|
||||
if len(blockErr.RequiredAnyOf) > 0 {
|
||||
err.WithRequiredAnyOf(blockErr.RequiredAnyOf...)
|
||||
}
|
||||
|
||||
hint := credentialRepairHint(blockErr)
|
||||
if selectedProfileAvailable && len(blockErr.PresentKeys) > 0 {
|
||||
hint += fmt.Sprintf(", or unset %s to use the selected profile", humanList(blockErr.PresentKeys, "and"))
|
||||
}
|
||||
return err.WithHint("%s.", hint)
|
||||
}
|
||||
|
||||
func credentialRepairHint(blockErr *extcred.BlockError) string {
|
||||
if len(blockErr.RequiredAnyOf) > 0 {
|
||||
return "set " + humanList(blockErr.RequiredAnyOf, "or")
|
||||
}
|
||||
return "set " + humanList(blockErr.MissingKeys, "and")
|
||||
}
|
||||
|
||||
func humanList(items []string, conjunction string) string {
|
||||
switch len(items) {
|
||||
case 0:
|
||||
return "the missing direct credential variables"
|
||||
case 1:
|
||||
return items[0]
|
||||
case 2:
|
||||
return items[0] + " " + conjunction + " " + items[1]
|
||||
default:
|
||||
return strings.Join(items[:len(items)-1], ", ") + ", " + conjunction + " " + items[len(items)-1]
|
||||
}
|
||||
}
|
||||
|
||||
// newInvalidPolicyError translates a provider's invalid-policy block into the
|
||||
// typed validation contract: the failed variable name travels in param, the
|
||||
// repair path in the hint, and the original block stays on the cause chain.
|
||||
// Reason carries only the variable name and its non-secret value.
|
||||
func newInvalidPolicyError(blockErr *extcred.BlockError) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", blockErr.Reason).
|
||||
WithParam(blockErr.Param).
|
||||
WithCause(blockErr).
|
||||
WithHint("set %s to a supported value or unset it.", blockErr.Param)
|
||||
}
|
||||
|
||||
func newCredentialIncompleteProviderContractError(prov extcred.Provider) error {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"credential provider %q returned credential_incomplete, which is reserved for the builtin env provider", prov.Name())
|
||||
}
|
||||
|
||||
// newProfileSecretInvalidError is deliberately generic (SECURITY): the
|
||||
// underlying cause may carry secret material, so neither it nor its message
|
||||
// may reach the envelope. app_id is plaintext and safe to echo.
|
||||
func newProfileSecretInvalidError(profile, appID string) error {
|
||||
return errs.NewConfigError(errs.SubtypeProfileSecretInvalid,
|
||||
"profile %q credential could not be resolved locally", profile).
|
||||
WithProfile(profile).
|
||||
WithAppID(appID).
|
||||
WithHint("verify the profile's app secret or re-add the profile with `lark-cli config`.")
|
||||
}
|
||||
|
||||
// enrichOrClearIdentity verifies a provider-supplied user identity via
|
||||
// enrichUserInfo. Verification failure is non-fatal — SupportedIdentities
|
||||
// (used for strict mode) is already set by the provider — but an unverified
|
||||
// identity must not survive it: a stale OpenID would attribute calls to a
|
||||
// user the token can no longer act for.
|
||||
func (p *CredentialProvider) enrichOrClearIdentity(ctx context.Context, acct *Account, source credentialSource) {
|
||||
err := p.enrichUserInfo(ctx, acct, source)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if p.warnOut != nil {
|
||||
_, _ = fmt.Fprintf(p.warnOut, "warning: unable to verify user identity from credential source %q: %v\n", source.Name(), err)
|
||||
}
|
||||
acct.UserOpenId = ""
|
||||
acct.UserName = ""
|
||||
}
|
||||
|
||||
// noActiveProfileCredentialSource is the credential_source reported on the
|
||||
// no_active_profile error. The error contract fixes this to the literal "config": there is
|
||||
// no resolved default profile at all, so the more specific config:currentApp /
|
||||
// config:firstApp source values (used on successful config-default selections)
|
||||
// would be misleading. It is an enum string, never a secret.
|
||||
const noActiveProfileCredentialSource = "config"
|
||||
|
||||
// selectionSourceForDefault reports whether the config default resolved to the
|
||||
// explicit currentApp or fell back to the first app.
|
||||
func selectionSourceForDefault(multi *core.MultiAppConfig) CredentialSourceKind {
|
||||
if multi != nil && multi.CurrentApp != "" {
|
||||
return SourceConfigCurrentApp
|
||||
}
|
||||
return SourceConfigFirstApp
|
||||
}
|
||||
|
||||
// presentDirectCredentialKeys returns the NAMES (never values) of the direct
|
||||
// app credential env vars that are set. Used to annotate DirectCredentialEnv.
|
||||
func presentDirectCredentialKeys() []string {
|
||||
var keys []string
|
||||
if os.Getenv(envvars.CliAppID) != "" {
|
||||
keys = append(keys, envvars.CliAppID)
|
||||
}
|
||||
if os.Getenv(envvars.CliAppSecret) != "" {
|
||||
keys = append(keys, envvars.CliAppSecret)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// presentDirectCredentialInputKeys returns all direct env input names that
|
||||
// must be cleared together to remove a profile/app_id conflict. Values are
|
||||
// never returned.
|
||||
func presentDirectCredentialInputKeys() []string {
|
||||
keys := presentDirectCredentialKeys()
|
||||
if os.Getenv(envvars.CliUserAccessToken) != "" {
|
||||
keys = append(keys, envvars.CliUserAccessToken)
|
||||
}
|
||||
if os.Getenv(envvars.CliTenantAccessToken) != "" {
|
||||
keys = append(keys, envvars.CliTenantAccessToken)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// Selection resolves the account (once) and returns the cached, secret-free
|
||||
// explanation of how the credential/App was selected. It mirrors
|
||||
// selectedCredentialSource: resolve-then-return.
|
||||
func (p *CredentialProvider) Selection(ctx context.Context) (IdentitySelection, error) {
|
||||
if _, err := p.ResolveAccount(ctx); err != nil {
|
||||
return IdentitySelection{}, err
|
||||
}
|
||||
return p.selection, nil
|
||||
return nil, core.NotConfiguredError()
|
||||
}
|
||||
|
||||
// enrichUserInfo resolves user identity when extension provides a UAT.
|
||||
@@ -728,13 +239,17 @@ func (p *CredentialProvider) enrichUserInfo(ctx context.Context, acct *Account,
|
||||
}
|
||||
|
||||
func (p *CredentialProvider) selectedCredentialSource(ctx context.Context) (credentialSource, error) {
|
||||
if _, err := p.resolveAccountSelection(ctx); err != nil {
|
||||
if p.selectedSource != nil {
|
||||
return p.selectedSource, nil
|
||||
}
|
||||
if p.defaultAcct == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if _, err := p.ResolveAccount(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.selectedSource == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"credential provider resolved an account without selecting a token source").
|
||||
WithHint("retry the command.")
|
||||
return nil, fmt.Errorf("credential provider resolved an account without selecting a token source")
|
||||
}
|
||||
return p.selectedSource, nil
|
||||
}
|
||||
@@ -787,88 +302,51 @@ func (p *CredentialProvider) doResolveIdentityHint(ctx context.Context) (*Identi
|
||||
|
||||
// ResolveToken resolves an access token.
|
||||
func (p *CredentialProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
acct, err := p.resolveAccountSelection(ctx)
|
||||
source, err := p.selectedCredentialSource(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if acct == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"credential provider resolved no account before %s token resolution", req.Type).
|
||||
WithHint("retry the command.")
|
||||
if source != nil {
|
||||
return resolveTokenFromSource(ctx, source, req)
|
||||
}
|
||||
source := p.selectedSource
|
||||
if source == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"credential provider resolved app %q without selecting a token source", acct.AppID).
|
||||
WithHint("retry the command.")
|
||||
|
||||
for _, prov := range p.providers {
|
||||
source := extensionTokenSource{provider: prov}
|
||||
result, found, err := source.TryResolveToken(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
if req.AppID == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"TokenSpec.AppID is required for %s token resolution", req.Type).
|
||||
WithHint("retry the command.")
|
||||
source = defaultTokenSource{resolver: p.defaultToken}
|
||||
result, found, err := source.TryResolveToken(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.AppID != acct.AppID {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"token requested for app %q but the selected account belongs to app %q", req.AppID, acct.AppID).
|
||||
WithHint("retry the command.")
|
||||
if found {
|
||||
return result, nil
|
||||
}
|
||||
return resolveTokenFromSource(ctx, source, req)
|
||||
return nil, &TokenUnavailableError{Type: req.Type}
|
||||
}
|
||||
|
||||
// ActiveExtensionProviderName reports whether an extension provider is managing
|
||||
// the credentials that actually win selection. With an explicit profile that
|
||||
// resolves successfully it reuses ResolveAccount's cached arbitration result;
|
||||
// otherwise it probes extension providers directly and returns the first
|
||||
// engaged provider.
|
||||
// credentials. It probes p.providers (extension providers only, not defaultAcct)
|
||||
// and returns the name of the first engaged provider.
|
||||
//
|
||||
// "Engaged" means: ResolveAccount returns a non-nil account, OR returns a
|
||||
// *extcred.BlockError (provider configured but misconfigured — still counts as
|
||||
// external). Any other probe error is propagated to the caller.
|
||||
//
|
||||
// A failed profile resolution (profile not found, broken secret, malformed
|
||||
// config, incomplete direct env, ...) deliberately does NOT propagate: this
|
||||
// probe guards the builtin setup/repair commands (auth, config), and an
|
||||
// unresolvable credential must never lock the user out of the commands that
|
||||
// fix it. It falls back to the engagement probe, which answers the only
|
||||
// question this function owns: is an extension provider holding credentials?
|
||||
// external). Any other error is propagated to the caller.
|
||||
//
|
||||
// Returns ("", nil) when no extension provider is active (built-in keychain path).
|
||||
// Safe to call multiple times: explicit-profile resolution uses sync.Once, while
|
||||
// the probe path only consults providers.
|
||||
// Safe to call multiple times — probes providers directly without the sync.Once cache.
|
||||
func (p *CredentialProvider) ActiveExtensionProviderName(ctx context.Context) (string, error) {
|
||||
// With an explicit profile, report the source that actually won the same
|
||||
// arbitration used by commands. A matching APP_ID-only env block is not an
|
||||
// external takeover once the selected profile supplies credentials/tokens.
|
||||
if p.profile != "" {
|
||||
if _, err := p.ResolveAccount(ctx); err == nil {
|
||||
if p.selectedSource == nil {
|
||||
return "", nil
|
||||
}
|
||||
if _, builtin := p.selectedSource.(defaultTokenSource); builtin {
|
||||
return "", nil
|
||||
}
|
||||
return p.selectedSource.Name(), nil
|
||||
}
|
||||
// Resolution failed — fall through to the engagement probe.
|
||||
}
|
||||
for _, prov := range p.providers {
|
||||
acct, err := prov.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
var blockErr *extcred.BlockError
|
||||
if errors.As(err, &blockErr) {
|
||||
// Align with formal arbitration: a misconfigured policy
|
||||
// variable is the same typed validation error everywhere —
|
||||
// not an external takeover of the provider that reported it,
|
||||
// and not license to keep scanning and blame a later
|
||||
// provider instead.
|
||||
if blockErr.Code == extcred.BlockReasonInvalidPolicy {
|
||||
return "", newInvalidPolicyError(blockErr)
|
||||
}
|
||||
if blockErr.Code == extcred.BlockReasonCredentialIncomplete {
|
||||
if _, builtin := prov.(*envprovider.Provider); !builtin {
|
||||
return "", newCredentialIncompleteProviderContractError(prov)
|
||||
}
|
||||
}
|
||||
name := blockErr.Provider
|
||||
if name == "" {
|
||||
name = prov.Name()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -24,7 +23,6 @@ type mockExtProvider struct {
|
||||
err error
|
||||
accountErr error
|
||||
tokenErr error
|
||||
tokenCalls int
|
||||
}
|
||||
|
||||
func (m *mockExtProvider) Name() string { return m.name }
|
||||
@@ -35,7 +33,6 @@ func (m *mockExtProvider) ResolveAccount(ctx context.Context) (*extcred.Account,
|
||||
return m.account, m.err
|
||||
}
|
||||
func (m *mockExtProvider) ResolveToken(ctx context.Context, req extcred.TokenSpec) (*extcred.Token, error) {
|
||||
m.tokenCalls++
|
||||
if m.tokenErr != nil {
|
||||
return nil, m.tokenErr
|
||||
}
|
||||
@@ -52,13 +49,11 @@ func (m *mockDefaultAcct) ResolveAccount(ctx context.Context) (*Account, error)
|
||||
}
|
||||
|
||||
type mockDefaultToken struct {
|
||||
result *TokenResult
|
||||
err error
|
||||
tokenCalls int
|
||||
result *TokenResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockDefaultToken) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
m.tokenCalls++
|
||||
return m.result, m.err
|
||||
}
|
||||
|
||||
@@ -121,45 +116,35 @@ func TestCredentialProvider_AccountCached(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCredentialProvider_TokenFromExtension(t *testing.T) {
|
||||
for _, sourceName := range []string{"env", "authsidecar"} {
|
||||
t.Run(sourceName, func(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{
|
||||
name: sourceName,
|
||||
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
|
||||
token: &extcred.Token{Value: "ext_tok", Source: sourceName},
|
||||
}},
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
|
||||
)
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Token != "ext_tok" {
|
||||
t.Errorf("expected ext_tok, got %s", result.Token)
|
||||
}
|
||||
})
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{
|
||||
name: "env",
|
||||
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
|
||||
token: &extcred.Token{Value: "ext_tok", Source: "env"},
|
||||
}},
|
||||
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
|
||||
)
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Token != "ext_tok" {
|
||||
t.Errorf("expected ext_tok, got %s", result.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_TokenFallsToDefault(t *testing.T) {
|
||||
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{&mockExtProvider{name: "skip"}},
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
defaultToken, nil,
|
||||
&mockDefaultAcct{}, &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}, nil,
|
||||
)
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Token != "default_tok" {
|
||||
t.Errorf("expected default_tok, got %s", result.Token)
|
||||
}
|
||||
if defaultToken.tokenCalls != 1 {
|
||||
t.Fatalf("default ResolveToken() calls = %d, want 1", defaultToken.tokenCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_TokenDoesNotMixSourcesAfterDefaultAccountSelection(t *testing.T) {
|
||||
@@ -174,7 +159,7 @@ func TestCredentialProvider_TokenDoesNotMixSourcesAfterDefaultAccountSelection(t
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveToken() error = %v", err)
|
||||
}
|
||||
@@ -196,7 +181,7 @@ func TestCredentialProvider_SelectedSourceWithoutTokenReturnsUnavailableError(t
|
||||
t.Fatalf("ResolveAccount() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveToken() error = nil, want unavailable error")
|
||||
}
|
||||
@@ -217,7 +202,7 @@ func TestCredentialProvider_ResolveTokenPropagatesNonBlockExtensionError(t *test
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err == nil || err.Error() != "provider exploded" {
|
||||
t.Fatalf("ResolveToken() error = %v, want provider exploded", err)
|
||||
}
|
||||
@@ -327,12 +312,12 @@ func TestCredentialProvider_ResolveIdentityHint_CachesResult(t *testing.T) {
|
||||
func TestCredentialProvider_ResolveTokenTreatsEmptyDefaultTokenAsMalformed(t *testing.T) {
|
||||
cp := NewCredentialProvider(
|
||||
nil,
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
nil,
|
||||
&mockDefaultToken{result: &TokenResult{Token: ""}},
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err == nil || !strings.Contains(err.Error(), "empty token") {
|
||||
t.Fatalf("ResolveToken() error = %v, want malformed empty token error", err)
|
||||
}
|
||||
@@ -425,189 +410,17 @@ func TestCredentialProvider_ResolveAccountWarnsWhenExtensionIdentityVerification
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenDoesNotBypassFailedDefaultAccountResolution(t *testing.T) {
|
||||
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
|
||||
cp := NewCredentialProvider(
|
||||
nil,
|
||||
&mockDefaultAcct{err: errors.New("config unavailable")},
|
||||
defaultToken,
|
||||
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "default_app"})
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT})
|
||||
if err == nil || err.Error() != "config unavailable" {
|
||||
t.Fatalf("ResolveToken() error = %v, want config unavailable", err)
|
||||
}
|
||||
if defaultToken.tokenCalls != 0 {
|
||||
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenRejectsUnboundAppBeforeExtensionIO(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appID string
|
||||
}{
|
||||
{name: "empty app id"},
|
||||
{name: "different app id", appID: "other_app"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
for _, sourceName := range []string{"env", "authsidecar"} {
|
||||
t.Run(tt.name+"/"+sourceName, func(t *testing.T) {
|
||||
provider := &mockExtProvider{
|
||||
name: sourceName,
|
||||
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
|
||||
token: &extcred.Token{Value: "ext_tok", Source: sourceName},
|
||||
}
|
||||
httpClientCalls := 0
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{provider},
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
&mockDefaultToken{result: &TokenResult{Token: "default_tok"}},
|
||||
func() (*http.Client, error) {
|
||||
httpClientCalls++
|
||||
return nil, errors.New("unexpected user_info call")
|
||||
},
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: tt.appID})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveToken() error = nil, want app binding error")
|
||||
}
|
||||
assertInternalUnknownWithRetryHint(t, err)
|
||||
if provider.tokenCalls != 0 {
|
||||
t.Fatalf("extension ResolveToken() calls = %d, want 0", provider.tokenCalls)
|
||||
}
|
||||
if httpClientCalls != 0 {
|
||||
t.Fatalf("httpClient() calls = %d, want 0", httpClientCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenRejectsUnboundAppBeforeDefaultIO(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appID string
|
||||
}{
|
||||
{name: "empty app id"},
|
||||
{name: "different app id", appID: "other_app"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
|
||||
cp := NewCredentialProvider(
|
||||
nil,
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
defaultToken,
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: tt.appID})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveToken() error = nil, want app binding error")
|
||||
}
|
||||
assertInternalUnknownWithRetryHint(t, err)
|
||||
if defaultToken.tokenCalls != 0 {
|
||||
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenRejectsNilAccountBeforeTokenIO(t *testing.T) {
|
||||
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
|
||||
cp := NewCredentialProvider(
|
||||
nil,
|
||||
&mockDefaultAcct{},
|
||||
defaultToken,
|
||||
nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "requested_app"})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveToken() error = nil, want nil account error")
|
||||
}
|
||||
assertInternalUnknownWithRetryHint(t, err)
|
||||
if defaultToken.tokenCalls != 0 {
|
||||
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenRejectsMissingSelectedSourceWithoutFallback(t *testing.T) {
|
||||
extension := &mockExtProvider{
|
||||
name: "env",
|
||||
token: &extcred.Token{Value: "ext_tok", Source: "env"},
|
||||
}
|
||||
defaultToken := &mockDefaultToken{result: &TokenResult{Token: "default_tok"}}
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{extension},
|
||||
&mockDefaultAcct{account: &Account{AppID: "default_app"}},
|
||||
defaultToken,
|
||||
nil,
|
||||
)
|
||||
cp.account = &Account{AppID: "selected_app"}
|
||||
cp.accountOnce.Do(func() {})
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "selected_app"})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveToken() error = nil, want missing selected source error")
|
||||
}
|
||||
assertInternalUnknownWithRetryHint(t, err)
|
||||
if extension.tokenCalls != 0 {
|
||||
t.Fatalf("extension ResolveToken() calls = %d, want 0", extension.tokenCalls)
|
||||
}
|
||||
if defaultToken.tokenCalls != 0 {
|
||||
t.Fatalf("default ResolveToken() calls = %d, want 0", defaultToken.tokenCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialProvider_ResolveTokenMatchingExtensionDoesNotEnrichIdentity(t *testing.T) {
|
||||
provider := &mockExtProvider{
|
||||
name: "env",
|
||||
account: &extcred.Account{AppID: "ext_app", Brand: "feishu"},
|
||||
token: &extcred.Token{Value: "ext_tok", Source: "env"},
|
||||
}
|
||||
httpClientCalls := 0
|
||||
cp := NewCredentialProvider(
|
||||
[]extcred.Provider{provider},
|
||||
nil,
|
||||
nil,
|
||||
func() (*http.Client, error) {
|
||||
httpClientCalls++
|
||||
return nil, errors.New("unexpected user_info call")
|
||||
},
|
||||
)
|
||||
|
||||
result, err := cp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "ext_app"})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveToken() error = %v", err)
|
||||
}
|
||||
if result.Token != "ext_tok" {
|
||||
t.Fatalf("ResolveToken() token = %q, want %q", result.Token, "ext_tok")
|
||||
}
|
||||
if provider.tokenCalls != 1 {
|
||||
t.Fatalf("extension ResolveToken() calls = %d, want 1", provider.tokenCalls)
|
||||
}
|
||||
if httpClientCalls != 0 {
|
||||
t.Fatalf("httpClient() calls = %d, want 0", httpClientCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func assertInternalUnknownWithRetryHint(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want typed internal error", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("error problem = %+v, want internal/unknown", problem)
|
||||
}
|
||||
if problem.Hint != "retry the command." {
|
||||
t.Fatalf("error hint = %q, want retry hint", problem.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveExtensionProviderName_ExtActive(t *testing.T) {
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
// stubDecideProvider satisfies extcred.Provider for building providerAccount
|
||||
// literals; decideIdentity only ever calls Name() on it.
|
||||
type stubDecideProvider struct{ name string }
|
||||
|
||||
func (s stubDecideProvider) Name() string { return s.name }
|
||||
func (s stubDecideProvider) Priority() int { return 0 }
|
||||
func (s stubDecideProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s stubDecideProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func pa(providerName, appID string) *providerAccount {
|
||||
return &providerAccount{
|
||||
acct: &Account{AppID: appID},
|
||||
source: extensionTokenSource{provider: stubDecideProvider{name: providerName}},
|
||||
}
|
||||
}
|
||||
|
||||
func appIDOnlyBlock(appID string) *extcred.BlockError {
|
||||
return &extcred.BlockError{
|
||||
Provider: "env",
|
||||
Reason: envvars.CliAppID + " is set but no app secret or access token is available",
|
||||
Code: extcred.BlockReasonCredentialIncomplete,
|
||||
RequiredAnyOf: []string{envvars.CliAppSecret, envvars.CliUserAccessToken, envvars.CliTenantAccessToken},
|
||||
PresentKeys: []string{envvars.CliAppID},
|
||||
AppID: appID,
|
||||
}
|
||||
}
|
||||
|
||||
func uatOnlyBlock() *extcred.BlockError {
|
||||
return &extcred.BlockError{
|
||||
Provider: "env",
|
||||
Reason: envvars.CliUserAccessToken + " is set but " + envvars.CliAppID + " is missing",
|
||||
Code: extcred.BlockReasonCredentialIncomplete,
|
||||
MissingKeys: []string{envvars.CliAppID},
|
||||
PresentKeys: []string{envvars.CliUserAccessToken},
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecideIdentity exercises the selection matrix as data: decideIdentity is
|
||||
// pure, so every rule (precedence, conflict detection, error attribution) is
|
||||
// table-testable without env vars or config fixtures.
|
||||
func TestDecideIdentity(t *testing.T) {
|
||||
tenantA := &core.MultiAppConfig{
|
||||
CurrentApp: "tenant_a",
|
||||
Apps: []core.AppConfig{{Name: "tenant_a", AppId: "cli_a"}},
|
||||
}
|
||||
noCurrent := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{Name: "tenant_a", AppId: "cli_a"}},
|
||||
}
|
||||
invalidConfigErr := errs.NewConfigError(errs.SubtypeInvalidConfig, "invalid config format")
|
||||
notConfiguredErr := core.NotConfiguredError()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in identityInputs
|
||||
route credentialRoute
|
||||
source CredentialSourceKind
|
||||
matched bool
|
||||
subtype errs.Subtype // "" = success expected
|
||||
}{
|
||||
{
|
||||
name: "managed provider wins over explicit profile",
|
||||
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, managed: pa("sidecar", "sidecar_app"), config: tenantA},
|
||||
route: routeManaged,
|
||||
source: SourceExtension("sidecar"),
|
||||
},
|
||||
{
|
||||
name: "profile conflicts with complete direct env app_id",
|
||||
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, direct: pa("env", "cli_x"), directKeys: []string{envvars.CliAppID, envvars.CliAppSecret}, config: tenantA},
|
||||
subtype: errs.SubtypeProfileAppCredentialConflict,
|
||||
},
|
||||
{
|
||||
name: "matched complete direct env yields profile route",
|
||||
in: identityInputs{profile: "tenant_a", profileSrc: SourceEnvProfile, direct: pa("env", "cli_a"), directKeys: []string{envvars.CliAppID, envvars.CliAppSecret}, config: tenantA},
|
||||
route: routeProfile,
|
||||
source: SourceEnvProfile,
|
||||
matched: true,
|
||||
},
|
||||
{
|
||||
name: "APP_ID-only block matching the profile yields profile route",
|
||||
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, directBlock: appIDOnlyBlock("cli_a"), directKeys: []string{envvars.CliAppID}, config: tenantA},
|
||||
route: routeProfile,
|
||||
source: SourceFlagProfile,
|
||||
matched: true,
|
||||
},
|
||||
{
|
||||
name: "APP_ID-only block mismatching the profile is a hard conflict",
|
||||
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, directBlock: appIDOnlyBlock("cli_x"), directKeys: []string{envvars.CliAppID}, config: tenantA},
|
||||
subtype: errs.SubtypeProfileAppCredentialConflict,
|
||||
},
|
||||
{
|
||||
name: "UAT-only block with a valid profile keeps the repair error",
|
||||
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, directBlock: uatOnlyBlock(), config: tenantA},
|
||||
subtype: errs.SubtypeAppCredentialIncomplete,
|
||||
},
|
||||
{
|
||||
name: "block without profile is app_credential_incomplete",
|
||||
in: identityInputs{directBlock: appIDOnlyBlock("cli_a"), directKeys: []string{envvars.CliAppID}},
|
||||
subtype: errs.SubtypeAppCredentialIncomplete,
|
||||
},
|
||||
{
|
||||
name: "complete direct env without profile wins",
|
||||
in: identityInputs{direct: pa("env", "cli_env"), directKeys: []string{envvars.CliAppID, envvars.CliAppSecret}},
|
||||
route: routeDirectEnv,
|
||||
source: SourceEnvAppID,
|
||||
},
|
||||
{
|
||||
name: "malformed config is not masked as profile_not_found",
|
||||
in: identityInputs{profile: "tenant_a", profileSrc: SourceFlagProfile, configErr: invalidConfigErr},
|
||||
subtype: errs.SubtypeInvalidConfig,
|
||||
},
|
||||
{
|
||||
name: "absent config degrades to profile_not_found",
|
||||
in: identityInputs{profile: "ghost", profileSrc: SourceEnvProfile, configErr: notConfiguredErr},
|
||||
subtype: errs.SubtypeProfileNotFound,
|
||||
},
|
||||
{
|
||||
name: "profile missing from a valid config is profile_not_found even with incomplete env",
|
||||
in: identityInputs{profile: "ghost", profileSrc: SourceEnvProfile, directBlock: appIDOnlyBlock("cli_a"), directKeys: []string{envvars.CliAppID}, config: tenantA},
|
||||
subtype: errs.SubtypeProfileNotFound,
|
||||
},
|
||||
{
|
||||
name: "config default reports currentApp",
|
||||
in: identityInputs{config: tenantA},
|
||||
route: routeConfigDefault,
|
||||
source: SourceConfigCurrentApp,
|
||||
},
|
||||
{
|
||||
name: "config default without currentApp reports firstApp",
|
||||
in: identityInputs{config: noCurrent},
|
||||
route: routeConfigDefault,
|
||||
source: SourceConfigFirstApp,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
d, err := decideIdentity(tc.in)
|
||||
if tc.subtype != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("decideIdentity = %+v, want error subtype %q", d, tc.subtype)
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok || prob.Subtype != tc.subtype {
|
||||
t.Fatalf("error = %v, want subtype %q", err, tc.subtype)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("decideIdentity: %v", err)
|
||||
}
|
||||
if d.route != tc.route {
|
||||
t.Errorf("route = %d, want %d", d.route, tc.route)
|
||||
}
|
||||
if d.selection.Source != tc.source {
|
||||
t.Errorf("source = %q, want %q", d.selection.Source, tc.source)
|
||||
}
|
||||
if d.selection.DirectCredentialEnv.Matched != tc.matched {
|
||||
t.Errorf("matched = %v, want %v", d.selection.DirectCredentialEnv.Matched, tc.matched)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -74,13 +74,9 @@ func NewDefaultAccountProvider(kc func() keychain.KeychainAccess, profile string
|
||||
|
||||
func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account, error) {
|
||||
// Load config once — used for both credentials and strict mode.
|
||||
// LoadOrNotConfigured distinguishes an absent config (→ not_configured)
|
||||
// from a malformed/unreadable one (→ invalid_config with cause), so a
|
||||
// broken config is never masked as "run config init" — matching the
|
||||
// explicit-profile path in doResolveAccount.
|
||||
multi, err := core.LoadOrNotConfigured()
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, core.NotConfiguredError()
|
||||
}
|
||||
|
||||
cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile)
|
||||
@@ -120,7 +116,6 @@ type DefaultTokenProvider struct {
|
||||
|
||||
tatOnce sync.Once
|
||||
tatResult *TokenResult
|
||||
tatAppID string
|
||||
tatErr error
|
||||
}
|
||||
|
||||
@@ -131,42 +126,21 @@ func NewDefaultTokenProvider(defaultAcct *DefaultAccountProvider, httpClient fun
|
||||
func (p *DefaultTokenProvider) ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
switch req.Type {
|
||||
case TokenTypeUAT:
|
||||
return p.resolveUAT(ctx, req)
|
||||
return p.resolveUAT(ctx)
|
||||
case TokenTypeTAT:
|
||||
return p.resolveTAT(ctx, req)
|
||||
return p.resolveTAT(ctx)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported token type: %s", req.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// checkTokenAppID refuses to hand out a token for a different app than the
|
||||
// caller resolved. The token provider re-reads the config, so a concurrent
|
||||
// profile edit between account resolution and token resolution could otherwise
|
||||
// cross tokens between apps. TokenSpec.AppID is REQUIRED here: an empty value
|
||||
// would silently disable the guarantee, so it is rejected rather than skipped.
|
||||
func checkTokenAppID(req TokenSpec, resolvedAppID string) error {
|
||||
if req.AppID == "" {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"TokenSpec.AppID is required for %s token resolution", req.Type)
|
||||
}
|
||||
if req.AppID == resolvedAppID {
|
||||
return nil
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"config changed during resolution: token requested for app %q but the saved profile now resolves to a different app", req.AppID).
|
||||
WithHint("retry the command.")
|
||||
}
|
||||
|
||||
// resolveUAT resolves a user access token. Not cached (unlike TAT) because UAT
|
||||
// may be refreshed between calls and GetValidAccessToken handles its own caching.
|
||||
func (p *DefaultTokenProvider) resolveUAT(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, error) {
|
||||
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkTokenAppID(req, acct.AppID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient, err := p.httpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -183,36 +157,20 @@ func (p *DefaultTokenProvider) resolveUAT(ctx context.Context, req TokenSpec) (*
|
||||
return &TokenResult{Token: token, Scopes: scopes}, nil
|
||||
}
|
||||
|
||||
// resolveTAT resolves a tenant access token. The result is cached after the
|
||||
// first mint via sync.Once — only the context from that call is used.
|
||||
//
|
||||
// The account is resolved and checked against the request BEFORE any token
|
||||
// work: a mismatched request must not trigger a token mint (network call,
|
||||
// quota, audit trail) for the wrong app. The cached result is additionally
|
||||
// re-checked on every hit, so a token minted for one app is never served to
|
||||
// a request that resolved another.
|
||||
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context, req TokenSpec) (*TokenResult, error) {
|
||||
// resolveTAT resolves a tenant access token. The result is cached after the first
|
||||
// call via sync.Once — only the context from the first call is used.
|
||||
func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) {
|
||||
p.tatOnce.Do(func() {
|
||||
p.tatResult, p.tatErr = p.doResolveTAT(ctx)
|
||||
})
|
||||
return p.tatResult, p.tatErr
|
||||
}
|
||||
|
||||
func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult, error) {
|
||||
acct, err := p.defaultAcct.ResolveAccount(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkTokenAppID(req, acct.AppID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.tatOnce.Do(func() {
|
||||
p.tatResult, p.tatErr = p.doResolveTAT(ctx, acct)
|
||||
p.tatAppID = acct.AppID
|
||||
})
|
||||
if p.tatErr != nil {
|
||||
return nil, p.tatErr
|
||||
}
|
||||
if err := checkTokenAppID(req, p.tatAppID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.tatResult, nil
|
||||
}
|
||||
|
||||
func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context, acct *Account) (*TokenResult, error) {
|
||||
httpClient, err := p.httpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -4,15 +4,10 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestDefaultTokenProvider_Dispatches(t *testing.T) {
|
||||
@@ -97,136 +92,3 @@ func TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped(t *testing.T) {
|
||||
t.Fatalf("code-0 invalid_scope must not be a ConfigError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckTokenAppID(t *testing.T) {
|
||||
if err := checkTokenAppID(TokenSpec{Type: TokenTypeUAT}, "cli_a"); err == nil {
|
||||
t.Fatal("empty requested app must be rejected: it would silently disable the guarantee")
|
||||
}
|
||||
if err := checkTokenAppID(TokenSpec{AppID: "cli_a"}, "cli_a"); err != nil {
|
||||
t.Fatalf("matching app must pass: %v", err)
|
||||
}
|
||||
err := checkTokenAppID(TokenSpec{AppID: "cli_a"}, "cli_b")
|
||||
if err == nil {
|
||||
t.Fatal("mismatched app must be refused")
|
||||
}
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("error type = %T, want *errs.InternalError", err)
|
||||
}
|
||||
}
|
||||
|
||||
// REAL-path regression for review F2: the token provider re-reads the config,
|
||||
// so a profile edit between account resolution and token resolution must not
|
||||
// hand a token minted for the new app to a caller that resolved the old one.
|
||||
// Uses the real DefaultAccountProvider + DefaultTokenProvider; the HTTP stub
|
||||
// makes the network step unreachable, so reaching it proves the app check ran
|
||||
// and passed first.
|
||||
func TestDefaultTokenProvider_RefusesTokenAfterConfigSwap(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeCfg := func(appID string) {
|
||||
t.Helper()
|
||||
multi := &core.MultiAppConfig{CurrentApp: "tenant_a", Apps: []core.AppConfig{{
|
||||
Name: "tenant_a", AppId: appID, AppSecret: core.PlainSecret("your-secret"), Brand: core.BrandFeishu,
|
||||
}}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
}
|
||||
writeCfg("cli_a")
|
||||
|
||||
httpSentinel := errors.New("http client sentinel: unreachable in test")
|
||||
tp := NewDefaultTokenProvider(
|
||||
NewDefaultAccountProvider(nil, "tenant_a"),
|
||||
func() (*http.Client, error) { return nil, httpSentinel },
|
||||
nil,
|
||||
)
|
||||
|
||||
// Matching app: the consistency check passes and resolution proceeds to
|
||||
// the (stubbed) HTTP step.
|
||||
_, err := tp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "cli_a"})
|
||||
if !errors.Is(err, httpSentinel) {
|
||||
t.Fatalf("err = %v, want the HTTP sentinel (check must pass for a matching app)", err)
|
||||
}
|
||||
|
||||
// The profile now resolves to a different app: the token request that was
|
||||
// arbitrated for cli_a must be refused before any token work happens.
|
||||
writeCfg("cli_b")
|
||||
_, err = tp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeUAT, AppID: "cli_a"})
|
||||
if err == nil || !strings.Contains(err.Error(), "config changed during resolution") {
|
||||
t.Fatalf("err = %v, want config-changed refusal", err)
|
||||
}
|
||||
}
|
||||
|
||||
// F1 regression: a TAT request for a mismatched app must be refused BEFORE
|
||||
// any token work starts — no HTTP client construction, no mint, no cache —
|
||||
// otherwise the CLI mints (and caches) a token for the wrong app and only
|
||||
// then refuses to return it, leaving auth audit/quota side effects behind.
|
||||
func TestDefaultTokenProvider_TATChecksAppBeforeAnyTokenWork(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
multi := &core.MultiAppConfig{CurrentApp: "tenant_a", Apps: []core.AppConfig{{
|
||||
Name: "tenant_a", AppId: "cli_b", AppSecret: core.PlainSecret("your-secret"), Brand: core.BrandFeishu,
|
||||
}}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
|
||||
httpCalled := false
|
||||
tp := NewDefaultTokenProvider(
|
||||
NewDefaultAccountProvider(nil, "tenant_a"),
|
||||
func() (*http.Client, error) { httpCalled = true; return nil, errors.New("http sentinel") },
|
||||
nil,
|
||||
)
|
||||
|
||||
// The profile resolves to cli_b, but the caller arbitrated cli_a.
|
||||
_, err := tp.ResolveToken(context.Background(), TokenSpec{Type: TokenTypeTAT, AppID: "cli_a"})
|
||||
if err == nil || !strings.Contains(err.Error(), "config changed during resolution") {
|
||||
t.Fatalf("err = %v, want config-changed refusal", err)
|
||||
}
|
||||
if httpCalled {
|
||||
t.Fatal("token work started for a mismatched app: the check must run before any HTTP client is built")
|
||||
}
|
||||
}
|
||||
|
||||
// countingTATTripper serves a canned successful TAT response and counts calls.
|
||||
type countingTATTripper struct{ calls int }
|
||||
|
||||
func (c *countingTATTripper) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
c.calls++
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":0,"access_token":"your-access-token"}`)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TAT happy path: the first request mints the token over HTTP, the second is
|
||||
// served from the sync.Once cache without another HTTP call.
|
||||
func TestDefaultTokenProvider_TATSuccessAndCacheHit(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
multi := &core.MultiAppConfig{CurrentApp: "tenant_a", Apps: []core.AppConfig{{
|
||||
Name: "tenant_a", AppId: "cli_a", AppSecret: core.PlainSecret("your-secret"), Brand: core.BrandFeishu,
|
||||
}}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig: %v", err)
|
||||
}
|
||||
|
||||
tripper := &countingTATTripper{}
|
||||
tp := NewDefaultTokenProvider(
|
||||
NewDefaultAccountProvider(nil, "tenant_a"),
|
||||
func() (*http.Client, error) { return &http.Client{Transport: tripper}, nil },
|
||||
nil,
|
||||
)
|
||||
|
||||
req := TokenSpec{Type: TokenTypeTAT, AppID: "cli_a"}
|
||||
first, err := tp.ResolveToken(context.Background(), req)
|
||||
if err != nil || first.Token != "your-access-token" {
|
||||
t.Fatalf("first resolve = %+v, %v; want minted token", first, err)
|
||||
}
|
||||
second, err := tp.ResolveToken(context.Background(), req)
|
||||
if err != nil || second.Token != "your-access-token" {
|
||||
t.Fatalf("second resolve = %+v, %v; want cached token", second, err)
|
||||
}
|
||||
if tripper.calls != 1 {
|
||||
t.Fatalf("HTTP calls = %d, want exactly 1 (second resolve must hit the cache)", tripper.calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
// CredentialSourceKind is the wire-stable App/credential selection source.
|
||||
type CredentialSourceKind string
|
||||
|
||||
const (
|
||||
SourceFlagProfile CredentialSourceKind = "flag:--profile"
|
||||
SourceEnvProfile CredentialSourceKind = "env:LARKSUITE_CLI_PROFILE"
|
||||
SourceEnvAppID CredentialSourceKind = "env:LARKSUITE_CLI_APP_ID"
|
||||
SourceConfigCurrentApp CredentialSourceKind = "config:currentApp"
|
||||
SourceConfigFirstApp CredentialSourceKind = "config:firstApp"
|
||||
|
||||
// SourceExtensionPrefix prefixes the name of a managed extension provider
|
||||
// that won selection outright (e.g. "extension:sidecar"). With it, an
|
||||
// empty Source is left with exactly one meaning: not resolved.
|
||||
SourceExtensionPrefix CredentialSourceKind = "extension:"
|
||||
)
|
||||
|
||||
// SourceExtension reports the selection source for a managed extension
|
||||
// provider by name.
|
||||
func SourceExtension(name string) CredentialSourceKind {
|
||||
return SourceExtensionPrefix + CredentialSourceKind(name)
|
||||
}
|
||||
|
||||
// DirectCredentialEnv describes the state of direct app credential env vars.
|
||||
// It never carries a secret value — only names and the non-sensitive app_id.
|
||||
type DirectCredentialEnv struct {
|
||||
Present bool `json:"present"`
|
||||
Keys []string `json:"keys,omitempty"`
|
||||
AppID string `json:"appId,omitempty"`
|
||||
Matched bool `json:"matched,omitempty"`
|
||||
ConflictsWithProfile bool `json:"conflictsWithProfile,omitempty"`
|
||||
}
|
||||
|
||||
// IdentitySelection is the explainable result of credential selection.
|
||||
// It carries NO secret value.
|
||||
type IdentitySelection struct {
|
||||
Source CredentialSourceKind
|
||||
DirectCredentialEnv DirectCredentialEnv
|
||||
}
|
||||
|
||||
// Explicit reports whether the identity was actively specified by the
|
||||
// user/agent (flag or env), which governs no-fallback behavior.
|
||||
func (s IdentitySelection) Explicit() bool {
|
||||
switch s.Source {
|
||||
case SourceFlagProfile, SourceEnvProfile, SourceEnvAppID:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIdentitySelectionExplicit(t *testing.T) {
|
||||
cases := []struct {
|
||||
src CredentialSourceKind
|
||||
explicit bool
|
||||
}{
|
||||
{SourceFlagProfile, true},
|
||||
{SourceEnvProfile, true},
|
||||
{SourceEnvAppID, true},
|
||||
{SourceConfigCurrentApp, false},
|
||||
{SourceConfigFirstApp, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
sel := IdentitySelection{Source: c.src}
|
||||
if sel.Explicit() != c.explicit {
|
||||
t.Errorf("source %q: Explicit()=%v want %v", c.src, sel.Explicit(), c.explicit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,24 +52,6 @@ func TestFullChain_EnvWins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullChain_EnvRejectsDifferentApp(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "env_app")
|
||||
t.Setenv(envvars.CliAppSecret, "env_secret")
|
||||
t.Setenv(envvars.CliUserAccessToken, "env_uat")
|
||||
|
||||
cp := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&envprovider.Provider{}},
|
||||
nil, nil, nil,
|
||||
)
|
||||
|
||||
_, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
|
||||
Type: credential.TokenTypeUAT, AppID: "other_app",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ResolveToken() error = nil, want app binding error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullChain_Fallthrough(t *testing.T) {
|
||||
// env provider returns nil (no env vars set), falls through to default token
|
||||
ep := &envprovider.Provider{}
|
||||
@@ -77,8 +59,7 @@ func TestFullChain_Fallthrough(t *testing.T) {
|
||||
|
||||
cp := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{ep},
|
||||
&mockDefaultAccountProvider{account: &credential.Account{AppID: "app1"}},
|
||||
mock, nil,
|
||||
nil, mock, nil,
|
||||
)
|
||||
result, err := cp.ResolveToken(context.Background(), credential.TokenSpec{
|
||||
Type: credential.TokenTypeUAT, AppID: "app1",
|
||||
@@ -91,14 +72,6 @@ func TestFullChain_Fallthrough(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type mockDefaultAccountProvider struct {
|
||||
account *credential.Account
|
||||
}
|
||||
|
||||
func (m *mockDefaultAccountProvider) ResolveAccount(context.Context) (*credential.Account, error) {
|
||||
return m.account, nil
|
||||
}
|
||||
|
||||
type mockDefaultTokenProvider struct {
|
||||
token string
|
||||
scopes string
|
||||
|
||||
@@ -21,7 +21,6 @@ const (
|
||||
|
||||
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
|
||||
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
||||
CliProfile = "LARKSUITE_CLI_PROFILE"
|
||||
|
||||
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
||||
CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS"
|
||||
|
||||
@@ -16,16 +16,18 @@ func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "claude-code")
|
||||
if got := AgentName(); got != "claude-code" {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(CliAgentName, agentName)
|
||||
if got := AgentName(); got != agentName {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(CliAgentName, " cursor ")
|
||||
if got := AgentName(); got != "cursor" {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(CliAgentName, " "+agentName+" ")
|
||||
if got := AgentName(); got != agentName {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
192
internal/flagalias/flagalias.go
Normal file
192
internal/flagalias/flagalias.go
Normal file
@@ -0,0 +1,192 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package flagalias owns parse-time aliases for Cobra/pflag commands.
|
||||
//
|
||||
// An alias is another accepted spelling of one canonical flag. It is not a
|
||||
// second pflag: parsing an alias resolves to the canonical flag before pflag
|
||||
// applies the value, so type, default, Changed state, required/enum/input
|
||||
// contracts, and repeated-flag behavior all stay attached to one object.
|
||||
//
|
||||
// Value conversion for non-equivalent legacy inputs is a business compatibility
|
||||
// concern, not an alias. Exact aliases always use the canonical flag's native
|
||||
// occurrence semantics; domains must not add a separate conflict policy.
|
||||
package flagalias
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// AnnotationAliases is attached to the canonical pflag. Consumers should use
|
||||
// Aliases instead of reading the annotation directly.
|
||||
const AnnotationAliases = "lark-cli/flag-aliases"
|
||||
|
||||
// Spec binds Aliases to one Canonical long-flag name. Names do not include the
|
||||
// leading "--".
|
||||
type Spec struct {
|
||||
Canonical string
|
||||
Aliases []string
|
||||
}
|
||||
|
||||
// Bind installs exact-name aliases on cmd and records them on their canonical
|
||||
// pflags for manifest/tooling introspection. Existing pflag normalization is
|
||||
// composed first; alias resolution is then applied to the normalized spelling.
|
||||
//
|
||||
// Bind is intentionally the only production owner of SetNormalizeFunc. It
|
||||
// validates the complete accepted-name set before installing alias metadata or
|
||||
// a normalizer, so a configuration error cannot leave aliases partially bound.
|
||||
func Bind(cmd *cobra.Command, specs []Spec) error {
|
||||
if len(specs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if cmd == nil {
|
||||
return fmt.Errorf("bind flag aliases: command is nil")
|
||||
}
|
||||
|
||||
cmd.InitDefaultHelpFlag()
|
||||
flagSet := cmd.Flags()
|
||||
previous := flagSet.GetNormalizeFunc()
|
||||
normalize := func(name string) string {
|
||||
if previous == nil {
|
||||
return name
|
||||
}
|
||||
return string(previous(flagSet, name))
|
||||
}
|
||||
|
||||
registered := make(map[string]string)
|
||||
collectRegistered(registered, flagSet)
|
||||
collectRegistered(registered, cmd.InheritedFlags())
|
||||
|
||||
// Existing annotations matter when Bind is composed by multiple adapters:
|
||||
// aliases are not independent pflags, so VisitAll alone cannot see them.
|
||||
acceptedAliases := make(map[string]string)
|
||||
collectAnnotatedAliases(acceptedAliases, flagSet, normalize)
|
||||
collectAnnotatedAliases(acceptedAliases, cmd.InheritedFlags(), normalize)
|
||||
|
||||
aliases := make(map[string]string)
|
||||
metadata := make(map[*pflag.Flag][]string)
|
||||
seenCanonical := make(map[string]struct{})
|
||||
for _, spec := range specs {
|
||||
if len(spec.Aliases) == 0 {
|
||||
continue
|
||||
}
|
||||
canonicalFlag := flagSet.Lookup(spec.Canonical)
|
||||
if canonicalFlag == nil {
|
||||
return fmt.Errorf("%s declares aliases for unregistered flag --%s", cmd.CommandPath(), spec.Canonical)
|
||||
}
|
||||
canonical := canonicalFlag.Name
|
||||
if _, exists := seenCanonical[canonical]; exists {
|
||||
return fmt.Errorf("%s declares flag aliases for --%s more than once after normalization", cmd.CommandPath(), canonical)
|
||||
}
|
||||
seenCanonical[canonical] = struct{}{}
|
||||
for _, alias := range spec.Aliases {
|
||||
if err := validateAliasName(alias); err != nil {
|
||||
return fmt.Errorf("%s alias for --%s: %w", cmd.CommandPath(), canonical, err)
|
||||
}
|
||||
normalized := normalize(alias)
|
||||
if normalized == "" {
|
||||
return fmt.Errorf("%s alias --%s for --%s normalizes to an empty name", cmd.CommandPath(), alias, canonical)
|
||||
}
|
||||
if normalized == canonical {
|
||||
return fmt.Errorf("%s declares --%s as an alias of itself (--%s after normalization)", cmd.CommandPath(), alias, canonical)
|
||||
}
|
||||
if existing, ok := registered[normalized]; ok {
|
||||
return fmt.Errorf("%s alias --%s for --%s conflicts with registered flag --%s after normalization", cmd.CommandPath(), alias, canonical, existing)
|
||||
}
|
||||
if existing, ok := acceptedAliases[normalized]; ok {
|
||||
return fmt.Errorf("%s alias --%s for --%s conflicts with existing alias for --%s after normalization to --%s", cmd.CommandPath(), alias, canonical, existing, normalized)
|
||||
}
|
||||
if existing, ok := aliases[normalized]; ok {
|
||||
if existing == canonical {
|
||||
return fmt.Errorf("%s declares duplicate alias --%s for --%s after normalization to --%s", cmd.CommandPath(), alias, canonical, normalized)
|
||||
}
|
||||
return fmt.Errorf("%s alias --%s maps to both --%s and --%s after normalization to --%s", cmd.CommandPath(), alias, existing, canonical, normalized)
|
||||
}
|
||||
aliases[normalized] = canonical
|
||||
metadata[canonicalFlag] = append(metadata[canonicalFlag], alias)
|
||||
}
|
||||
}
|
||||
|
||||
if len(aliases) == 0 {
|
||||
return nil
|
||||
}
|
||||
for flag, names := range metadata {
|
||||
setAliases(flag, append(Aliases(flag), names...))
|
||||
}
|
||||
flagSet.SetNormalizeFunc(func(set *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
normalized := name
|
||||
if previous != nil {
|
||||
normalized = string(previous(set, name))
|
||||
}
|
||||
if canonical, ok := aliases[normalized]; ok {
|
||||
return pflag.NormalizedName(canonical)
|
||||
}
|
||||
return pflag.NormalizedName(normalized)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// MustBind is the flag-registration form of Bind. Cobra/pflag registration
|
||||
// already treats duplicate or invalid flag definitions as programmer errors;
|
||||
// MustBind preserves that startup-fail-fast contract for callers whose mount
|
||||
// API does not return an error.
|
||||
func MustBind(cmd *cobra.Command, specs []Spec) {
|
||||
if err := Bind(cmd, specs); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Aliases returns a defensive copy of the raw accepted alias spellings stored
|
||||
// on a canonical pflag. Alias order matches declaration order.
|
||||
func Aliases(flag *pflag.Flag) []string {
|
||||
if flag == nil || len(flag.Annotations) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), flag.Annotations[AnnotationAliases]...)
|
||||
}
|
||||
|
||||
func validateAliasName(name string) error {
|
||||
switch {
|
||||
case name == "":
|
||||
return fmt.Errorf("name must not be empty")
|
||||
case strings.HasPrefix(name, "-"):
|
||||
return fmt.Errorf("name %q must not include leading dashes", name)
|
||||
case strings.ContainsAny(name, " \t\r\n"):
|
||||
return fmt.Errorf("name %q must not contain whitespace", name)
|
||||
case strings.Contains(name, "="):
|
||||
return fmt.Errorf("name %q must not contain '='", name)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func collectRegistered(dst map[string]string, set *pflag.FlagSet) {
|
||||
if set == nil {
|
||||
return
|
||||
}
|
||||
set.VisitAll(func(flag *pflag.Flag) {
|
||||
dst[flag.Name] = flag.Name
|
||||
})
|
||||
}
|
||||
|
||||
func collectAnnotatedAliases(dst map[string]string, set *pflag.FlagSet, normalize func(string) string) {
|
||||
if set == nil {
|
||||
return
|
||||
}
|
||||
set.VisitAll(func(flag *pflag.Flag) {
|
||||
for _, alias := range Aliases(flag) {
|
||||
dst[normalize(alias)] = flag.Name
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func setAliases(flag *pflag.Flag, aliases []string) {
|
||||
if flag.Annotations == nil {
|
||||
flag.Annotations = make(map[string][]string)
|
||||
}
|
||||
flag.Annotations[AnnotationAliases] = append([]string(nil), aliases...)
|
||||
}
|
||||
224
internal/flagalias/flagalias_test.go
Normal file
224
internal/flagalias/flagalias_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package flagalias
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func TestBindResolvesAliasesToOneCanonicalFlag(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "messages"}
|
||||
cmd.Flags().String("order", "desc", "message order")
|
||||
if err := cmd.MarkFlagRequired("order"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Bind(cmd, []Spec{{Canonical: "order", Aliases: []string{"sort", "sort-order"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := cmd.ParseFlags([]string{"--sort-order", "asc"}); err != nil {
|
||||
t.Fatalf("ParseFlags(alias) error = %v", err)
|
||||
}
|
||||
canonical := cmd.Flags().Lookup("order")
|
||||
if got := canonical.Value.String(); got != "asc" {
|
||||
t.Fatalf("canonical value = %q, want asc", got)
|
||||
}
|
||||
if !canonical.Changed {
|
||||
t.Fatal("alias must mark canonical flag Changed")
|
||||
}
|
||||
if err := cmd.ValidateRequiredFlags(); err != nil {
|
||||
t.Fatalf("alias must satisfy required canonical flag: %v", err)
|
||||
}
|
||||
if got := cmd.Flags().Lookup("sort-order"); got != canonical {
|
||||
t.Fatalf("Lookup(alias) = %p, want canonical %p", got, canonical)
|
||||
}
|
||||
if got := Aliases(canonical); strings.Join(got, ",") != "sort,sort-order" {
|
||||
t.Fatalf("Aliases(canonical) = %v", got)
|
||||
}
|
||||
if usage := cmd.Flags().FlagUsages(); strings.Contains(usage, "--sort") {
|
||||
t.Fatalf("aliases leaked into help:\n%s", usage)
|
||||
}
|
||||
var names []string
|
||||
cmd.Flags().VisitAll(func(flag *pflag.Flag) { names = append(names, flag.Name) })
|
||||
if strings.Contains(strings.Join(names, ","), "sort") {
|
||||
t.Fatalf("aliases were registered as independent flags: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindUsesNativeRepeatedFlagSemantics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "alias last", args: []string{"--order", "asc", "--sort", "desc"}, want: "desc"},
|
||||
{name: "canonical last", args: []string{"--sort", "desc", "--order", "asc"}, want: "asc"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "messages"}
|
||||
cmd.Flags().String("order", "", "")
|
||||
if err := Bind(cmd, []Spec{{Canonical: "order", Aliases: []string{"sort"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.ParseFlags(test.args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("order"); got != test.want {
|
||||
t.Fatalf("order = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "messages"}
|
||||
cmd.Flags().StringSlice("fields", nil, "")
|
||||
if err := Bind(cmd, []Spec{{Canonical: "fields", Aliases: []string{"field"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.ParseFlags([]string{"--field", "name", "--fields", "status"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetStringSlice("fields"); strings.Join(got, ",") != "name,status" {
|
||||
t.Fatalf("collection aliases did not accumulate: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindComposesExistingNormalizer(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "messages"}
|
||||
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
cmd.Flags().String("order", "", "")
|
||||
if err := Bind(cmd, []Spec{{Canonical: "order", Aliases: []string{"sort-order"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.ParseFlags([]string{"--sort_order", "asc"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("order"); got != "asc" {
|
||||
t.Fatalf("order = %q, want asc", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindRejectsDuplicateCanonicalAfterNormalization(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "messages"}
|
||||
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
cmd.Flags().String("sort-order", "", "")
|
||||
|
||||
err := Bind(cmd, []Spec{
|
||||
{Canonical: "sort_order", Aliases: []string{"order"}},
|
||||
{Canonical: "sort-order", Aliases: []string{"ordering"}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "more than once after normalization") {
|
||||
t.Fatalf("Bind() error = %v", err)
|
||||
}
|
||||
if got := Aliases(cmd.Flags().Lookup("sort-order")); len(got) != 0 {
|
||||
t.Fatalf("failed bind partially mutated annotations: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindRejectsAcceptedNameCollisionsWithoutMutation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
specs []Spec
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "registered canonical",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("order", "", "")
|
||||
cmd.Flags().String("query", "", "")
|
||||
},
|
||||
specs: []Spec{{Canonical: "order", Aliases: []string{"query"}}},
|
||||
want: "conflicts with registered flag --query",
|
||||
},
|
||||
{
|
||||
name: "ambiguous alias",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("order", "", "")
|
||||
cmd.Flags().String("field", "", "")
|
||||
},
|
||||
specs: []Spec{
|
||||
{Canonical: "order", Aliases: []string{"sort"}},
|
||||
{Canonical: "field", Aliases: []string{"sort"}},
|
||||
},
|
||||
want: "maps to both",
|
||||
},
|
||||
{
|
||||
name: "normalized collision",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(strings.ReplaceAll(name, "_", "-"))
|
||||
})
|
||||
cmd.Flags().String("order", "", "")
|
||||
cmd.Flags().String("sort-order", "", "")
|
||||
},
|
||||
specs: []Spec{{Canonical: "order", Aliases: []string{"sort_order"}}},
|
||||
want: "after normalization",
|
||||
},
|
||||
{
|
||||
name: "invalid spelling",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
cmd.Flags().String("order", "", "")
|
||||
},
|
||||
specs: []Spec{{Canonical: "order", Aliases: []string{"--sort"}}},
|
||||
want: "must not include leading dashes",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "messages"}
|
||||
test.setup(cmd)
|
||||
err := Bind(cmd, test.specs)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Bind() error = %v, want %q", err, test.want)
|
||||
}
|
||||
if got := Aliases(cmd.Flags().Lookup("order")); len(got) != 0 {
|
||||
t.Fatalf("failed bind partially mutated annotations: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindRejectsInheritedFlagCollision(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
parent.PersistentFlags().String("profile", "", "")
|
||||
child := &cobra.Command{Use: "messages"}
|
||||
child.Flags().String("order", "", "")
|
||||
parent.AddCommand(child)
|
||||
|
||||
err := Bind(child, []Spec{{Canonical: "order", Aliases: []string{"profile"}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "registered flag --profile") {
|
||||
t.Fatalf("Bind() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindCanComposeIndependentAdapters(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "messages"}
|
||||
cmd.Flags().String("order", "", "")
|
||||
cmd.Flags().String("query", "", "")
|
||||
if err := Bind(cmd, []Spec{{Canonical: "order", Aliases: []string{"sort"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Bind(cmd, []Spec{{Canonical: "query", Aliases: []string{"keyword"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.ParseFlags([]string{"--sort", "asc", "--keyword", "launch"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("order"); got != "asc" {
|
||||
t.Fatalf("order = %q", got)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetString("query"); got != "launch" {
|
||||
t.Fatalf("query = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,10 @@ type Stub struct {
|
||||
// matches after the first hit. Each match appends to CapturedBodies.
|
||||
Reusable bool
|
||||
|
||||
// Optional (optional): when true, Verify does not require this stub to be
|
||||
// matched. Useful for negative assertions via OnMatch.
|
||||
Optional bool
|
||||
|
||||
// CapturedHeaders records the request headers of the matched request.
|
||||
// Populated after RoundTrip matches this stub.
|
||||
CapturedHeaders http.Header
|
||||
@@ -137,6 +141,9 @@ func (r *Registry) Verify(t testing.TB) {
|
||||
if s.matched {
|
||||
continue
|
||||
}
|
||||
if s.Optional {
|
||||
continue
|
||||
}
|
||||
// Reusable stubs never set s.matched; treat any captured hit as a match.
|
||||
if s.Reusable && len(s.CapturedBodies) > 0 {
|
||||
continue
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -58,3 +59,25 @@ func WriteAlertWarning(w io.Writer, alert *extcs.Alert) error {
|
||||
alert.Provider, strings.Join(alert.MatchedRules, ", "))
|
||||
return err
|
||||
}
|
||||
|
||||
// writePaginationDiagnostic reports a record stream's pagination outcome on the
|
||||
// diagnostics stream, as one JSON object per line.
|
||||
//
|
||||
// A record stream has no envelope to carry meta, so without this a result
|
||||
// truncated by --page-limit is byte-identical to a complete one — the reader
|
||||
// cannot tell "these are all the records" from "these are the first 500". It is
|
||||
// JSON rather than prose because the reader that needs it is a program.
|
||||
func writePaginationDiagnostic(w io.Writer, meta PaginationMeta) error {
|
||||
payload := struct {
|
||||
Diagnostic string `json:"_diagnostic"`
|
||||
PaginationMeta
|
||||
}{Diagnostic: "pagination", PaginationMeta: meta}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return wrapOutputError("render", err)
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "%s\n", encoded); err != nil {
|
||||
return wrapOutputError("write", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
@@ -36,9 +35,11 @@ type EmitterConfig struct {
|
||||
// EmitOptions describes one result's wire representation.
|
||||
//
|
||||
// The format contract is explicit: JSON (including the empty default) uses an
|
||||
// Envelope; pretty, table, csv, and ndjson render naked business data. JQ takes
|
||||
// precedence over Format and filters the JSON Envelope. Raw affects only JSON
|
||||
// envelope encoding and jq's complex-value encoding.
|
||||
// Envelope. Pretty and table render business data plus a human pagination
|
||||
// summary when supplied; csv and ndjson keep stdout as naked records and put
|
||||
// pagination metadata on the diagnostics stream. JQ takes precedence over
|
||||
// Format and filters the JSON Envelope. Raw affects only JSON envelope encoding
|
||||
// and jq's complex-value encoding.
|
||||
//
|
||||
// JQSafetyWarning preserves the legacy difference between RuntimeContext.emit
|
||||
// (false) and WriteSuccessEnvelope (true) until their callers are migrated.
|
||||
@@ -94,8 +95,8 @@ func NewEmitter(config EmitterConfig) *Emitter {
|
||||
}
|
||||
|
||||
// Success scans and emits one command result by composing the package's leaf
|
||||
// primitives. JSON and jq use the standard envelope; pretty, table, csv, and
|
||||
// ndjson render the business value directly.
|
||||
// primitives. JSON and jq use the standard envelope; record formats keep their
|
||||
// stdout payload free of envelope metadata.
|
||||
func (e *Emitter) Success(data interface{}, opts EmitOptions) error {
|
||||
if err := e.requireOutput(); err != nil {
|
||||
return err
|
||||
@@ -104,14 +105,23 @@ func (e *Emitter) Success(data interface{}, opts EmitOptions) error {
|
||||
if opts.JQ != "" {
|
||||
return e.emitEnvelope(data, true, opts)
|
||||
}
|
||||
|
||||
switch opts.Format {
|
||||
case "", "json":
|
||||
return e.emitEnvelope(data, true, opts)
|
||||
case "pretty":
|
||||
if opts.Format == "pretty" {
|
||||
return e.emitPretty(data, opts)
|
||||
}
|
||||
|
||||
format, known := ParseFormat(opts.Format)
|
||||
if !known {
|
||||
fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", opts.Format)
|
||||
return e.emitEnvelope(data, true, opts)
|
||||
}
|
||||
switch format {
|
||||
case FormatJSON:
|
||||
return e.emitEnvelope(data, true, opts)
|
||||
case FormatTable, FormatCSV, FormatNDJSON:
|
||||
return e.emitFormatted(data, format, opts.Meta)
|
||||
default:
|
||||
return e.emitFormatted(data, opts.Format)
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"unsupported output format %q", format)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +255,10 @@ func (e *Emitter) emitPretty(data interface{}, opts EmitOptions) error {
|
||||
}
|
||||
if opts.Pretty != nil {
|
||||
return e.emit(func(w io.Writer) error {
|
||||
return opts.Pretty(w, e.colorEnabled)
|
||||
if err := opts.Pretty(w, e.colorEnabled); err != nil {
|
||||
return err
|
||||
}
|
||||
return writePaginationSummary(w, opts.Meta)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -255,7 +268,10 @@ func (e *Emitter) emitPretty(data interface{}, opts EmitOptions) error {
|
||||
return e.emitEnvelope(data, true, opts)
|
||||
}
|
||||
|
||||
func (e *Emitter) emitFormatted(data interface{}, rawFormat string) error {
|
||||
// emitFormatted handles only non-envelope formats. JSON, jq, and unknown-format
|
||||
// fallback are resolved by Success before reaching this function, so there is
|
||||
// exactly one JSON success contract: the standard Envelope.
|
||||
func (e *Emitter) emitFormatted(data interface{}, format Format, meta *Meta) error {
|
||||
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
@@ -266,43 +282,49 @@ func (e *Emitter) emitFormatted(data interface{}, rawFormat string) error {
|
||||
}
|
||||
}
|
||||
|
||||
format, known := ParseFormat(rawFormat)
|
||||
if !known && e.errOut != nil {
|
||||
fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", rawFormat)
|
||||
switch format {
|
||||
case FormatTable:
|
||||
return e.emit(func(w io.Writer) error {
|
||||
if err := WriteFormatted(w, data, format); err != nil {
|
||||
return err
|
||||
}
|
||||
return writePaginationSummary(w, meta)
|
||||
})
|
||||
case FormatCSV, FormatNDJSON:
|
||||
if err := e.emit(func(w io.Writer) error {
|
||||
return WriteFormatted(w, data, format)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if meta == nil || meta.Pagination == nil {
|
||||
return nil
|
||||
}
|
||||
return writePaginationDiagnostic(e.errOut, *meta.Pagination)
|
||||
default:
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"non-envelope emitter received unsupported format %q", format)
|
||||
}
|
||||
if format == FormatJSON {
|
||||
return e.printLegacyDataJSON(data)
|
||||
}
|
||||
return e.emit(func(w io.Writer) error {
|
||||
return WriteFormatted(w, data, format)
|
||||
})
|
||||
}
|
||||
|
||||
type emitterDataMap map[string]interface{}
|
||||
|
||||
// printLegacyDataJSON matches FormatValue's JSON branch while sourcing notice
|
||||
// data from this Emitter instead of PrintJson's global PendingNotice hook.
|
||||
func (e *Emitter) printLegacyDataJSON(data interface{}) error {
|
||||
// Normalise structs / named maps to plain generic types first, exactly as
|
||||
// FormatValue does, so a struct or named-map payload still matches the map
|
||||
// case below and keeps its injected _notice on the unknown-format fallback.
|
||||
data = toGeneric(data)
|
||||
if m, ok := data.(map[string]interface{}); ok {
|
||||
if _, isEnvelope := m["ok"]; isEnvelope {
|
||||
if notice := e.notice(); notice != nil {
|
||||
m = maps.Clone(m)
|
||||
m["_notice"] = notice
|
||||
}
|
||||
}
|
||||
// The named map retains identical JSON bytes while preventing PrintJson
|
||||
// from consulting its legacy global notice hook a second time.
|
||||
return e.emit(func(w io.Writer) error {
|
||||
return WriteJSON(w, emitterDataMap(m))
|
||||
})
|
||||
func writePaginationSummary(w io.Writer, meta *Meta) error {
|
||||
if meta == nil || meta.Pagination == nil {
|
||||
return nil
|
||||
}
|
||||
return e.emit(func(w io.Writer) error {
|
||||
return WriteJSON(w, data)
|
||||
})
|
||||
pagination := meta.Pagination
|
||||
status := "complete"
|
||||
if !pagination.Complete {
|
||||
status = "incomplete"
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "\nPagination: %s (%d page(s), %d item(s))", status, pagination.Pages, pagination.Items); err != nil {
|
||||
return err
|
||||
}
|
||||
if !pagination.Complete && pagination.NextToken != "" {
|
||||
if _, err := fmt.Fprintf(w, "; resume token: %q", pagination.NextToken); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := fmt.Fprintln(w)
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *Emitter) emit(render func(io.Writer) error) error {
|
||||
|
||||
@@ -63,6 +63,127 @@ func TestEmitterSuccessWritesAllBytes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterPaginationMetadataByFormat(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": "1", "name": "first"}},
|
||||
}
|
||||
meta := &output.Meta{
|
||||
Count: 1,
|
||||
Pagination: &output.PaginationMeta{
|
||||
Complete: false,
|
||||
Pages: 2,
|
||||
Items: 1,
|
||||
NextToken: "resume-token",
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("json envelope", func(t *testing.T) {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout, ErrOut: stderr, CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
if err := emitter.Success(data, output.EmitOptions{Format: "json", Meta: meta}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
var envelope output.Envelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v", err)
|
||||
}
|
||||
if envelope.Meta == nil || !reflect.DeepEqual(envelope.Meta.Pagination, meta.Pagination) {
|
||||
t.Fatalf("pagination meta = %#v, want %#v", envelope.Meta, meta.Pagination)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("json wrote pagination diagnostic to stderr: %q", stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown format falls back to the same json envelope", func(t *testing.T) {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout, ErrOut: stderr, CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
if err := emitter.Success(data, output.EmitOptions{Format: "yaml", Meta: meta}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
var envelope output.Envelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("fallback stdout is not one complete JSON envelope: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if envelope.Meta == nil || !reflect.DeepEqual(envelope.Meta.Pagination, meta.Pagination) {
|
||||
t.Fatalf("fallback pagination meta = %#v, want %#v", envelope.Meta, meta.Pagination)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), `warning: unknown format "yaml", falling back to json`) {
|
||||
t.Fatalf("fallback stderr = %q, want unknown-format warning", stderr.String())
|
||||
}
|
||||
if strings.Contains(stderr.String(), `"_diagnostic":"pagination"`) {
|
||||
t.Fatalf("fallback emitted a second pagination contract: %q", stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
format string
|
||||
pretty output.PrettyRenderer
|
||||
}{
|
||||
{name: "pretty", format: "pretty", pretty: func(w io.Writer, _ bool) error {
|
||||
_, err := io.WriteString(w, "first\n")
|
||||
return err
|
||||
}},
|
||||
{name: "table", format: "table"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout, ErrOut: stderr, CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
if err := emitter.Success(data, output.EmitOptions{Format: tc.format, Pretty: tc.pretty, Meta: meta}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{"Pagination: incomplete", "2 page(s)", "1 item(s)", `resume token: "resume-token"`} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("stdout = %q, want %q", stdout.String(), want)
|
||||
}
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("%s wrote pagination diagnostic to stderr: %q", tc.format, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, format := range []string{"ndjson", "csv"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout, ErrOut: stderr, CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
if err := emitter.Success(data, output.EmitOptions{Format: format, Meta: meta}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "_diagnostic") || strings.Contains(stdout.String(), "resume-token") {
|
||||
t.Fatalf("%s stdout was polluted by pagination metadata: %q", format, stdout.String())
|
||||
}
|
||||
var diagnostic struct {
|
||||
Diagnostic string `json:"_diagnostic"`
|
||||
Complete bool `json:"complete"`
|
||||
Pages int `json:"pages"`
|
||||
Items int `json:"items"`
|
||||
NextToken string `json:"next_token"`
|
||||
}
|
||||
if err := json.Unmarshal(bytes.TrimSpace(stderr.Bytes()), &diagnostic); err != nil {
|
||||
t.Fatalf("decode pagination diagnostic %q: %v", stderr.String(), err)
|
||||
}
|
||||
if diagnostic.Diagnostic != "pagination" || diagnostic.Complete || diagnostic.Pages != 2 || diagnostic.Items != 1 || diagnostic.NextToken != "resume-token" {
|
||||
t.Fatalf("pagination diagnostic = %+v", diagnostic)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterMarshalFailureReturnsTypedErrorWithoutOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
stdout := &bytes.Buffer{}
|
||||
|
||||
@@ -269,16 +269,9 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
|
||||
MatchedRules: []string{"fixture-rule"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown_format_data_envelope_notice",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"ok": true, "value": "fixture"}
|
||||
},
|
||||
ok: true,
|
||||
format: "yaml",
|
||||
useFormat: true,
|
||||
notice: map[string]interface{}{"skills": map[string]interface{}{"current": "1.0.0"}},
|
||||
},
|
||||
// Unknown-format fallback is intentionally excluded from this frozen
|
||||
// legacy set: it now uses the standard JSON Envelope. The replacement
|
||||
// contract lives in TestEmitterPaginationMetadataByFormat.
|
||||
}
|
||||
|
||||
golden := loadRuntimeContextLegacyGolden(t)
|
||||
@@ -732,7 +725,7 @@ func TestEmitterCapturesNoticeAndColorDependencies(t *testing.T) {
|
||||
t.Fatalf("Emitter.Success(unknown format) error = %v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") {
|
||||
t.Fatalf("legacy JSON fallback consulted global notice:\n%s", stdout.String())
|
||||
t.Fatalf("JSON envelope fallback consulted global notice:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,24 @@ type Envelope struct {
|
||||
|
||||
// Meta carries optional metadata in envelope responses.
|
||||
type Meta struct {
|
||||
Count int `json:"count,omitempty"`
|
||||
Rollback string `json:"rollback,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
Rollback string `json:"rollback,omitempty"`
|
||||
Pagination *PaginationMeta `json:"pagination,omitempty"`
|
||||
}
|
||||
|
||||
// PaginationMeta reports how a paginated read ended.
|
||||
//
|
||||
// It lives in the envelope's meta rather than in the business data because a
|
||||
// stop reason is not part of the resource: writing it into data both pollutes
|
||||
// the payload and forces the caller to tell an API field apart from one the CLI
|
||||
// synthesised. Complete plus NextToken is the whole story — a run either
|
||||
// exhausted the endpoint or stopped at --page-limit with somewhere to resume —
|
||||
// so there is no separate stop_reason string to keep in sync.
|
||||
type PaginationMeta struct {
|
||||
Complete bool `json:"complete"`
|
||||
Pages int `json:"pages"`
|
||||
Items int `json:"items"`
|
||||
NextToken string `json:"next_token,omitempty"`
|
||||
}
|
||||
|
||||
// PendingNotice, if set, returns system-level notices to inject as the
|
||||
|
||||
@@ -98,10 +98,6 @@
|
||||
"table_with_safety_warning": {
|
||||
"stdout": "id name \n── ─────\n1 Alice\n",
|
||||
"stderr": "warning: content safety alert from emitter-oracle (rules: fixture-rule)\n"
|
||||
},
|
||||
"unknown_format_data_envelope_notice": {
|
||||
"stdout": "{\n \"_notice\": {\n \"skills\": {\n \"current\": \"1.0.0\"\n }\n },\n \"ok\": true,\n \"value\": \"fixture\"\n}\n",
|
||||
"stderr": "warning: unknown format \"yaml\", falling back to json\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
rootcmd "github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/flagalias"
|
||||
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -134,6 +135,7 @@ func commandDomain(c *cobra.Command, path string, source manifest.Source) string
|
||||
func flagFromPFlag(f *pflag.Flag) manifest.Flag {
|
||||
return manifest.Flag{
|
||||
Name: f.Name,
|
||||
Aliases: flagalias.Aliases(f),
|
||||
Shorthand: f.Shorthand,
|
||||
Usage: f.Usage,
|
||||
Hidden: f.Hidden,
|
||||
@@ -141,7 +143,7 @@ func flagFromPFlag(f *pflag.Flag) manifest.Flag {
|
||||
TakesValue: f.NoOptDefVal == "",
|
||||
DefValue: f.DefValue,
|
||||
NoOptValue: f.NoOptDefVal,
|
||||
Annotations: cloneAnnotations(f.Annotations),
|
||||
Annotations: cloneAnnotations(f.Annotations, flagalias.AnnotationAliases),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,13 +164,23 @@ func hasAnnotation(f *pflag.Flag, key string) bool {
|
||||
return ok && len(values) > 0
|
||||
}
|
||||
|
||||
func cloneAnnotations(in map[string][]string) map[string][]string {
|
||||
func cloneAnnotations(in map[string][]string, excluded ...string) map[string][]string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
skip := make(map[string]struct{}, len(excluded))
|
||||
for _, key := range excluded {
|
||||
skip[key] = struct{}{}
|
||||
}
|
||||
out := make(map[string][]string, len(in))
|
||||
for key, values := range in {
|
||||
if _, ok := skip[key]; ok {
|
||||
continue
|
||||
}
|
||||
out[key] = append([]string(nil), values...)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/flagalias"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCommandFromCobraExportsAliasesAsFirstClassMetadata(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
cmd := &cobra.Command{Use: "+messages"}
|
||||
cmd.Flags().String("order", "desc", "message order")
|
||||
root.AddCommand(cmd)
|
||||
if err := flagalias.Bind(cmd, []flagalias.Spec{{Canonical: "order", Aliases: []string{"sort", "sort-order"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entry := commandFromCobra(cmd, nil)
|
||||
flag := findFlag(entry.Flags, "order")
|
||||
if flag == nil {
|
||||
t.Fatal("manifest is missing canonical --order")
|
||||
}
|
||||
if !slices.Equal(flag.Aliases, []string{"sort", "sort-order"}) {
|
||||
t.Fatalf("manifest aliases = %v", flag.Aliases)
|
||||
}
|
||||
if _, leaked := flag.Annotations[flagalias.AnnotationAliases]; leaked {
|
||||
t.Fatalf("internal alias annotation leaked into manifest: %#v", flag.Annotations)
|
||||
}
|
||||
if findFlag(entry.Flags, "sort") != nil || findFlag(entry.Flags, "sort-order") != nil {
|
||||
t.Fatalf("aliases were exported as independent flags: %#v", entry.Flags)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
||||
@@ -90,6 +91,42 @@ func TestCollectContainsDocsFetchAndDryRunFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectExportsShortcutAliasesOnCanonicalFlags(t *testing.T) {
|
||||
got, err := collectHandAuthored(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("collectHandAuthored() error = %v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
command string
|
||||
canonical string
|
||||
aliases []string
|
||||
}{
|
||||
{command: "base +url-resolve", canonical: "url", aliases: []string{"query"}},
|
||||
{command: "im +chat-messages-list", canonical: "order", aliases: []string{"sort-order"}},
|
||||
{command: "sheets +workbook-info", canonical: "spreadsheet-token", aliases: []string{"token"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.command+"/"+test.canonical, func(t *testing.T) {
|
||||
cmd := findManifestCommand(&got, test.command)
|
||||
if cmd == nil {
|
||||
t.Fatalf("manifest command %q not found", test.command)
|
||||
}
|
||||
flag := findManifestFlag(cmd, test.canonical)
|
||||
if flag == nil {
|
||||
t.Fatalf("canonical --%s not found", test.canonical)
|
||||
}
|
||||
if strings.Join(flag.Aliases, ",") != strings.Join(test.aliases, ",") {
|
||||
t.Fatalf("aliases = %v, want %v", flag.Aliases, test.aliases)
|
||||
}
|
||||
for _, alias := range test.aliases {
|
||||
if findManifestFlag(cmd, alias) != nil {
|
||||
t.Fatalf("alias --%s exported as an independent flag", alias)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectExcludesGeneratedServiceCommands(t *testing.T) {
|
||||
got, err := collectHandAuthored(context.Background())
|
||||
if err != nil {
|
||||
|
||||
@@ -45,6 +45,18 @@ Adding a new row requires approval from the matching CODEOWNERS or quality gate
|
||||
|
||||
`legacy-commands.txt` only covers hand-authored legacy commands. Generated OpenAPI service commands are intentionally excluded from `command-manifest.json`; they are included in `command-index.json` only so command references can be checked against the real CLI surface.
|
||||
|
||||
## Public Domain Allowlists
|
||||
|
||||
`internal/qualitygate/config/allowlists/public-domains.txt` contains supported public hostnames approved for Go source. `fixture-domains.txt` contains test-only hostnames used by `*_test.go`, the repository-root `tests/` directory, or any `testdata/` directory; fixture entries do not apply to production Go files or `skills/`.
|
||||
|
||||
Keep one lowercase exact hostname per line, sorted alphabetically. Wildcards, suffix rules, duplicates, schemes, ports, and paths are rejected; approving `larkoffice.com` does not approve its subdomains.
|
||||
|
||||
RFC 2606 reserves the `.test`, `.example`, `.invalid`, and `.localhost` namespaces plus the exact names `example.com`, `example.net`, and `example.org`. These names are accepted without an allowlist entry and must not be listed.
|
||||
|
||||
Every public entry needs a current non-fixture Go use, evidence that it is a supported public endpoint, and CODEOWNER approval. Other test-only hostnames belong in the fixture list. Tenant-specific, private-control-plane, and internal API hostnames are not eligible.
|
||||
|
||||
`lint/domaincontract` validates both lists and scans complete Go files. In CI, unapproved-host findings are limited to values whose expressions intersect added lines; list validation and unused-entry checks remain repository-wide. See `lint/README.md` for scanner semantics.
|
||||
|
||||
## Semantic Blocker Policy
|
||||
|
||||
The semantic reviewer can propose findings, but the local gatekeeper recomputes whether each finding is reproducible from `facts.json`. A finding blocks only when all of these are true:
|
||||
|
||||
24
internal/qualitygate/config/allowlists/fixture-domains.txt
Normal file
24
internal/qualitygate/config/allowlists/fixture-domains.txt
Normal file
@@ -0,0 +1,24 @@
|
||||
# Exact test-only hostnames. Keep sorted.
|
||||
abc.feishu.cn
|
||||
attacker.example.com
|
||||
bytedance.feishu.cn
|
||||
cdn.feishu.cn
|
||||
evil.example.com
|
||||
example.feishu.cn
|
||||
example.larkoffice.com
|
||||
example.larksuite.com
|
||||
feishu.cn
|
||||
feishu.doubao.com
|
||||
gateway.docker.internal
|
||||
host.containers.internal
|
||||
host.docker.internal
|
||||
host.lima.internal
|
||||
lf3-static.bytednsdoc.com
|
||||
meetings.feishu.cn
|
||||
meetings.larksuite.com
|
||||
p3-lark-file.byteimg.com
|
||||
passport.feishu.cn
|
||||
sample.feishu.cn
|
||||
x.feishu.cn
|
||||
xxx.feishu.cn
|
||||
xxx.larksuite.com
|
||||
18
internal/qualitygate/config/allowlists/public-domains.txt
Normal file
18
internal/qualitygate/config/allowlists/public-domains.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
# Exact public hostnames. Keep sorted.
|
||||
accounts.feishu.cn
|
||||
accounts.larksuite.com
|
||||
applink.feishu.cn
|
||||
applink.larksuite.com
|
||||
ark.ap-southeast.bytepluses.com
|
||||
github.com
|
||||
larkoffice.com
|
||||
lf-larkemail.bytetos.com
|
||||
mcp.feishu.cn
|
||||
mcp.larksuite.com
|
||||
open.feishu.cn
|
||||
open.larksuite.com
|
||||
registry.npmjs.org
|
||||
registry.npmmirror.com
|
||||
sf16-sg.tiktokcdn.com
|
||||
www.feishu.cn
|
||||
www.larksuite.com
|
||||
@@ -19,6 +19,57 @@ func TestValidateRejectsDuplicateCommandPaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAcceptsDistinctFlagAliases(t *testing.T) {
|
||||
m := Manifest{SchemaVersion: 1, Commands: []Command{{
|
||||
Path: "im +messages",
|
||||
CanonicalPath: "im +messages",
|
||||
Source: SourceShortcut,
|
||||
Flags: []Flag{
|
||||
{Name: "order", Aliases: []string{"sort", "sort-order"}},
|
||||
{Name: "query", Aliases: []string{"keyword"}},
|
||||
},
|
||||
}}}
|
||||
if err := m.Validate(KindCommandManifest); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsFlagAliasCollisions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flags []Flag
|
||||
}{
|
||||
{
|
||||
name: "alias and canonical",
|
||||
flags: []Flag{
|
||||
{Name: "order", Aliases: []string{"query"}},
|
||||
{Name: "query"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "alias and alias",
|
||||
flags: []Flag{
|
||||
{Name: "order", Aliases: []string{"sort"}},
|
||||
{Name: "field", Aliases: []string{"sort"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "alias self reference",
|
||||
flags: []Flag{{Name: "order", Aliases: []string{"order"}}},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
m := Manifest{SchemaVersion: 1, Commands: []Command{{
|
||||
Path: "im +messages", CanonicalPath: "im +messages", Source: SourceShortcut, Flags: test.flags,
|
||||
}}}
|
||||
if err := m.Validate(KindCommandManifest); err == nil {
|
||||
t.Fatal("expected alias collision to fail")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidSource(t *testing.T) {
|
||||
m := Manifest{SchemaVersion: 1, Commands: []Command{
|
||||
{Path: "docs +fetch", CanonicalPath: "docs +fetch", Source: Source("invalid")},
|
||||
|
||||
@@ -40,6 +40,7 @@ type Command struct {
|
||||
|
||||
type Flag struct {
|
||||
Name string `json:"name"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
Shorthand string `json:"shorthand,omitempty"`
|
||||
Usage string `json:"usage,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
@@ -154,15 +155,24 @@ func validateCommand(kind string, i int, cmd Command) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
seenFlags := make(map[string]struct{}, len(cmd.Flags))
|
||||
acceptedNames := make(map[string]string, len(cmd.Flags))
|
||||
for j, flag := range cmd.Flags {
|
||||
if err := validateFlag(prefix, j, flag); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := seenFlags[flag.Name]; ok {
|
||||
if existing, ok := acceptedNames[flag.Name]; ok {
|
||||
if existing != flag.Name {
|
||||
return fmt.Errorf("%s flags[%d].name %s conflicts with an alias of --%s", prefix, j, flag.Name, existing)
|
||||
}
|
||||
return fmt.Errorf("%s flags[%d].name is duplicated: %s", prefix, j, flag.Name)
|
||||
}
|
||||
seenFlags[flag.Name] = struct{}{}
|
||||
acceptedNames[flag.Name] = flag.Name
|
||||
for k, alias := range flag.Aliases {
|
||||
if existing, ok := acceptedNames[alias]; ok {
|
||||
return fmt.Errorf("%s flags[%d].aliases[%d] %s conflicts with accepted name of --%s", prefix, j, k, alias, existing)
|
||||
}
|
||||
acceptedNames[alias] = flag.Name
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -175,6 +185,29 @@ func validateFlag(commandPrefix string, i int, flag Flag) error {
|
||||
if strings.ContainsAny(flag.Name, " \t\r\n") {
|
||||
return fmt.Errorf("%s.name must not contain whitespace", prefix)
|
||||
}
|
||||
seenAliases := make(map[string]struct{}, len(flag.Aliases))
|
||||
for j, alias := range flag.Aliases {
|
||||
aliasPrefix := fmt.Sprintf("%s.aliases[%d]", prefix, j)
|
||||
if err := validateString(aliasPrefix, alias, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasPrefix(alias, "-") {
|
||||
return fmt.Errorf("%s must not include leading dashes", aliasPrefix)
|
||||
}
|
||||
if strings.ContainsAny(alias, " \t\r\n") {
|
||||
return fmt.Errorf("%s must not contain whitespace", aliasPrefix)
|
||||
}
|
||||
if strings.Contains(alias, "=") {
|
||||
return fmt.Errorf("%s must not contain '='", aliasPrefix)
|
||||
}
|
||||
if alias == flag.Name {
|
||||
return fmt.Errorf("%s must differ from canonical name %s", aliasPrefix, flag.Name)
|
||||
}
|
||||
if _, ok := seenAliases[alias]; ok {
|
||||
return fmt.Errorf("%s is duplicated: %s", aliasPrefix, alias)
|
||||
}
|
||||
seenAliases[alias] = struct{}{}
|
||||
}
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
value string
|
||||
|
||||
@@ -199,7 +199,11 @@ func materializePlaceholderExample(raw string, cmd manifest.Command) (materializ
|
||||
if eq := strings.IndexByte(name, '='); eq >= 0 {
|
||||
flagName := name[:eq]
|
||||
flag := findManifestFlag(&cmd, flagName)
|
||||
value, ok := materializePlaceholderValue(name[eq+1:], placeholderContextForFlag(flagName, flag))
|
||||
contextName := flagName
|
||||
if flag != nil {
|
||||
contextName = flag.Name
|
||||
}
|
||||
value, ok := materializePlaceholderValue(name[eq+1:], placeholderContextForFlag(contextName, flag))
|
||||
if !ok {
|
||||
return materializedExample{}, false
|
||||
}
|
||||
@@ -208,7 +212,7 @@ func materializePlaceholderExample(raw string, cmd manifest.Command) (materializ
|
||||
}
|
||||
flag := findManifestFlag(&cmd, name)
|
||||
if flag != nil && flag.TakesValue && i+1 < len(argv) {
|
||||
value, ok := materializePlaceholderValue(argv[i+1], placeholderContextForFlag(name, flag))
|
||||
value, ok := materializePlaceholderValue(argv[i+1], placeholderContextForFlag(flag.Name, flag))
|
||||
if !ok {
|
||||
return materializedExample{}, false
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package rules
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
@@ -170,7 +171,11 @@ func consumeFlags(args []string, cmd *manifest.Command) ([]string, []string, err
|
||||
hasInlineValue = true
|
||||
}
|
||||
flag := findManifestFlag(cmd, name)
|
||||
flags = append(flags, name)
|
||||
acceptedName := name
|
||||
if flag != nil {
|
||||
acceptedName = flag.Name
|
||||
}
|
||||
flags = append(flags, acceptedName)
|
||||
if flag != nil && !hasInlineValue && flag.TakesValue && i+1 < len(args) {
|
||||
i++
|
||||
}
|
||||
@@ -201,7 +206,7 @@ func isShellOperator(arg string) bool {
|
||||
|
||||
func findManifestFlag(cmd *manifest.Command, name string) *manifest.Flag {
|
||||
for i := range cmd.Flags {
|
||||
if cmd.Flags[i].Name == name || cmd.Flags[i].Shorthand == name {
|
||||
if cmd.Flags[i].Name == name || cmd.Flags[i].Shorthand == name || slices.Contains(cmd.Flags[i].Aliases, name) {
|
||||
return &cmd.Flags[i]
|
||||
}
|
||||
}
|
||||
@@ -241,6 +246,9 @@ func indexManifest(m manifest.Manifest) manifestIndex {
|
||||
flagSet := make(map[string]bool, len(cmd.Flags))
|
||||
for _, fl := range cmd.Flags {
|
||||
flagSet[fl.Name] = true
|
||||
for _, alias := range fl.Aliases {
|
||||
flagSet[alias] = true
|
||||
}
|
||||
}
|
||||
index.flags[cmd.Path] = flagSet
|
||||
}
|
||||
|
||||
@@ -379,6 +379,26 @@ func TestCheckReferencesAllowsHelpFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAgainstManifestAcceptsAliasAndCanonicalizesFact(t *testing.T) {
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "im +messages",
|
||||
Runnable: true,
|
||||
Flags: []manifest.Flag{{
|
||||
Name: "order", Aliases: []string{"sort-order"}, TakesValue: true,
|
||||
}},
|
||||
}}}
|
||||
got, err := parseAgainstManifest(m, "lark-cli im +messages --sort-order asc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(got.Flags, ",") != "order" {
|
||||
t.Fatalf("flags = %v, want canonical order", got.Flags)
|
||||
}
|
||||
if index := indexManifest(m); !index.hasFlag("im +messages", "sort-order") {
|
||||
t.Fatal("manifest index did not retain accepted alias name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckReferencesSkipsTemplateServicePlaceholder(t *testing.T) {
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{Path: "im"}}}
|
||||
ex := skillscan.Example{Raw: "lark-cli im <resource> <method> [flags]", SourceFile: "skills/lark-demo/SKILL.md", Line: 1}
|
||||
|
||||
@@ -180,8 +180,8 @@ func saveCachedMerged(data []byte, cm CacheMeta) error {
|
||||
// localVersion is sent as data_version query param for server-side version comparison.
|
||||
// Returns (data, reg, err). A nil reg means the version is unchanged (not modified).
|
||||
func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, err error) {
|
||||
// Route through the shared proxy-plugin-aware transport so remote API
|
||||
// definition fetches honor proxy plugin mode instead of bypassing it.
|
||||
// Remote metadata is platform traffic and must honor both the shared proxy
|
||||
// configuration and the registered platform transport extension.
|
||||
client := transport.NewHTTPClient(fetchTimeout)
|
||||
req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil)
|
||||
if err != nil {
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
var _ internaltransport.RoundTripperDecorator = (*Transport)(nil)
|
||||
|
||||
const (
|
||||
HeaderProductModel = "X-Agent-Device-Type"
|
||||
HeaderOSType = "X-Agent-Os-Type"
|
||||
@@ -40,6 +42,28 @@ func NewTransport(next http.RoundTripper, source Source) *Transport {
|
||||
}
|
||||
}
|
||||
|
||||
// BaseRoundTripper exposes the network transport so policy routers can clone
|
||||
// and rebuild the complete decorator graph without dropping risk control.
|
||||
func (t *Transport) BaseRoundTripper() http.RoundTripper {
|
||||
if t == nil || t.next == nil {
|
||||
return internaltransport.Fallback()
|
||||
}
|
||||
return t.next
|
||||
}
|
||||
|
||||
// WithBaseRoundTripper returns an equivalent risk-control boundary over base.
|
||||
func (t *Transport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
if t == nil {
|
||||
return NewTransport(base, nil)
|
||||
}
|
||||
cloned := *t
|
||||
if base == nil {
|
||||
base = internaltransport.Fallback()
|
||||
}
|
||||
cloned.next = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package transport owns how the CLI assembles its outbound HTTP transport: the
|
||||
// shared base RoundTripper (Shared/Fallback/NewHTTPClient), the LARK_CLI_NO_PROXY
|
||||
// shared base RoundTripper (Shared/Fallback and the HTTP client constructors), the LARK_CLI_NO_PROXY
|
||||
// direct-egress clone, and the ~/.lark-cli/proxy_config.json proxy-plugin mode.
|
||||
//
|
||||
// Proxy-plugin mode forces all outbound HTTP(S) requests through a fixed loopback
|
||||
|
||||
258
internal/transport/default_client.go
Normal file
258
internal/transport/default_client.go
Normal file
@@ -0,0 +1,258 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
type requestMatcher func(*http.Request) bool
|
||||
type transportPolicyBuilder func(http.RoundTripper) http.RoundTripper
|
||||
|
||||
type sdkBootstrapRedirectContextKey struct{}
|
||||
|
||||
var (
|
||||
// larkws pins this client during package initialization.
|
||||
sdkBootstrapHTTPClient = http.DefaultClient
|
||||
installDefaultClientMu sync.Mutex
|
||||
)
|
||||
|
||||
// sdkBootstrapTransport applies the platform HTTP policy only to dependency
|
||||
// bootstrap requests selected by match. Unmatched DefaultClient traffic is
|
||||
// delegated directly to the previous transport.
|
||||
type sdkBootstrapTransport struct {
|
||||
base http.RoundTripper
|
||||
match requestMatcher
|
||||
buildPlatformPolicy transportPolicyBuilder
|
||||
|
||||
policyMu sync.RWMutex
|
||||
}
|
||||
|
||||
func (t *sdkBootstrapTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if !t.isBootstrapRequest(req) {
|
||||
return t.fallbackTransport().RoundTrip(req)
|
||||
}
|
||||
|
||||
base := t.base
|
||||
if base == nil {
|
||||
// Resolve Shared lazily so bridge installation never initializes
|
||||
// workspace-scoped proxy state ahead of workspace selection.
|
||||
base = Shared()
|
||||
}
|
||||
buildPlatformPolicy := t.platformPolicyBuilder()
|
||||
if buildPlatformPolicy == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"SDK bootstrap transport policy is not configured",
|
||||
)
|
||||
}
|
||||
base = buildPlatformPolicy(base)
|
||||
if base == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"SDK bootstrap transport policy returned a nil transport",
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve extensions per hop so redirects retain platform policy.
|
||||
extended := WrapWithExtensionForClass(base, exttransport.RequestClassPlatform)
|
||||
guarded := &sameOriginRedirectTransport{base: extended}
|
||||
return guarded.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (t *sdkBootstrapTransport) platformPolicyBuilder() transportPolicyBuilder {
|
||||
t.policyMu.RLock()
|
||||
defer t.policyMu.RUnlock()
|
||||
return t.buildPlatformPolicy
|
||||
}
|
||||
|
||||
func (t *sdkBootstrapTransport) setPlatformPolicyBuilder(build transportPolicyBuilder) {
|
||||
t.policyMu.Lock()
|
||||
t.buildPlatformPolicy = build
|
||||
t.policyMu.Unlock()
|
||||
}
|
||||
|
||||
func (t *sdkBootstrapTransport) isBootstrapRequest(req *http.Request) bool {
|
||||
if req == nil {
|
||||
return false
|
||||
}
|
||||
if _, redirected := req.Context().Value(sdkBootstrapRedirectContextKey{}).(struct{}); redirected {
|
||||
return true
|
||||
}
|
||||
return t.match != nil && t.match(req)
|
||||
}
|
||||
|
||||
func (t *sdkBootstrapTransport) fallbackTransport() http.RoundTripper {
|
||||
if t.base != nil {
|
||||
return t.base
|
||||
}
|
||||
// Preserve net/http's dynamic nil-Transport fallback.
|
||||
return http.DefaultTransport
|
||||
}
|
||||
|
||||
// sameOriginRedirectTransport rejects redirects before net/http can replay a
|
||||
// bootstrap request to a different logical origin.
|
||||
type sameOriginRedirectTransport struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *sameOriginRedirectTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := t.base.RoundTrip(req)
|
||||
if err != nil || resp == nil || !isFollowedRedirect(resp.StatusCode) {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
location := resp.Header.Get("Location")
|
||||
if location == "" {
|
||||
return resp, nil
|
||||
}
|
||||
target, parseErr := req.URL.Parse(location)
|
||||
if parseErr != nil {
|
||||
if resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"platform request returned an invalid redirect location: %v",
|
||||
parseErr,
|
||||
).WithCause(parseErr)
|
||||
}
|
||||
if sameOrigin(req.URL, target) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
return nil, errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"platform bootstrap blocked cross-origin redirect from %q to %q",
|
||||
originName(req.URL),
|
||||
originName(target),
|
||||
)
|
||||
}
|
||||
|
||||
// sdkBootstrapRedirectPolicy preserves the prior hook and marks each redirect hop.
|
||||
func sdkBootstrapRedirectPolicy(
|
||||
match requestMatcher,
|
||||
previous func(*http.Request, []*http.Request) error,
|
||||
) func(*http.Request, []*http.Request) error {
|
||||
return func(req *http.Request, via []*http.Request) error {
|
||||
if previous != nil {
|
||||
if err := previous(req, via); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if len(via) >= 10 {
|
||||
// Retain net/http's default redirect limit.
|
||||
return errs.NewNetworkError(
|
||||
errs.SubtypeNetworkTransport,
|
||||
"stopped after 10 redirects",
|
||||
)
|
||||
}
|
||||
|
||||
if req == nil || len(via) == 0 || match == nil || !match(via[0]) {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.WithValue(req.Context(), sdkBootstrapRedirectContextKey{}, struct{}{})
|
||||
*req = *req.WithContext(ctx)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func originName(candidate *url.URL) string {
|
||||
if candidate == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(candidate.Scheme) + "://" + candidate.Host
|
||||
}
|
||||
|
||||
func sameOrigin(left, right *url.URL) bool {
|
||||
if left == nil || right == nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(left.Scheme, right.Scheme) &&
|
||||
strings.EqualFold(left.Hostname(), right.Hostname()) &&
|
||||
originPort(left) == originPort(right)
|
||||
}
|
||||
|
||||
func originPort(candidate *url.URL) string {
|
||||
if port := candidate.Port(); port != "" {
|
||||
return port
|
||||
}
|
||||
switch strings.ToLower(candidate.Scheme) {
|
||||
case "http":
|
||||
return "80"
|
||||
case "https":
|
||||
return "443"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func isFollowedRedirect(status int) bool {
|
||||
switch status {
|
||||
case http.StatusMovedPermanently,
|
||||
http.StatusFound,
|
||||
http.StatusSeeOther,
|
||||
http.StatusTemporaryRedirect,
|
||||
http.StatusPermanentRedirect:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// InstallSDKTransportBridge wraps larkws's captured HTTP bootstrap client. All
|
||||
// requests through that client hit the bridge, but only matched bootstrap
|
||||
// traffic uses platform policy. The SDK owns the subsequent WebSocket dial,
|
||||
// which does not use this net/http transport.
|
||||
func InstallSDKTransportBridge(buildPlatformPolicy func(http.RoundTripper) http.RoundTripper) {
|
||||
installDefaultClientMu.Lock()
|
||||
defer installDefaultClientMu.Unlock()
|
||||
installSDKTransportBridge(
|
||||
sdkBootstrapHTTPClient,
|
||||
isSDKWebSocketBootstrapRequest,
|
||||
buildPlatformPolicy,
|
||||
)
|
||||
}
|
||||
|
||||
func isSDKWebSocketBootstrapRequest(req *http.Request) bool {
|
||||
return req != nil &&
|
||||
req.Method == http.MethodPost &&
|
||||
core.IsPlatformEndpointURL(req.URL) &&
|
||||
req.URL.Path == larkws.GenEndpointUri
|
||||
}
|
||||
|
||||
func installSDKTransportBridge(
|
||||
client *http.Client,
|
||||
match requestMatcher,
|
||||
buildPlatformPolicy transportPolicyBuilder,
|
||||
) {
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
if existing, ok := client.Transport.(*sdkBootstrapTransport); ok {
|
||||
existing.setPlatformPolicyBuilder(buildPlatformPolicy)
|
||||
return
|
||||
}
|
||||
base := client.Transport
|
||||
previousRedirect := client.CheckRedirect
|
||||
client.Transport = &sdkBootstrapTransport{
|
||||
base: base,
|
||||
match: match,
|
||||
buildPlatformPolicy: buildPlatformPolicy,
|
||||
}
|
||||
client.CheckRedirect = sdkBootstrapRedirectPolicy(match, previousRedirect)
|
||||
}
|
||||
120
internal/transport/extension.go
Normal file
120
internal/transport/extension.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
var _ RoundTripperDecorator = (*ExtensionMiddleware)(nil)
|
||||
|
||||
type resolvedExtension struct {
|
||||
provider exttransport.Provider
|
||||
interceptor exttransport.Interceptor
|
||||
}
|
||||
|
||||
func resolveExtension() *resolvedExtension {
|
||||
p := exttransport.GetProvider()
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
interceptor := p.ResolveInterceptor(context.Background())
|
||||
if interceptor == nil {
|
||||
return nil
|
||||
}
|
||||
return &resolvedExtension{provider: p, interceptor: interceptor}
|
||||
}
|
||||
|
||||
func (e *resolvedExtension) wrap(base http.RoundTripper, class exttransport.RequestClass, enforceScope bool) http.RoundTripper {
|
||||
if base == nil {
|
||||
base = Shared()
|
||||
}
|
||||
if e == nil {
|
||||
return base
|
||||
}
|
||||
if enforceScope {
|
||||
if scoped, ok := e.provider.(exttransport.ScopedProvider); ok && !scoped.SupportsRequestClass(class) {
|
||||
return base
|
||||
}
|
||||
}
|
||||
return &ExtensionMiddleware{Base: base, Ext: e.interceptor, ExtName: e.provider.Name()}
|
||||
}
|
||||
|
||||
// ExtensionMiddleware wraps the built-in transport chain with extension
|
||||
// pre/post hooks. The built-in chain always executes unless an
|
||||
// exttransport.AbortableInterceptor rejects the request.
|
||||
//
|
||||
// The original request context is restored after the pre hook to prevent an
|
||||
// extension from replacing cancellation, deadlines, or built-in values. The
|
||||
// request is cloned so URL and header mutations do not alter the caller's
|
||||
// request object. The body remains shared; interceptors that consume it must
|
||||
// restore it before returning.
|
||||
type ExtensionMiddleware struct {
|
||||
Base http.RoundTripper
|
||||
Ext exttransport.Interceptor
|
||||
ExtName string
|
||||
}
|
||||
|
||||
// BaseRoundTripper returns the wrapped built-in transport chain.
|
||||
func (m *ExtensionMiddleware) BaseRoundTripper() http.RoundTripper {
|
||||
if m.Base == nil {
|
||||
return Shared()
|
||||
}
|
||||
return m.Base
|
||||
}
|
||||
|
||||
// WithBaseRoundTripper clones the middleware over base.
|
||||
func (m *ExtensionMiddleware) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
cloned := *m
|
||||
cloned.Base = base
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// RoundTrip invokes the extension pre hook, the wrapped transport, and then
|
||||
// the optional post hook. Abortable interceptors can stop the request before
|
||||
// the wrapped transport is called.
|
||||
func (m *ExtensionMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
origCtx := req.Context()
|
||||
req = req.Clone(origCtx)
|
||||
|
||||
var (
|
||||
post func(*http.Response, error)
|
||||
abortErr error
|
||||
)
|
||||
if a, ok := m.Ext.(exttransport.AbortableInterceptor); ok {
|
||||
post, abortErr = a.PreRoundTripE(req)
|
||||
} else {
|
||||
post = m.Ext.PreRoundTrip(req)
|
||||
}
|
||||
if abortErr != nil {
|
||||
if post != nil {
|
||||
post(nil, abortErr)
|
||||
}
|
||||
return nil, &exttransport.AbortError{Extension: m.ExtName, Reason: abortErr}
|
||||
}
|
||||
|
||||
req = req.WithContext(origCtx)
|
||||
resp, err := m.BaseRoundTripper().RoundTrip(req)
|
||||
if post != nil {
|
||||
post(resp, err)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// WrapWithExtension wraps base with the currently registered transport
|
||||
// extension. With no registered provider or no resolved interceptor, base is
|
||||
// returned unchanged.
|
||||
func WrapWithExtension(base http.RoundTripper) http.RoundTripper {
|
||||
return resolveExtension().wrap(base, "", false)
|
||||
}
|
||||
|
||||
// WrapWithExtensionForClass wraps base only when the registered provider
|
||||
// supports class. Providers without the optional ScopedProvider interface keep
|
||||
// their historical all-request behavior.
|
||||
func WrapWithExtensionForClass(base http.RoundTripper, class exttransport.RequestClass) http.RoundTripper {
|
||||
return resolveExtension().wrap(base, class, true)
|
||||
}
|
||||
924
internal/transport/extension_test.go
Normal file
924
internal/transport/extension_test.go
Normal file
@@ -0,0 +1,924 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
type testProvider struct {
|
||||
interceptor exttransport.Interceptor
|
||||
resolveCalls *int
|
||||
}
|
||||
|
||||
func (p testProvider) Name() string { return "test-provider" }
|
||||
|
||||
func (p testProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
|
||||
if p.resolveCalls != nil {
|
||||
*p.resolveCalls++
|
||||
}
|
||||
return p.interceptor
|
||||
}
|
||||
|
||||
type scopedTestProvider struct {
|
||||
testProvider
|
||||
supported exttransport.RequestClass
|
||||
}
|
||||
|
||||
func (p scopedTestProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
|
||||
return class == p.supported
|
||||
}
|
||||
|
||||
type testHeaderInterceptor struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (i *testHeaderInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
i.calls++
|
||||
req.Header.Set("X-Test-Platform", "routed")
|
||||
return nil
|
||||
}
|
||||
|
||||
type abortingTestInterceptor struct {
|
||||
reason error
|
||||
post func(*http.Response, error)
|
||||
}
|
||||
|
||||
func (i *abortingTestInterceptor) PreRoundTrip(*http.Request) func(*http.Response, error) {
|
||||
panic("PreRoundTrip called for abortable interceptor")
|
||||
}
|
||||
|
||||
func (i *abortingTestInterceptor) PreRoundTripE(*http.Request) (func(*http.Response, error), error) {
|
||||
return i.post, i.reason
|
||||
}
|
||||
|
||||
func TestLegacyProviderKeepsAllRequestBehavior(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
|
||||
interceptor := &testHeaderInterceptor{}
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(testProvider{interceptor: interceptor})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
received := make(chan string, 2)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
received <- req.Header.Get("X-Test-Platform")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
for _, client := range []*http.Client{
|
||||
ClientForRequestClass(NewHTTPClient(0), exttransport.RequestClassPlatform),
|
||||
NewExternalHTTPClient(0),
|
||||
} {
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
if got := <-received; got != "routed" {
|
||||
t.Fatalf("platform request header = %q, want routed", got)
|
||||
}
|
||||
if got := <-received; got != "routed" {
|
||||
t.Fatalf("external request header = %q, want routed for legacy provider", got)
|
||||
}
|
||||
if interceptor.calls != 2 {
|
||||
t.Fatalf("extension calls = %d, want exactly 2", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedProviderOnlyRunsForSupportedRequestClass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
|
||||
interceptor := &testHeaderInterceptor{}
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
received := make(chan string, 2)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
received <- req.Header.Get("X-Test-Platform")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
clients := []*http.Client{
|
||||
ClientForRequestClass(NewHTTPClient(0), exttransport.RequestClassPlatform),
|
||||
NewExternalHTTPClient(0),
|
||||
}
|
||||
for _, client := range clients {
|
||||
resp, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
if got := <-received; got != "routed" {
|
||||
t.Fatalf("platform request header = %q, want routed", got)
|
||||
}
|
||||
if got := <-received; got != "" {
|
||||
t.Fatalf("external request received scoped provider header %q", got)
|
||||
}
|
||||
if interceptor.calls != 1 {
|
||||
t.Fatalf("extension calls = %d, want exactly 1", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterResolvesProviderOnce(t *testing.T) {
|
||||
resolveCalls := 0
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(testProvider{
|
||||
interceptor: &testHeaderInterceptor{},
|
||||
resolveCalls: &resolveCalls,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
})
|
||||
_ = NewHTTPPolicyRouter(base, base)
|
||||
|
||||
if resolveCalls != 1 {
|
||||
t.Fatalf("ResolveInterceptor() calls = %d, want 1 per router", resolveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapBridgeBlocksCrossOriginRedirectAfterSameOriginHop(t *testing.T) {
|
||||
var externalCalls atomic.Int32
|
||||
var relayBody string
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Host == "external.example" {
|
||||
externalCalls.Add(1)
|
||||
return noContentResponse(req), nil
|
||||
}
|
||||
switch req.URL.Path {
|
||||
case "/bootstrap":
|
||||
return redirectResponse(req, http.StatusTemporaryRedirect, "/relay"), nil
|
||||
case "/relay":
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
relayBody = string(body)
|
||||
return redirectResponse(
|
||||
req,
|
||||
http.StatusPermanentRedirect,
|
||||
"https://external.example/target",
|
||||
), nil
|
||||
default:
|
||||
return noContentResponse(req), nil
|
||||
}
|
||||
})
|
||||
|
||||
client := &http.Client{Transport: base}
|
||||
installSDKTransportBridge(client, func(req *http.Request) bool {
|
||||
return req.URL != nil && req.URL.Path == "/bootstrap"
|
||||
}, identityTransportPolicy)
|
||||
|
||||
const secret = "app_secret=secret"
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://platform.example/bootstrap",
|
||||
strings.NewReader(secret),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
|
||||
t.Fatalf("Do() error = %v, want cross-origin redirect rejection", err)
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryPolicy ||
|
||||
problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("Do() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if relayBody != secret {
|
||||
t.Fatalf("same-origin relay body = %q, want %q", relayBody, secret)
|
||||
}
|
||||
if got := externalCalls.Load(); got != 0 {
|
||||
t.Fatalf("cross-origin target calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapRedirectGuardClassifiesInvalidLocation(t *testing.T) {
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return redirectResponse(req, http.StatusFound, "%"), nil
|
||||
})
|
||||
client := &http.Client{Transport: &sameOriginRedirectTransport{base: base}}
|
||||
resp, err := client.Get("https://platform.example/bootstrap")
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid redirect location") {
|
||||
t.Fatalf("Do() error = %v, want invalid redirect rejection", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("Do() problem = %#v, %v; want internal/invalid_response", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
type redirectPolicyInterceptor struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (i *redirectPolicyInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
i.calls++
|
||||
req.Header.Set("X-Extension-Hop", strconv.Itoa(i.calls))
|
||||
req.Header.Set("X-Reserved", "extension")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSDKBootstrapBridgeRetainsPoliciesAcrossSameOriginRedirect(t *testing.T) {
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &redirectPolicyInterceptor{}
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
var finalHeaders http.Header
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Path {
|
||||
case "/bootstrap":
|
||||
return redirectResponse(req, http.StatusTemporaryRedirect, "/next"), nil
|
||||
case "/next":
|
||||
finalHeaders = req.Header.Clone()
|
||||
return noContentResponse(req), nil
|
||||
default:
|
||||
return noContentResponse(req), nil
|
||||
}
|
||||
})
|
||||
|
||||
builtInCalls := 0
|
||||
client := &http.Client{Transport: base}
|
||||
installSDKTransportBridge(
|
||||
client,
|
||||
func(req *http.Request) bool {
|
||||
return req.URL != nil && req.URL.Path == "/bootstrap"
|
||||
},
|
||||
func(base http.RoundTripper) http.RoundTripper {
|
||||
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
builtInCalls++
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set("X-Builtin-Hop", strconv.Itoa(builtInCalls))
|
||||
req.Header.Set("X-Reserved", "trusted")
|
||||
return base.RoundTrip(req)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://platform.example/bootstrap",
|
||||
strings.NewReader("body"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if finalHeaders == nil {
|
||||
t.Fatal("same-origin redirect target was not called")
|
||||
}
|
||||
if interceptor.calls != 2 {
|
||||
t.Fatalf("extension calls = %d, want 2", interceptor.calls)
|
||||
}
|
||||
if builtInCalls != 2 {
|
||||
t.Fatalf("built-in policy calls = %d, want 2", builtInCalls)
|
||||
}
|
||||
if got := finalHeaders.Get("X-Extension-Hop"); got != "2" {
|
||||
t.Fatalf("final X-Extension-Hop = %q, want 2", got)
|
||||
}
|
||||
if got := finalHeaders.Get("X-Builtin-Hop"); got != "2" {
|
||||
t.Fatalf("final X-Builtin-Hop = %q, want 2", got)
|
||||
}
|
||||
if got := finalHeaders.Get("X-Reserved"); got != "trusted" {
|
||||
t.Fatalf("final X-Reserved = %q, want trusted built-in value", got)
|
||||
}
|
||||
}
|
||||
|
||||
type redirectRewriteInterceptor struct {
|
||||
target *url.URL
|
||||
postLocation string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (i *redirectRewriteInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
i.calls++
|
||||
req.URL.Scheme = i.target.Scheme
|
||||
req.URL.Host = i.target.Host
|
||||
if i.postLocation == "" {
|
||||
return nil
|
||||
}
|
||||
return func(resp *http.Response, err error) {
|
||||
if err == nil && resp != nil && isFollowedRedirect(resp.StatusCode) {
|
||||
resp.Header.Set("Location", i.postLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapRedirectGuardUsesLogicalURLAfterExtensionRewrite(t *testing.T) {
|
||||
sidecarURL, err := url.Parse("https://sidecar.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sidecarCalls := 0
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Host != sidecarURL.Host {
|
||||
t.Fatalf("network host = %q, want extension target %q", req.URL.Host, sidecarURL.Host)
|
||||
}
|
||||
sidecarCalls++
|
||||
switch req.URL.Path {
|
||||
case "/bootstrap":
|
||||
return redirectResponse(
|
||||
req,
|
||||
http.StatusTemporaryRedirect,
|
||||
"https://platform.example/next",
|
||||
), nil
|
||||
case "/next":
|
||||
return noContentResponse(req), nil
|
||||
default:
|
||||
return noContentResponse(req), nil
|
||||
}
|
||||
})
|
||||
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &redirectRewriteInterceptor{target: sidecarURL}
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
client := &http.Client{Transport: base}
|
||||
installSDKTransportBridge(client, func(req *http.Request) bool {
|
||||
return req.URL != nil && req.URL.Path == "/bootstrap"
|
||||
}, identityTransportPolicy)
|
||||
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://platform.example/bootstrap",
|
||||
strings.NewReader("body"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if sidecarCalls != 2 {
|
||||
t.Fatalf("sidecar calls = %d, want 2", sidecarCalls)
|
||||
}
|
||||
if interceptor.calls != 2 {
|
||||
t.Fatalf("extension calls = %d, want 2", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapRedirectGuardChecksLocationAfterExtensionPostHook(t *testing.T) {
|
||||
var externalCalls atomic.Int32
|
||||
sidecarURL, err := url.Parse("https://sidecar.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Host == "external.example" {
|
||||
externalCalls.Add(1)
|
||||
return noContentResponse(req), nil
|
||||
}
|
||||
return redirectResponse(
|
||||
req,
|
||||
http.StatusTemporaryRedirect,
|
||||
"https://platform.example/next",
|
||||
), nil
|
||||
})
|
||||
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: &redirectRewriteInterceptor{
|
||||
target: sidecarURL,
|
||||
postLocation: "https://external.example/target",
|
||||
}},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
client := &http.Client{Transport: base}
|
||||
installSDKTransportBridge(client, func(req *http.Request) bool {
|
||||
return req.URL != nil && req.URL.Path == "/bootstrap"
|
||||
}, identityTransportPolicy)
|
||||
req, err := http.NewRequest(
|
||||
http.MethodPost,
|
||||
"https://platform.example/bootstrap",
|
||||
strings.NewReader("secret"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
|
||||
t.Fatalf("Do() error = %v, want post-hook Location rejection", err)
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryPolicy ||
|
||||
problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("Do() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if got := externalCalls.Load(); got != 0 {
|
||||
t.Fatalf("post-hook redirect target calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameOriginNormalizesDefaultPort(t *testing.T) {
|
||||
left, err := url.Parse("https://platform.example/bootstrap")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
right, err := url.Parse("https://platform.example:443/next")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameOrigin(left, right) {
|
||||
t.Fatal("sameOrigin() = false for equivalent default HTTPS ports")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultClientBridgeCoversWebSocketSDKBootstrap(t *testing.T) {
|
||||
preserveHTTPClientState(t, sdkBootstrapHTTPClient)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &testHeaderInterceptor{}
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
seenHeader := make(chan string, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
seenHeader <- req.Header.Get("X-Test-Platform")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = io.WriteString(w, `{"code":400,"msg":"stop after bootstrap"}`)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
installSDKTransportBridge(sdkBootstrapHTTPClient, func(req *http.Request) bool {
|
||||
return req.URL != nil && req.URL.Host == strings.TrimPrefix(server.URL, "http://")
|
||||
}, identityTransportPolicy)
|
||||
|
||||
client := larkws.NewClient(
|
||||
"test-app",
|
||||
"test-secret",
|
||||
larkws.WithDomain(server.URL),
|
||||
larkws.WithAutoReconnect(false),
|
||||
)
|
||||
if err := client.Start(context.Background()); err == nil {
|
||||
t.Fatal("WebSocket SDK Start() error = nil, want bootstrap failure")
|
||||
}
|
||||
if got := <-seenHeader; got != "routed" {
|
||||
t.Fatalf("WebSocket bootstrap header = %q, want routed", got)
|
||||
}
|
||||
if interceptor.calls != 1 {
|
||||
t.Fatalf("extension calls = %d, want exactly 1 bootstrap call", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKTransportBridgeUsesPinnedClientAfterGlobalReplacement(t *testing.T) {
|
||||
preserveHTTPClientState(t, sdkBootstrapHTTPClient)
|
||||
oldDefaultClient := http.DefaultClient
|
||||
t.Cleanup(func() { http.DefaultClient = oldDefaultClient })
|
||||
|
||||
var pinnedCalls atomic.Int32
|
||||
pinnedHeader := make(chan string, 1)
|
||||
sdkBootstrapHTTPClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
pinnedCalls.Add(1)
|
||||
pinnedHeader <- req.Header.Get("X-Pinned-Bridge")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":400,"msg":"stop"}`)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
sdkBootstrapHTTPClient.CheckRedirect = nil
|
||||
|
||||
var replacementCalls atomic.Int32
|
||||
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
replacementCalls.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
|
||||
InstallSDKTransportBridge(func(base http.RoundTripper) http.RoundTripper {
|
||||
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set("X-Pinned-Bridge", "routed")
|
||||
return base.RoundTrip(req)
|
||||
})
|
||||
})
|
||||
|
||||
client := larkws.NewClient(
|
||||
"test-app",
|
||||
"test-secret",
|
||||
larkws.WithAutoReconnect(false),
|
||||
)
|
||||
if err := client.Start(context.Background()); err == nil {
|
||||
t.Fatal("WebSocket SDK Start() error = nil, want bootstrap failure")
|
||||
}
|
||||
if got := pinnedCalls.Load(); got != 1 {
|
||||
t.Fatalf("SDK-pinned client calls = %d, want 1", got)
|
||||
}
|
||||
if got := <-pinnedHeader; got != "routed" {
|
||||
t.Fatalf("SDK-pinned bridge header = %q, want routed", got)
|
||||
}
|
||||
if got := replacementCalls.Load(); got != 0 {
|
||||
t.Fatalf("replacement DefaultClient calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKWebSocketBootstrapMatcherIsNarrow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
url string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "platform bootstrap",
|
||||
method: http.MethodPost,
|
||||
url: "https://open.feishu.cn/callback/ws/endpoint",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "other platform path",
|
||||
method: http.MethodPost,
|
||||
url: "https://open.feishu.cn/open-apis/test",
|
||||
},
|
||||
{
|
||||
name: "wrong bootstrap method",
|
||||
method: http.MethodGet,
|
||||
url: "https://open.feishu.cn/callback/ws/endpoint",
|
||||
},
|
||||
{
|
||||
name: "external lookalike",
|
||||
method: http.MethodPost,
|
||||
url: "https://external.example/callback/ws/endpoint",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req, err := http.NewRequest(tt.method, tt.url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := isSDKWebSocketBootstrapRequest(req); got != tt.want {
|
||||
t.Fatalf("isSDKWebSocketBootstrapRequest() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKTransportBridgeLeavesOtherPlatformPathsUntouched(t *testing.T) {
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &testHeaderInterceptor{}
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
baseCalls := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
baseCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
})}
|
||||
installSDKTransportBridge(client, isSDKWebSocketBootstrapRequest, nil)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if baseCalls != 1 {
|
||||
t.Fatalf("base calls = %d, want 1", baseCalls)
|
||||
}
|
||||
if interceptor.calls != 0 {
|
||||
t.Fatalf("extension calls = %d, want 0 for unmatched DefaultClient traffic", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKTransportBridgeNilBasePreservesDefaultTransportForUnmatchedRequest(t *testing.T) {
|
||||
oldDefaultTransport := http.DefaultTransport
|
||||
t.Cleanup(func() { http.DefaultTransport = oldDefaultTransport })
|
||||
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "1")
|
||||
|
||||
var firstCalls atomic.Int32
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
firstCalls.Add(1)
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
})
|
||||
client := &http.Client{}
|
||||
installSDKTransportBridge(client, func(*http.Request) bool { return false }, nil)
|
||||
|
||||
var currentCalls atomic.Int32
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
currentCalls.Add(1)
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
})
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:1/unmatched", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if got := firstCalls.Load(); got != 0 {
|
||||
t.Fatalf("install-time DefaultTransport calls = %d, want 0", got)
|
||||
}
|
||||
if got := currentCalls.Load(); got != 1 {
|
||||
t.Fatalf("request-time DefaultTransport calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKTransportBridgeUpdatesPlatformPolicy(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return noContentResponse(req), nil
|
||||
})}
|
||||
var firstCalls, secondCalls int
|
||||
build := func(calls *int) transportPolicyBuilder {
|
||||
return func(base http.RoundTripper) http.RoundTripper {
|
||||
*calls++
|
||||
return base
|
||||
}
|
||||
}
|
||||
match := func(*http.Request) bool { return true }
|
||||
installSDKTransportBridge(client, match, build(&firstCalls))
|
||||
installSDKTransportBridge(client, match, build(&secondCalls))
|
||||
req, err := http.NewRequest(http.MethodPost, "https://platform.example/bootstrap", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if firstCalls != 0 || secondCalls != 1 {
|
||||
t.Fatalf("policy calls = (%d, %d), want (0, 1)", firstCalls, secondCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapTransportFailsClosedWithoutPlatformPolicy(t *testing.T) {
|
||||
var baseCalls atomic.Int32
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
baseCalls.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
installSDKTransportBridge(client, func(*http.Request) bool { return true }, nil)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "https://platform.example/bootstrap", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "policy is not configured") {
|
||||
t.Fatalf("Do() error = %v, want missing policy rejection", err)
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryInternal ||
|
||||
problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("Do() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
if got := baseCalls.Load(); got != 0 {
|
||||
t.Fatalf("base transport calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapTransportFailsClosedForNilPlatformTransport(t *testing.T) {
|
||||
var baseCalls atomic.Int32
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
baseCalls.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
installSDKTransportBridge(client, func(*http.Request) bool { return true }, func(http.RoundTripper) http.RoundTripper {
|
||||
return nil
|
||||
})
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "https://platform.example/bootstrap", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "nil transport") {
|
||||
t.Fatalf("Do() error = %v, want nil policy transport rejection", err)
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryInternal ||
|
||||
problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("Do() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
if got := baseCalls.Load(); got != 0 {
|
||||
t.Fatalf("base transport calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSDKBootstrapRedirectPolicyRetainsDefaultLimit(t *testing.T) {
|
||||
policy := sdkBootstrapRedirectPolicy(nil, nil)
|
||||
via := make([]*http.Request, 10)
|
||||
err := policy(&http.Request{}, via)
|
||||
if err == nil {
|
||||
t.Fatal("redirect policy error = nil after 10 redirects")
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryNetwork ||
|
||||
problem.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("redirect problem = %#v, %v; want network/transport", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionMiddlewareUsesFallbackWhenBaseIsNil(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
|
||||
previous := http.DefaultTransport
|
||||
var calls atomic.Int32
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
t.Cleanup(func() { http.DefaultTransport = previous })
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := (&ExtensionMiddleware{Ext: &testHeaderInterceptor{}}).RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("fallback transport calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionMiddlewareAbortsBeforeBase(t *testing.T) {
|
||||
reason := errors.New("blocked")
|
||||
baseCalled := false
|
||||
postCalled := false
|
||||
interceptor := &abortingTestInterceptor{
|
||||
reason: reason,
|
||||
post: func(resp *http.Response, err error) {
|
||||
postCalled = true
|
||||
if resp != nil || err != reason {
|
||||
t.Errorf("post arguments = (%v, %v), want (nil, reason)", resp, err)
|
||||
}
|
||||
},
|
||||
}
|
||||
middleware := &ExtensionMiddleware{
|
||||
Base: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
baseCalled = true
|
||||
return nil, nil
|
||||
}),
|
||||
Ext: interceptor,
|
||||
ExtName: "test-provider",
|
||||
}
|
||||
|
||||
resp, err := middleware.RoundTrip(httptest.NewRequest(http.MethodGet, "https://example.com", nil))
|
||||
if resp != nil {
|
||||
t.Fatalf("response = %v, want nil", resp)
|
||||
}
|
||||
var abortErr *exttransport.AbortError
|
||||
if !errors.As(err, &abortErr) {
|
||||
t.Fatalf("error = %T, want *transport.AbortError", err)
|
||||
}
|
||||
if abortErr.Extension != "test-provider" || abortErr.Reason != reason {
|
||||
t.Fatalf("abort error = %#v, want provider and reason", abortErr)
|
||||
}
|
||||
if baseCalled {
|
||||
t.Fatal("base transport was called")
|
||||
}
|
||||
if !postCalled {
|
||||
t.Fatal("post hook was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func preserveHTTPClientState(t *testing.T, client *http.Client) {
|
||||
t.Helper()
|
||||
oldTransport := client.Transport
|
||||
oldCheckRedirect := client.CheckRedirect
|
||||
t.Cleanup(func() {
|
||||
client.Transport = oldTransport
|
||||
client.CheckRedirect = oldCheckRedirect
|
||||
})
|
||||
}
|
||||
|
||||
func identityTransportPolicy(base http.RoundTripper) http.RoundTripper {
|
||||
return base
|
||||
}
|
||||
|
||||
func redirectResponse(req *http.Request, status int, location string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Location": []string{location}},
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}
|
||||
}
|
||||
|
||||
func noContentResponse(req *http.Request) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
232
internal/transport/policy_router.go
Normal file
232
internal/transport/policy_router.go
Normal file
@@ -0,0 +1,232 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
type requestClassContextKey struct{}
|
||||
type forcedRequestClassContextKey struct{}
|
||||
|
||||
// HTTPPolicyRouter selects an HTTP transport policy from request intent and
|
||||
// the endpoint catalog. Explicit request intent takes precedence; otherwise
|
||||
// known platform endpoints use the platform policy and all other URLs use the
|
||||
// external policy.
|
||||
type HTTPPolicyRouter struct {
|
||||
platform http.RoundTripper
|
||||
external http.RoundTripper
|
||||
}
|
||||
|
||||
// RoundTripperDecorator describes a transport layer that can be rebuilt over
|
||||
// a cloned base transport. Connection-policy helpers use this contract to
|
||||
// preserve retry, response, and extension layers while safely customizing the
|
||||
// innermost *http.Transport.
|
||||
type RoundTripperDecorator interface {
|
||||
BaseRoundTripper() http.RoundTripper
|
||||
WithBaseRoundTripper(http.RoundTripper) http.RoundTripper
|
||||
}
|
||||
|
||||
// NewHTTPPolicyRouter constructs a router over two policy chains. A nil chain
|
||||
// falls back to the shared proxy-aware transport. The currently registered
|
||||
// extension provider is resolved once and applied according to its optional
|
||||
// ScopedProvider contract.
|
||||
func NewHTTPPolicyRouter(platform, external http.RoundTripper) *HTTPPolicyRouter {
|
||||
if platform == nil {
|
||||
platform = Shared()
|
||||
}
|
||||
if external == nil {
|
||||
external = Shared()
|
||||
}
|
||||
|
||||
extension := resolveExtension()
|
||||
return &HTTPPolicyRouter{
|
||||
platform: extension.wrap(platform, exttransport.RequestClassPlatform, true),
|
||||
external: extension.wrap(external, exttransport.RequestClassExternal, true),
|
||||
}
|
||||
}
|
||||
|
||||
// RoundTrip dispatches the request to its selected policy chain.
|
||||
func (r *HTTPPolicyRouter) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"HTTP policy router received a nil request",
|
||||
)
|
||||
}
|
||||
class, err := classifyRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if class == exttransport.RequestClassPlatform {
|
||||
return r.platform.RoundTrip(req)
|
||||
}
|
||||
return r.external.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (r *HTTPPolicyRouter) transportForClass(class exttransport.RequestClass) (http.RoundTripper, bool) {
|
||||
switch class {
|
||||
case exttransport.RequestClassPlatform:
|
||||
return r.platform, true
|
||||
case exttransport.RequestClassExternal:
|
||||
return r.external, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func classifyRequest(req *http.Request) (exttransport.RequestClass, error) {
|
||||
if explicit, ok := req.Context().Value(requestClassContextKey{}).(exttransport.RequestClass); ok {
|
||||
switch explicit {
|
||||
case exttransport.RequestClassPlatform, exttransport.RequestClassExternal:
|
||||
return explicit, nil
|
||||
default:
|
||||
return "", errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"unsupported HTTP request class %q",
|
||||
explicit,
|
||||
)
|
||||
}
|
||||
}
|
||||
if core.IsPlatformEndpointURL(req.URL) {
|
||||
return exttransport.RequestClassPlatform, nil
|
||||
}
|
||||
return exttransport.RequestClassExternal, nil
|
||||
}
|
||||
|
||||
// WithRequestClass returns a shallow copy of req with explicit routing intent.
|
||||
func WithRequestClass(req *http.Request, class exttransport.RequestClass) *http.Request {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
ctx := context.WithValue(req.Context(), requestClassContextKey{}, class)
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func withForcedRequestClass(req *http.Request, class exttransport.RequestClass) *http.Request {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
if _, forced := req.Context().Value(forcedRequestClassContextKey{}).(struct{}); forced {
|
||||
return req
|
||||
}
|
||||
ctx := context.WithValue(req.Context(), requestClassContextKey{}, class)
|
||||
ctx = context.WithValue(ctx, forcedRequestClassContextKey{}, struct{}{})
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
type requestClassTransport struct {
|
||||
base http.RoundTripper
|
||||
class exttransport.RequestClass
|
||||
}
|
||||
|
||||
func (t *requestClassTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return t.base.RoundTrip(withForcedRequestClass(req, t.class))
|
||||
}
|
||||
|
||||
// CloneHTTPTransport exposes a structural cloning capability without requiring
|
||||
// higher-level safety helpers to import this package. The explicit request
|
||||
// class selects the policy branch that must be rebuilt.
|
||||
func (t *requestClassTransport) CloneHTTPTransport() (http.RoundTripper, *http.Transport, bool) {
|
||||
return CloneHTTPTransportForRequestClass(t.base, t.class)
|
||||
}
|
||||
|
||||
// TransformHTTPTransport clones the selected policy branch and replaces its
|
||||
// concrete transport in place. Keeping the replacement at the graph leaf is
|
||||
// important for policies that must observe requests after outer decorators
|
||||
// have run, such as proxy selection.
|
||||
func (t *requestClassTransport) TransformHTTPTransport(transform func(*http.Transport) (http.RoundTripper, bool)) (http.RoundTripper, bool) {
|
||||
return transformHTTPTransportForRequestClass(t.base, t.class, transform, 0)
|
||||
}
|
||||
|
||||
// ClientForRequestClass clones client and forces all of its requests through a
|
||||
// specific policy class. The original client is never mutated.
|
||||
func ClientForRequestClass(client *http.Client, class exttransport.RequestClass) *http.Client {
|
||||
if client == nil {
|
||||
client = &http.Client{}
|
||||
}
|
||||
cloned := *client
|
||||
base := client.Transport
|
||||
if base == nil {
|
||||
base = Shared()
|
||||
}
|
||||
cloned.Transport = &requestClassTransport{base: base, class: class}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// CloneHTTPTransportForRequestClass selects one policy branch, clones its
|
||||
// innermost *http.Transport, and rebuilds every composable decorator around
|
||||
// the clone. Callers can customize concrete before using rebuilt. The original
|
||||
// transport graph is never mutated.
|
||||
func CloneHTTPTransportForRequestClass(base http.RoundTripper, class exttransport.RequestClass) (rebuilt http.RoundTripper, concrete *http.Transport, ok bool) {
|
||||
rebuilt, ok = transformHTTPTransportForRequestClass(base, class, func(cloned *http.Transport) (http.RoundTripper, bool) {
|
||||
concrete = cloned
|
||||
return cloned, true
|
||||
}, 0)
|
||||
if !ok {
|
||||
return nil, nil, false
|
||||
}
|
||||
return rebuilt, concrete, true
|
||||
}
|
||||
|
||||
func transformHTTPTransportForRequestClass(
|
||||
base http.RoundTripper,
|
||||
class exttransport.RequestClass,
|
||||
transform func(*http.Transport) (http.RoundTripper, bool),
|
||||
depth int,
|
||||
) (http.RoundTripper, bool) {
|
||||
if depth > 32 {
|
||||
return nil, false
|
||||
}
|
||||
if base == nil || transform == nil {
|
||||
if transform == nil {
|
||||
return nil, false
|
||||
}
|
||||
base = Shared()
|
||||
}
|
||||
|
||||
switch current := base.(type) {
|
||||
case *http.Transport:
|
||||
cloned := cloneHTTPTransport(current)
|
||||
rebuilt, valid := transform(cloned)
|
||||
return rebuilt, valid && rebuilt != nil
|
||||
case *requestClassTransport:
|
||||
return transformHTTPTransportForRequestClass(current.base, class, transform, depth+1)
|
||||
case *HTTPPolicyRouter:
|
||||
selected, valid := current.transportForClass(class)
|
||||
if !valid {
|
||||
return nil, false
|
||||
}
|
||||
return transformHTTPTransportForRequestClass(selected, class, transform, depth+1)
|
||||
case RoundTripperDecorator:
|
||||
inner := current.BaseRoundTripper()
|
||||
if inner == nil || inner == base {
|
||||
return nil, false
|
||||
}
|
||||
rebuiltInner, valid := transformHTTPTransportForRequestClass(inner, class, transform, depth+1)
|
||||
if !valid {
|
||||
return nil, false
|
||||
}
|
||||
rebuilt := current.WithBaseRoundTripper(rebuiltInner)
|
||||
return rebuilt, rebuilt != nil
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func cloneHTTPTransport(source *http.Transport) *http.Transport {
|
||||
cloned := source.Clone()
|
||||
// Clone leaves an auto-configured h2 handler on source.
|
||||
if cloned.TLSNextProto == nil {
|
||||
if _, ok := source.TLSNextProto["h2"]; ok {
|
||||
cloned.ForceAttemptHTTP2 = true
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
351
internal/transport/policy_router_test.go
Normal file
351
internal/transport/policy_router_test.go
Normal file
@@ -0,0 +1,351 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package transport
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
type cloneTestDecorator struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (d *cloneTestDecorator) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return d.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (d *cloneTestDecorator) BaseRoundTripper() http.RoundTripper {
|
||||
return d.base
|
||||
}
|
||||
|
||||
func (d *cloneTestDecorator) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
return &cloneTestDecorator{base: base}
|
||||
}
|
||||
|
||||
type headerCloneTestDecorator struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (d *headerCloneTestDecorator) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.Header.Set("X-Decorator", "applied")
|
||||
return d.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (d *headerCloneTestDecorator) BaseRoundTripper() http.RoundTripper {
|
||||
return d.base
|
||||
}
|
||||
|
||||
func (d *headerCloneTestDecorator) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
|
||||
return &headerCloneTestDecorator{base: base}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterClassifiesFromEndpointCatalog(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
|
||||
platformCalls := 0
|
||||
externalCalls := 0
|
||||
router := NewHTTPPolicyRouter(
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
platformCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
externalCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
)
|
||||
|
||||
for _, rawURL := range []string{
|
||||
"https://open.feishu.cn/open-apis/test",
|
||||
"https://example.com/file",
|
||||
} {
|
||||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := router.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
if platformCalls != 1 || externalCalls != 1 {
|
||||
t.Fatalf("platform calls = %d, external calls = %d; want 1 each", platformCalls, externalCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterExplicitClassOverridesCatalog(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
|
||||
platformCalls := 0
|
||||
externalCalls := 0
|
||||
router := NewHTTPPolicyRouter(
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
platformCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
externalCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = WithRequestClass(req, exttransport.RequestClassExternal)
|
||||
resp, err := router.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if platformCalls != 0 || externalCalls != 1 {
|
||||
t.Fatalf("platform calls = %d, external calls = %d; want 0 and 1", platformCalls, externalCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientForRequestClassOutermostIntentWins(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
platformCalls := 0
|
||||
externalCalls := 0
|
||||
router := NewHTTPPolicyRouter(
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
platformCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
externalCalls++
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}),
|
||||
)
|
||||
|
||||
platform := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassPlatform)
|
||||
external := ClientForRequestClass(platform, exttransport.RequestClassExternal)
|
||||
resp, err := external.Get("https://open.feishu.cn/open-apis/test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if platformCalls != 0 || externalCalls != 1 {
|
||||
t.Fatalf("platform calls = %d, external calls = %d; want outer external intent to win", platformCalls, externalCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterRejectsInvalidExplicitClass(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
router := NewHTTPPolicyRouter(nil, nil)
|
||||
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = WithRequestClass(req, exttransport.RequestClass("invalid"))
|
||||
if _, err := router.RoundTrip(req); err == nil || !strings.Contains(err.Error(), "unsupported HTTP request class") {
|
||||
t.Fatalf("RoundTrip() error = %v, want unsupported request class", err)
|
||||
} else if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryInternal ||
|
||||
problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterRejectsNilRequest(t *testing.T) {
|
||||
router := NewHTTPPolicyRouter(nil, nil)
|
||||
_, err := router.RoundTrip(nil)
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPolicyRouterReclassifiesRedirectTargets(t *testing.T) {
|
||||
interceptor := &testHeaderInterceptor{}
|
||||
exttransport.Register(scopedTestProvider{
|
||||
testProvider: testProvider{interceptor: interceptor},
|
||||
supported: exttransport.RequestClassPlatform,
|
||||
})
|
||||
t.Cleanup(func() { exttransport.Register(nil) })
|
||||
|
||||
receivedHeader := make(chan string, 1)
|
||||
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
receivedHeader <- req.Header.Get("X-Test-Platform")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(external.Close)
|
||||
|
||||
router := NewHTTPPolicyRouter(
|
||||
roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusFound,
|
||||
Header: http.Header{"Location": []string{external.URL}},
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
}),
|
||||
http.DefaultTransport,
|
||||
)
|
||||
client := &http.Client{Transport: router}
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if got := <-receivedHeader; got != "" {
|
||||
t.Fatalf("redirect target received platform-scoped header %q", got)
|
||||
}
|
||||
if interceptor.calls != 1 {
|
||||
t.Fatalf("extension calls = %d, want only the initial platform request", interceptor.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneHTTPTransportForRequestClassRebuildsDecorators(t *testing.T) {
|
||||
wantErr := errors.New("preserved proxy policy")
|
||||
base := &http.Transport{
|
||||
Proxy: func(*http.Request) (*url.URL, error) {
|
||||
return nil, wantErr
|
||||
},
|
||||
}
|
||||
decorated := &cloneTestDecorator{base: base}
|
||||
router := NewHTTPPolicyRouter(decorated, decorated)
|
||||
|
||||
rebuilt, concrete, ok := CloneHTTPTransportForRequestClass(router, exttransport.RequestClassExternal)
|
||||
if !ok {
|
||||
t.Fatal("CloneHTTPTransportForRequestClass() ok = false")
|
||||
}
|
||||
if concrete == base {
|
||||
t.Fatal("CloneHTTPTransportForRequestClass() reused the original *http.Transport")
|
||||
}
|
||||
if _, ok := rebuilt.(*cloneTestDecorator); !ok {
|
||||
t.Fatalf("rebuilt transport type = %T, want *cloneTestDecorator", rebuilt)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rebuilt.RoundTrip(req); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("RoundTrip() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneHTTPTransportForRequestClassPreservesAutomaticHTTP2(t *testing.T) {
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(nil)
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
source := &http.Transport{
|
||||
Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"}),
|
||||
}
|
||||
router := NewHTTPPolicyRouter(&http.Transport{}, source)
|
||||
|
||||
_, cloned, ok := CloneHTTPTransportForRequestClass(router, exttransport.RequestClassExternal)
|
||||
if !ok {
|
||||
t.Fatal("CloneHTTPTransportForRequestClass() ok = false")
|
||||
}
|
||||
if !cloned.ForceAttemptHTTP2 {
|
||||
t.Fatal("ForceAttemptHTTP2 = false, want true")
|
||||
}
|
||||
if cloned.TLSNextProto != nil {
|
||||
t.Fatal("TLSNextProto is non-nil, want automatic HTTP/2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneHTTPTransportForRequestClassKeepsOutermostIntent(t *testing.T) {
|
||||
platformErr := errors.New("platform transport")
|
||||
externalErr := errors.New("external transport")
|
||||
newBlocked := func(reason error) *http.Transport {
|
||||
return &http.Transport{Proxy: func(*http.Request) (*url.URL, error) { return nil, reason }}
|
||||
}
|
||||
router := NewHTTPPolicyRouter(newBlocked(platformErr), newBlocked(externalErr))
|
||||
platform := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassPlatform)
|
||||
external := ClientForRequestClass(platform, exttransport.RequestClassExternal)
|
||||
|
||||
source, ok := external.Transport.(interface {
|
||||
CloneHTTPTransport() (http.RoundTripper, *http.Transport, bool)
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("transport type %T has no clone capability", external.Transport)
|
||||
}
|
||||
rebuilt, _, ok := source.CloneHTTPTransport()
|
||||
if !ok {
|
||||
t.Fatal("CloneHTTPTransport() ok = false")
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rebuilt.RoundTrip(req); !errors.Is(err, externalErr) {
|
||||
t.Fatalf("RoundTrip() error = %v, want outer external transport error %v", err, externalErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientForRequestClassOverridesCallerIntent(t *testing.T) {
|
||||
platformErr := errors.New("platform transport")
|
||||
externalErr := errors.New("external transport")
|
||||
newBlocked := func(reason error) *http.Transport {
|
||||
return &http.Transport{Proxy: func(*http.Request) (*url.URL, error) { return nil, reason }}
|
||||
}
|
||||
router := NewHTTPPolicyRouter(newBlocked(platformErr), newBlocked(externalErr))
|
||||
client := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassExternal)
|
||||
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = WithRequestClass(req, exttransport.RequestClassPlatform)
|
||||
|
||||
if _, err := client.Do(req); !errors.Is(err, externalErr) {
|
||||
t.Fatalf("Do() error = %v, want forced external transport error %v", err, externalErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransformHTTPTransportReplacesLeafInsideDecorators(t *testing.T) {
|
||||
exttransport.Register(nil)
|
||||
decorated := &headerCloneTestDecorator{base: &http.Transport{}}
|
||||
router := NewHTTPPolicyRouter(decorated, decorated)
|
||||
client := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassExternal)
|
||||
|
||||
source, ok := client.Transport.(interface {
|
||||
TransformHTTPTransport(func(*http.Transport) (http.RoundTripper, bool)) (http.RoundTripper, bool)
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("transport type %T has no transform capability", client.Transport)
|
||||
}
|
||||
rebuilt, ok := source.TransformHTTPTransport(func(*http.Transport) (http.RoundTripper, bool) {
|
||||
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if got := req.Header.Get("X-Decorator"); got != "applied" {
|
||||
t.Fatalf("leaf received X-Decorator = %q, want applied", got)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}), true
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("TransformHTTPTransport() ok = false")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := rebuilt.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
// Shared returns the base http.RoundTripper for all CLI HTTP clients.
|
||||
@@ -55,21 +57,29 @@ func Fallback() *http.Transport {
|
||||
return noProxyTransport()
|
||||
}
|
||||
|
||||
// NewHTTPClient returns an *http.Client whose Transport is the shared,
|
||||
// proxy-plugin-aware base (see Shared). Prefer this over a bare &http.Client{}
|
||||
// for outbound requests: a bare client falls back to http.DefaultTransport and
|
||||
// therefore silently bypasses proxy plugin mode (fixed proxy + trusted CA, or
|
||||
// fail-closed), creating an audit blind spot.
|
||||
// NewHTTPClient returns a policy-routed client over the shared proxy-aware
|
||||
// transport. Known platform endpoints use the platform request class; all
|
||||
// other URLs use the external request class. Existing unscoped transport
|
||||
// providers continue to apply to both classes.
|
||||
//
|
||||
// A zero timeout means no client-level timeout (callers relying on context
|
||||
// deadlines pass 0).
|
||||
func NewHTTPClient(timeout time.Duration) *http.Client {
|
||||
base := Shared()
|
||||
return &http.Client{
|
||||
Transport: Shared(),
|
||||
Transport: NewHTTPPolicyRouter(base, base),
|
||||
Timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// NewExternalHTTPClient returns a client for user-provided, pre-signed, CDN,
|
||||
// package-registry, and other non-platform URLs. It forces the external policy
|
||||
// while preserving the shared proxy configuration and the historical behavior
|
||||
// of unscoped transport providers. A zero timeout means no client-level timeout.
|
||||
func NewExternalHTTPClient(timeout time.Duration) *http.Client {
|
||||
return ClientForRequestClass(NewHTTPClient(timeout), exttransport.RequestClassExternal)
|
||||
}
|
||||
|
||||
// noProxyTransport is a proxy-disabled clone of http.DefaultTransport, lazily
|
||||
// built the first time LARK_CLI_NO_PROXY is observed set.
|
||||
var noProxyTransport = sync.OnceValue(func() *http.Transport {
|
||||
|
||||
@@ -88,23 +88,24 @@ func TestShared_NoProxyOverridesSystemProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewHTTPClient verifies the factory wires the shared proxy-plugin-aware
|
||||
// transport (instead of a bare client that bypasses proxy plugin mode).
|
||||
func TestNewHTTPClient(t *testing.T) {
|
||||
// TestHTTPClientConstructors verifies both the policy-routed client and its
|
||||
// forced-external view retain explicit transports and configured timeouts.
|
||||
func TestHTTPClientConstructors(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
unsetProxyPluginEnv(t)
|
||||
resetProxyPluginState()
|
||||
t.Setenv(EnvNoProxy, "")
|
||||
|
||||
c := NewHTTPClient(7 * time.Second)
|
||||
if c.Transport == nil {
|
||||
t.Fatal("NewHTTPClient transport is nil; want shared transport")
|
||||
}
|
||||
if c.Transport != Shared() {
|
||||
t.Errorf("NewHTTPClient transport = %v, want Shared()", c.Transport)
|
||||
}
|
||||
if c.Timeout != 7*time.Second {
|
||||
t.Errorf("NewHTTPClient timeout = %v, want 7s", c.Timeout)
|
||||
for name, client := range map[string]*http.Client{
|
||||
"routed": NewHTTPClient(7 * time.Second),
|
||||
"external": NewExternalHTTPClient(7 * time.Second),
|
||||
} {
|
||||
if client.Transport == nil {
|
||||
t.Fatalf("%s client transport is nil", name)
|
||||
}
|
||||
if client.Timeout != 7*time.Second {
|
||||
t.Errorf("%s client timeout = %v, want 7s", name, client.Timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,4 +154,32 @@ func TestShared_MalformedConfigFailsClosedEvenWithNoProxy(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("RoundTrip() err = nil (resp=%v); malformed config must fail closed", resp)
|
||||
}
|
||||
|
||||
for name, test := range map[string]struct {
|
||||
client *http.Client
|
||||
url string
|
||||
}{
|
||||
"platform": {
|
||||
client: NewHTTPClient(time.Second),
|
||||
url: "https://open.feishu.cn/open-apis/test",
|
||||
},
|
||||
"external": {
|
||||
client: NewHTTPClient(time.Second),
|
||||
url: "https://external.example/test",
|
||||
},
|
||||
"forced external": {
|
||||
client: NewExternalHTTPClient(time.Second),
|
||||
url: "https://external.example/test",
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
resp, err := test.client.Get(test.url)
|
||||
if err == nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
t.Fatalf("policy-routed client succeeded with malformed proxy config")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,10 +62,7 @@ func httpClient() *http.Client {
|
||||
if DefaultClient != nil {
|
||||
return DefaultClient
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: fetchTimeout,
|
||||
Transport: transport.Shared(),
|
||||
}
|
||||
return transport.NewExternalHTTPClient(fetchTimeout)
|
||||
}
|
||||
|
||||
// updateState is persisted to disk for caching.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -12,6 +13,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
)
|
||||
|
||||
// roundTripFunc adapts a function to http.RoundTripper.
|
||||
@@ -19,6 +22,30 @@ type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
||||
|
||||
type updateExternalProvider struct {
|
||||
interceptor exttransport.Interceptor
|
||||
}
|
||||
|
||||
func (p updateExternalProvider) Name() string { return "update-external-test" }
|
||||
|
||||
func (p updateExternalProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
|
||||
return p.interceptor
|
||||
}
|
||||
|
||||
func (updateExternalProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
|
||||
return class == exttransport.RequestClassExternal
|
||||
}
|
||||
|
||||
type updateExternalInterceptor struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (i *updateExternalInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
i.calls++
|
||||
req.Header.Set("X-External-Route", "1")
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearSkipEnv unsets all env vars that shouldSkip checks,
|
||||
// preventing the host environment (e.g. CI=true) from polluting test results.
|
||||
func clearSkipEnv(t *testing.T) {
|
||||
@@ -242,6 +269,46 @@ func TestRefreshCache(t *testing.T) {
|
||||
RefreshCache("1.0.0")
|
||||
}
|
||||
|
||||
func TestHTTPClientUsesExternalRequestClass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARK_CLI_NO_PROXY", "")
|
||||
previousClient := DefaultClient
|
||||
DefaultClient = nil
|
||||
t.Cleanup(func() { DefaultClient = previousClient })
|
||||
|
||||
previousProvider := exttransport.GetProvider()
|
||||
interceptor := &updateExternalInterceptor{}
|
||||
exttransport.Register(updateExternalProvider{interceptor: interceptor})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
|
||||
previousTransport := http.DefaultTransport
|
||||
var receivedHeader string
|
||||
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
receivedHeader = req.Header.Get("X-External-Route")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Header: make(http.Header),
|
||||
Body: http.NoBody,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
t.Cleanup(func() { http.DefaultTransport = previousTransport })
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/npm/latest", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if interceptor.calls != 1 || receivedHeader != "1" {
|
||||
t.Fatalf("external route = calls %d, header %q; want 1, %q", interceptor.calls, receivedHeader, "1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingAtomicAccess(t *testing.T) {
|
||||
// Initially nil
|
||||
if got := GetPending(); got != nil {
|
||||
|
||||
@@ -5,11 +5,15 @@ package validate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -34,6 +38,9 @@ func isRestrictedDownloadIP(ip net.IP) bool {
|
||||
return true
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
if v4[0] == 0 { // RFC 1122 "this network"
|
||||
return true
|
||||
}
|
||||
if v4[0] == 10 || v4[0] == 127 {
|
||||
return true
|
||||
}
|
||||
@@ -52,6 +59,9 @@ func isRestrictedDownloadIP(ip net.IP) bool {
|
||||
if v4[0] == 198 && (v4[1] == 18 || v4[1] == 19) { // RFC2544 benchmarking
|
||||
return true
|
||||
}
|
||||
if v4[0] >= 240 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if ip.IsPrivate() {
|
||||
@@ -76,32 +86,42 @@ func ValidateDownloadSourceURL(ctx context.Context, rawURL string) error {
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("only http/https URLs are supported")
|
||||
}
|
||||
host := strings.TrimSpace(strings.ToLower(u.Hostname()))
|
||||
_, err = resolveDownloadHost(ctx, u.Hostname(), net.DefaultResolver.LookupIP)
|
||||
return err
|
||||
}
|
||||
|
||||
type downloadLookupIPFunc func(context.Context, string, string) ([]net.IP, error)
|
||||
|
||||
func resolveDownloadHost(ctx context.Context, rawHost string, lookupIP downloadLookupIPFunc) ([]net.IP, error) {
|
||||
host := strings.TrimSpace(strings.ToLower(rawHost))
|
||||
if host == "" {
|
||||
return fmt.Errorf("URL host is required")
|
||||
return nil, fmt.Errorf("URL host is required")
|
||||
}
|
||||
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
|
||||
return fmt.Errorf("local/internal host is not allowed")
|
||||
return nil, fmt.Errorf("local/internal host is not allowed")
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if isRestrictedDownloadIP(ip) {
|
||||
return fmt.Errorf("local/internal host is not allowed")
|
||||
return nil, fmt.Errorf("local/internal host is not allowed")
|
||||
}
|
||||
return nil
|
||||
return []net.IP{ip}, nil
|
||||
}
|
||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
if lookupIP == nil {
|
||||
lookupIP = net.DefaultResolver.LookupIP
|
||||
}
|
||||
ips, err := lookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve host")
|
||||
return nil, fmt.Errorf("failed to resolve host")
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return fmt.Errorf("failed to resolve host")
|
||||
return nil, fmt.Errorf("failed to resolve host")
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if isRestrictedDownloadIP(ip) {
|
||||
return fmt.Errorf("local/internal host is not allowed")
|
||||
return nil, fmt.Errorf("local/internal host is not allowed")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
// NewDownloadHTTPClient clones base client and enforces download-safe redirect
|
||||
@@ -115,7 +135,10 @@ func NewDownloadHTTPClient(base *http.Client, opts DownloadHTTPClientOptions) *h
|
||||
}
|
||||
|
||||
cloned := *base
|
||||
cloned.Transport = cloneDownloadTransport(base.Transport)
|
||||
cloned.Transport = &downloadSchemeTransport{
|
||||
base: cloneDownloadTransport(base.Transport),
|
||||
allowHTTP: opts.AllowHTTP,
|
||||
}
|
||||
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= opts.MaxRedirects {
|
||||
return fmt.Errorf("too many redirects")
|
||||
@@ -138,18 +161,310 @@ func NewDownloadHTTPClient(base *http.Client, opts DownloadHTTPClientOptions) *h
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneDownloadTransport(base http.RoundTripper) *http.Transport {
|
||||
var cloned *http.Transport
|
||||
if src, ok := base.(*http.Transport); ok && src != nil {
|
||||
cloned = src.Clone()
|
||||
} else {
|
||||
if def, ok := http.DefaultTransport.(*http.Transport); ok && def != nil {
|
||||
cloned = def.Clone()
|
||||
} else {
|
||||
cloned = &http.Transport{}
|
||||
}
|
||||
type downloadSchemeTransport struct {
|
||||
base http.RoundTripper
|
||||
allowHTTP bool
|
||||
}
|
||||
|
||||
func (t *downloadSchemeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req == nil || req.URL == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"download transport received a nil request",
|
||||
)
|
||||
}
|
||||
switch {
|
||||
case strings.EqualFold(req.URL.Scheme, "https"):
|
||||
case t.allowHTTP && strings.EqualFold(req.URL.Scheme, "http"):
|
||||
default:
|
||||
return nil, errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"only https URLs are supported",
|
||||
)
|
||||
}
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
type selectedDownloadProxyKey struct{}
|
||||
|
||||
type proxyAwareDownloadTransport struct {
|
||||
selectProxy func(*http.Request) (*url.URL, error)
|
||||
direct http.RoundTripper
|
||||
proxied *http.Transport
|
||||
lookupIP downloadLookupIPFunc
|
||||
|
||||
mu sync.Mutex
|
||||
proxiedByTLSServer map[string]*http.Transport
|
||||
}
|
||||
|
||||
func (t *proxyAwareDownloadTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req == nil || req.URL == nil {
|
||||
return nil, fmt.Errorf("download transport received a nil request")
|
||||
}
|
||||
proxyURL, err := t.selectProxy(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if proxyURL == nil {
|
||||
return t.direct.RoundTrip(req)
|
||||
}
|
||||
|
||||
targetIPs, err := resolveDownloadHost(req.Context(), req.URL.Hostname(), t.lookupIP)
|
||||
if err != nil {
|
||||
return nil, errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"blocked download target: %v",
|
||||
err,
|
||||
).WithCause(err)
|
||||
}
|
||||
if strings.EqualFold(req.URL.Scheme, "http") && net.ParseIP(req.URL.Hostname()) == nil {
|
||||
// HTTP proxies cannot pin the target IP separately from the Host header.
|
||||
return nil, errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"plain HTTP hostname downloads through a proxy are not allowed",
|
||||
).WithHint("use HTTPS or a literal public IP")
|
||||
}
|
||||
|
||||
selected := *proxyURL
|
||||
proxied := t.proxied
|
||||
if strings.EqualFold(req.URL.Scheme, "https") {
|
||||
proxied = t.proxiedTransportForTLSServer(req.URL.Hostname())
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for index, targetIP := range targetIPs {
|
||||
proxiedReq, pinErr := pinDownloadRequestTargetToIP(req, targetIP)
|
||||
if pinErr != nil {
|
||||
return nil, pinErr
|
||||
}
|
||||
ctx := context.WithValue(proxiedReq.Context(), selectedDownloadProxyKey{}, &selected)
|
||||
proxiedReq = proxiedReq.WithContext(ctx)
|
||||
|
||||
resp, roundTripErr := proxied.RoundTrip(proxiedReq)
|
||||
if roundTripErr == nil {
|
||||
if resp != nil {
|
||||
// Hide the internal pinned URL from redirect handling.
|
||||
resp.Request = req
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
lastErr = roundTripErr
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if req.Context().Err() != nil {
|
||||
break
|
||||
}
|
||||
if index+1 < len(targetIPs) && !canRetryDownloadTarget(req) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (t *proxyAwareDownloadTransport) CloseIdleConnections() {
|
||||
if closer, ok := t.direct.(interface{ CloseIdleConnections() }); ok {
|
||||
closer.CloseIdleConnections()
|
||||
}
|
||||
t.proxied.CloseIdleConnections()
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
for _, transport := range t.proxiedByTLSServer {
|
||||
transport.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *proxyAwareDownloadTransport) proxiedTransportForTLSServer(serverName string) *http.Transport {
|
||||
if configured := t.proxied.TLSClientConfig; configured != nil && configured.ServerName != "" {
|
||||
serverName = configured.ServerName
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if transport := t.proxiedByTLSServer[serverName]; transport != nil {
|
||||
return transport
|
||||
}
|
||||
|
||||
transport := t.proxied.Clone()
|
||||
targetTLSConfig := cloneDownloadTLSConfig(transport.TLSClientConfig)
|
||||
targetTLSConfig.ServerName = serverName
|
||||
transport.TLSClientConfig = targetTLSConfig
|
||||
configureHTTPSProxyTLSDialer(transport, t.proxied)
|
||||
if t.proxiedByTLSServer == nil {
|
||||
t.proxiedByTLSServer = make(map[string]*http.Transport)
|
||||
}
|
||||
t.proxiedByTLSServer[serverName] = transport
|
||||
return transport
|
||||
}
|
||||
|
||||
type blockedDownloadTransport struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (t *blockedDownloadTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return nil, t.err
|
||||
}
|
||||
|
||||
func cloneDownloadTransport(base http.RoundTripper) http.RoundTripper {
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
if source, ok := base.(interface {
|
||||
TransformHTTPTransport(func(*http.Transport) (http.RoundTripper, bool)) (http.RoundTripper, bool)
|
||||
}); ok {
|
||||
rebuilt, transformed := source.TransformHTTPTransport(newDownloadTransportLeaf)
|
||||
if transformed && rebuilt != nil {
|
||||
return rebuilt
|
||||
}
|
||||
}
|
||||
if source, ok := base.(*http.Transport); ok && source != nil {
|
||||
rebuilt, transformed := newDownloadTransportLeaf(source)
|
||||
if transformed && rebuilt != nil {
|
||||
return rebuilt
|
||||
}
|
||||
}
|
||||
return &blockedDownloadTransport{err: errs.NewInternalError(
|
||||
errs.SubtypeUnknown,
|
||||
"cannot safely clone download transport %T",
|
||||
base,
|
||||
)}
|
||||
}
|
||||
|
||||
func newDownloadTransportLeaf(source *http.Transport) (http.RoundTripper, bool) {
|
||||
return newDownloadTransportLeafWithResolver(source, net.DefaultResolver.LookupIP)
|
||||
}
|
||||
|
||||
func newDownloadTransportLeafWithResolver(source *http.Transport, lookupIP downloadLookupIPFunc) (http.RoundTripper, bool) {
|
||||
if source == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
selectProxy := source.Proxy
|
||||
direct := cloneDownloadHTTPTransport(source)
|
||||
direct.Proxy = nil
|
||||
configureDirectDownloadTransport(direct)
|
||||
if selectProxy == nil {
|
||||
return direct, true
|
||||
}
|
||||
|
||||
// The proxied branch validates the requested URL before construction and
|
||||
// on every redirect. Its TCP peer is the selected proxy, so applying the
|
||||
// direct-origin IP guard there would incorrectly reject trusted loopback or
|
||||
// private-network proxies. Freeze the selected proxy in request context so
|
||||
// a stateful selector cannot switch the second lookup to direct egress.
|
||||
proxied := cloneDownloadHTTPTransport(source)
|
||||
proxied.Proxy = func(req *http.Request) (*url.URL, error) {
|
||||
selected, ok := req.Context().Value(selectedDownloadProxyKey{}).(*url.URL)
|
||||
if !ok || selected == nil {
|
||||
return nil, fmt.Errorf("download proxy selection is missing")
|
||||
}
|
||||
cloned := *selected
|
||||
return &cloned, nil
|
||||
}
|
||||
return &proxyAwareDownloadTransport{
|
||||
selectProxy: selectProxy,
|
||||
direct: direct,
|
||||
proxied: proxied,
|
||||
lookupIP: lookupIP,
|
||||
proxiedByTLSServer: make(map[string]*http.Transport),
|
||||
}, true
|
||||
}
|
||||
|
||||
func cloneDownloadHTTPTransport(source *http.Transport) *http.Transport {
|
||||
cloned := source.Clone()
|
||||
if cloned.TLSNextProto == nil {
|
||||
if _, ok := source.TLSNextProto["h2"]; ok {
|
||||
cloned.ForceAttemptHTTP2 = true
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func pinDownloadRequestTargetToIP(req *http.Request, targetIP net.IP) (*http.Request, error) {
|
||||
if req == nil || req.URL == nil {
|
||||
return nil, fmt.Errorf("download request URL is missing")
|
||||
}
|
||||
if targetIP == nil || isRestrictedDownloadIP(targetIP) {
|
||||
return nil, fmt.Errorf("blocked download target: local/internal host is not allowed")
|
||||
}
|
||||
|
||||
originalHost := req.URL.Host
|
||||
pinnedHost := targetIP.String()
|
||||
if port := req.URL.Port(); port != "" {
|
||||
pinnedHost = net.JoinHostPort(pinnedHost, port)
|
||||
} else if strings.Contains(pinnedHost, ":") {
|
||||
pinnedHost = "[" + pinnedHost + "]"
|
||||
}
|
||||
|
||||
pinned := req.Clone(req.Context())
|
||||
pinnedURL := *req.URL
|
||||
pinnedURL.Host = pinnedHost
|
||||
pinned.URL = &pinnedURL
|
||||
pinned.Host = originalHost
|
||||
return pinned, nil
|
||||
}
|
||||
|
||||
func canRetryDownloadTarget(req *http.Request) bool {
|
||||
if req == nil || req.Body != nil {
|
||||
return false
|
||||
}
|
||||
return req.Method == http.MethodGet || req.Method == http.MethodHead
|
||||
}
|
||||
|
||||
func cloneDownloadTLSConfig(config *tls.Config) *tls.Config {
|
||||
if config == nil {
|
||||
return &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
return config.Clone()
|
||||
}
|
||||
|
||||
func configureHTTPSProxyTLSDialer(transport, source *http.Transport) {
|
||||
if transport.DialTLSContext != nil || transport.DialTLS != nil {
|
||||
return
|
||||
}
|
||||
|
||||
proxyTLSConfig := cloneDownloadTLSConfig(source.TLSClientConfig)
|
||||
transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
rawConn, err := dialDownloadProxy(ctx, source, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config := proxyTLSConfig.Clone()
|
||||
serverName, _, splitErr := net.SplitHostPort(addr)
|
||||
if splitErr != nil {
|
||||
rawConn.Close()
|
||||
return nil, fmt.Errorf("invalid HTTPS proxy address: %w", splitErr)
|
||||
}
|
||||
config.ServerName = serverName
|
||||
tlsConn := tls.Client(rawConn, config)
|
||||
handshakeCtx := ctx
|
||||
cancel := func() {}
|
||||
if source.TLSHandshakeTimeout > 0 {
|
||||
handshakeCtx, cancel = context.WithTimeout(ctx, source.TLSHandshakeTimeout)
|
||||
}
|
||||
defer cancel()
|
||||
if err := tlsConn.HandshakeContext(handshakeCtx); err != nil {
|
||||
rawConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return tlsConn, nil
|
||||
}
|
||||
}
|
||||
|
||||
func dialDownloadProxy(ctx context.Context, source *http.Transport, network, addr string) (net.Conn, error) {
|
||||
if source.DialContext != nil {
|
||||
return source.DialContext(ctx, network, addr)
|
||||
}
|
||||
if source.Dial != nil {
|
||||
return source.Dial(network, addr)
|
||||
}
|
||||
var dialer net.Dialer
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
func configureDirectDownloadTransport(cloned *http.Transport) {
|
||||
origDial := cloned.DialContext
|
||||
cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
conn, err := dialConn(ctx, origDial, network, addr)
|
||||
@@ -158,7 +473,7 @@ func cloneDownloadTransport(base http.RoundTripper) *http.Transport {
|
||||
}
|
||||
if err := validateConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
return nil, downloadTargetPolicyError(err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
@@ -172,13 +487,26 @@ func cloneDownloadTransport(base http.RoundTripper) *http.Transport {
|
||||
}
|
||||
if err := validateConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, downloadTargetPolicyError(err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
if cloned.DialTLS != nil {
|
||||
origDialTLS := cloned.DialTLS
|
||||
cloned.DialTLS = func(network, addr string) (net.Conn, error) {
|
||||
conn, err := origDialTLS(network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, downloadTargetPolicyError(err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
// DialContextFunc is the signature for DialContext / DialTLSContext.
|
||||
@@ -194,7 +522,7 @@ func WrapDialContextWithIPCheck(origDial DialContextFunc) DialContextFunc {
|
||||
}
|
||||
if err := validateConnRemoteIP(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
return nil, downloadTargetPolicyError(err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
@@ -208,6 +536,14 @@ func dialConn(ctx context.Context, dialFn func(context.Context, string, string)
|
||||
return d.DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
func downloadTargetPolicyError(err error) error {
|
||||
return errs.NewSecurityPolicyError(
|
||||
errs.SubtypeAccessDenied,
|
||||
"blocked download target: %v",
|
||||
err,
|
||||
).WithCause(err)
|
||||
}
|
||||
|
||||
func validateConnRemoteIP(conn net.Conn) error {
|
||||
if conn == nil {
|
||||
return fmt.Errorf("nil connection")
|
||||
|
||||
529
internal/validate/url_internal_test.go
Normal file
529
internal/validate/url_internal_test.go
Normal file
@@ -0,0 +1,529 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package validate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestProxiedHTTPSDownloadPinsValidatedTargetIP(t *testing.T) {
|
||||
const (
|
||||
targetHost = "rebind.example"
|
||||
targetIP = "203.0.113.10"
|
||||
)
|
||||
|
||||
proxyCalled := make(chan struct{}, 1)
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
proxyCalled <- struct{}{}
|
||||
if req.Method != http.MethodConnect {
|
||||
t.Errorf("proxy request method = %q, want CONNECT", req.Method)
|
||||
}
|
||||
if got := req.Host; got != targetIP+":443" {
|
||||
t.Errorf("proxy CONNECT target = %q, want validated IP %q", got, targetIP+":443")
|
||||
}
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lookupIP := func(context.Context, string, string) ([]net.IP, error) {
|
||||
return []net.IP{net.ParseIP(targetIP)}, nil
|
||||
}
|
||||
transport, ok := newDownloadTransportLeafWithResolver(
|
||||
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
|
||||
lookupIP,
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://"+targetHost+"/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pinned, err := pinDownloadRequestTargetToIP(req, net.ParseIP(targetIP))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pinned.Host != targetHost {
|
||||
t.Fatalf("pinned request Host = %q, want %q", pinned.Host, targetHost)
|
||||
}
|
||||
if _, err := transport.RoundTrip(req); err == nil {
|
||||
t.Fatal("RoundTrip() error = nil, want proxy rejection after CONNECT")
|
||||
}
|
||||
select {
|
||||
case <-proxyCalled:
|
||||
default:
|
||||
t.Fatal("proxy was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestrictedDownloadIPBlocksReservedIPv4(t *testing.T) {
|
||||
for _, rawIP := range []string{"0.1.2.3", "240.0.0.1"} {
|
||||
if !isRestrictedDownloadIP(net.ParseIP(rawIP)) {
|
||||
t.Fatalf("%s was classified as safe", rawIP)
|
||||
}
|
||||
}
|
||||
if isRestrictedDownloadIP(net.ParseIP("1.1.1.1")) {
|
||||
t.Fatal("1.1.1.1 was classified as restricted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneDownloadTLSConfigSetsMinimumVersion(t *testing.T) {
|
||||
if got := cloneDownloadTLSConfig(nil).MinVersion; got != tls.VersionTLS12 {
|
||||
t.Fatalf("MinVersion = %d, want TLS 1.2", got)
|
||||
}
|
||||
configured := &tls.Config{MinVersion: tls.VersionTLS13}
|
||||
if got := cloneDownloadTLSConfig(configured).MinVersion; got != tls.VersionTLS13 {
|
||||
t.Fatalf("cloned MinVersion = %d, want TLS 1.3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneDownloadHTTPTransportPreservesHTTP2Policy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source *http.Transport
|
||||
wantForce bool
|
||||
wantH2Handler bool
|
||||
wantProtocolMap bool
|
||||
}{
|
||||
{
|
||||
name: "automatic",
|
||||
source: &http.Transport{
|
||||
Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"}),
|
||||
},
|
||||
wantForce: true,
|
||||
},
|
||||
{
|
||||
name: "custom TLS without opt-in",
|
||||
source: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom dial without opt-in",
|
||||
source: &http.Transport{
|
||||
DialContext: func(context.Context, string, string) (net.Conn, error) {
|
||||
return nil, errors.New("unused")
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit opt-in",
|
||||
source: &http.Transport{ForceAttemptHTTP2: true},
|
||||
wantForce: true,
|
||||
},
|
||||
{
|
||||
name: "explicit h2 handler",
|
||||
source: &http.Transport{
|
||||
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{
|
||||
"h2": func(string, *tls.Conn) http.RoundTripper { return nil },
|
||||
},
|
||||
},
|
||||
wantH2Handler: true,
|
||||
wantProtocolMap: true,
|
||||
},
|
||||
{
|
||||
name: "explicit opt-out",
|
||||
source: &http.Transport{
|
||||
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{},
|
||||
},
|
||||
wantProtocolMap: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cloned := cloneDownloadHTTPTransport(test.source)
|
||||
if cloned.ForceAttemptHTTP2 != test.wantForce {
|
||||
t.Fatalf("ForceAttemptHTTP2 = %v, want %v", cloned.ForceAttemptHTTP2, test.wantForce)
|
||||
}
|
||||
_, hasH2Handler := cloned.TLSNextProto["h2"]
|
||||
if hasH2Handler != test.wantH2Handler {
|
||||
t.Fatalf("h2 handler = %v, want %v", hasH2Handler, test.wantH2Handler)
|
||||
}
|
||||
if hasProtocolMap := cloned.TLSNextProto != nil; hasProtocolMap != test.wantProtocolMap {
|
||||
t.Fatalf("TLSNextProto is non-nil = %v, want %v", hasProtocolMap, test.wantProtocolMap)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneDownloadTransportPreservesAutomaticHTTP2(t *testing.T) {
|
||||
source := &http.Transport{
|
||||
Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"}),
|
||||
}
|
||||
rebuilt := cloneDownloadTransport(source)
|
||||
proxyAware, ok := rebuilt.(*proxyAwareDownloadTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *proxyAwareDownloadTransport", rebuilt)
|
||||
}
|
||||
direct, ok := proxyAware.direct.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("direct transport type = %T, want *http.Transport", proxyAware.direct)
|
||||
}
|
||||
for name, transport := range map[string]*http.Transport{
|
||||
"direct": direct,
|
||||
"proxied": proxyAware.proxied,
|
||||
} {
|
||||
if !transport.ForceAttemptHTTP2 {
|
||||
t.Fatalf("%s ForceAttemptHTTP2 = false, want true", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxiedDownloadRejectsRestrictedResolvedTarget(t *testing.T) {
|
||||
var proxyCalled atomic.Bool
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
proxyCalled.Store(true)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lookupIP := func(context.Context, string, string) ([]net.IP, error) {
|
||||
return []net.IP{net.ParseIP("127.0.0.1")}, nil
|
||||
}
|
||||
transport, ok := newDownloadTransportLeafWithResolver(
|
||||
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
|
||||
lookupIP,
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://rebind.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = transport.RoundTrip(req)
|
||||
if err == nil {
|
||||
t.Fatal("RoundTrip() error = nil, want restricted target rejection")
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryPolicy ||
|
||||
problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if proxyCalled.Load() {
|
||||
t.Fatal("proxy was called for a restricted resolved target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxiedPlainHTTPHostnameRejectsLocalProxy(t *testing.T) {
|
||||
var proxyCalled atomic.Bool
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
proxyCalled.Store(true)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lookupIP := func(ctx context.Context, _, host string) ([]net.IP, error) {
|
||||
if host == "public.example" {
|
||||
return []net.IP{net.ParseIP("203.0.113.10")}, nil
|
||||
}
|
||||
return net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
}
|
||||
transport, ok := newDownloadTransportLeafWithResolver(
|
||||
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
|
||||
lookupIP,
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://public.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = transport.RoundTrip(req)
|
||||
if err == nil {
|
||||
t.Fatal("RoundTrip() error = nil, want plain HTTP hostname rejection")
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryPolicy ||
|
||||
problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
} else if problem.Hint != "use HTTPS or a literal public IP" {
|
||||
t.Fatalf("RoundTrip() hint = %q, want recovery guidance", problem.Hint)
|
||||
}
|
||||
if proxyCalled.Load() {
|
||||
t.Fatal("proxy was called for a plain HTTP hostname target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxiedHTTPSDownloadTriesEveryValidatedTargetIP(t *testing.T) {
|
||||
connectTargets := make(chan string, 2)
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
connectTargets <- req.Host
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transport, ok := newDownloadTransportLeafWithResolver(
|
||||
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
|
||||
func(context.Context, string, string) ([]net.IP, error) {
|
||||
return []net.IP{
|
||||
net.ParseIP("203.0.113.10"),
|
||||
net.ParseIP("203.0.113.11"),
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://multi.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := transport.RoundTrip(req); err == nil {
|
||||
t.Fatal("RoundTrip() error = nil, want proxy rejection")
|
||||
}
|
||||
for _, want := range []string{"203.0.113.10:443", "203.0.113.11:443"} {
|
||||
select {
|
||||
case got := <-connectTargets:
|
||||
if got != want {
|
||||
t.Fatalf("proxy CONNECT target = %q, want %q", got, want)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("proxy did not receive CONNECT target %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanRetryDownloadTargetOnlyAllowsBodylessReads(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
method string
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{method: http.MethodGet, want: true},
|
||||
{method: http.MethodHead, want: true},
|
||||
{method: http.MethodPost},
|
||||
{method: http.MethodGet, body: "body"},
|
||||
} {
|
||||
req, err := http.NewRequest(test.method, "https://download.example/file", strings.NewReader(test.body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if test.body == "" {
|
||||
req.Body = nil
|
||||
}
|
||||
if got := canRetryDownloadTarget(req); got != test.want {
|
||||
t.Fatalf("canRetryDownloadTarget(%s, body=%q) = %v, want %v", test.method, test.body, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxiedHTTPSTargetPreservesOriginalTLSServerName(t *testing.T) {
|
||||
transport, ok := newDownloadTransportLeafWithResolver(
|
||||
&http.Transport{Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"})},
|
||||
func(context.Context, string, string) ([]net.IP, error) {
|
||||
return []net.IP{net.ParseIP("203.0.113.10")}, nil
|
||||
},
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
|
||||
}
|
||||
proxyAware, ok := transport.(*proxyAwareDownloadTransport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *proxyAwareDownloadTransport", transport)
|
||||
}
|
||||
|
||||
pinned := proxyAware.proxiedTransportForTLSServer("download.example")
|
||||
if pinned.TLSClientConfig == nil {
|
||||
t.Fatal("TLSClientConfig = nil")
|
||||
}
|
||||
if pinned.TLSClientConfig.ServerName != "download.example" {
|
||||
t.Fatalf("TLS ServerName = %q, want download.example", pinned.TLSClientConfig.ServerName)
|
||||
}
|
||||
if proxyAware.proxied.TLSClientConfig != nil && proxyAware.proxied.TLSClientConfig.ServerName != "" {
|
||||
t.Fatalf("base proxy TLS ServerName = %q, want unchanged", proxyAware.proxied.TLSClientConfig.ServerName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSProxyTLSDialerUsesLegacyDial(t *testing.T) {
|
||||
wantErr := errors.New("legacy dial used")
|
||||
source := &http.Transport{
|
||||
Dial: func(string, string) (net.Conn, error) {
|
||||
return nil, wantErr
|
||||
},
|
||||
}
|
||||
target := source.Clone()
|
||||
configureHTTPSProxyTLSDialer(target, source)
|
||||
if target.DialTLSContext == nil {
|
||||
t.Fatal("DialTLSContext = nil")
|
||||
}
|
||||
if _, err := target.DialTLSContext(context.Background(), "tcp", "proxy.example:443"); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("DialTLSContext() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectDownloadLegacyDialTLSClosesRestrictedConnection(t *testing.T) {
|
||||
clientConn, serverConn := net.Pipe()
|
||||
t.Cleanup(func() { serverConn.Close() })
|
||||
conn := &trackedDownloadConn{
|
||||
Conn: clientConn,
|
||||
remoteAddr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 443},
|
||||
}
|
||||
rebuilt, ok := newDownloadTransportLeaf(&http.Transport{
|
||||
DialTLS: func(string, string) (net.Conn, error) {
|
||||
return conn, nil
|
||||
},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeaf() did not rebuild transport")
|
||||
}
|
||||
transport, ok := rebuilt.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("rebuilt transport = %T, want *http.Transport", rebuilt)
|
||||
}
|
||||
|
||||
_, err := transport.DialTLS("tcp", "public.example:443")
|
||||
if err == nil || !strings.Contains(err.Error(), "local/internal host is not allowed") {
|
||||
t.Fatalf("DialTLS() error = %v, want restricted target rejection", err)
|
||||
}
|
||||
if problem, ok := errs.ProblemOf(err); !ok ||
|
||||
problem.Category != errs.CategoryPolicy ||
|
||||
problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("DialTLS() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if !conn.closed {
|
||||
t.Fatal("restricted connection was not closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectDownloadLegacyDialTLSPreservesDialError(t *testing.T) {
|
||||
wantErr := errors.New("dial failed")
|
||||
rebuilt, ok := newDownloadTransportLeaf(&http.Transport{
|
||||
DialTLS: func(string, string) (net.Conn, error) {
|
||||
return nil, wantErr
|
||||
},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("newDownloadTransportLeaf() did not rebuild transport")
|
||||
}
|
||||
transport, ok := rebuilt.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("rebuilt transport = %T, want *http.Transport", rebuilt)
|
||||
}
|
||||
|
||||
if _, err := transport.DialTLS("tcp", "public.example:443"); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("DialTLS() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSProxyTLSDialerRetainsHandshakeTimeout(t *testing.T) {
|
||||
clientConn, serverConn := net.Pipe()
|
||||
t.Cleanup(func() {
|
||||
clientConn.Close()
|
||||
serverConn.Close()
|
||||
})
|
||||
source := &http.Transport{
|
||||
DialContext: func(context.Context, string, string) (net.Conn, error) {
|
||||
return clientConn, nil
|
||||
},
|
||||
TLSHandshakeTimeout: 50 * time.Millisecond,
|
||||
}
|
||||
target := source.Clone()
|
||||
configureHTTPSProxyTLSDialer(target, source)
|
||||
|
||||
started := time.Now()
|
||||
if _, err := target.DialTLSContext(context.Background(), "tcp", "proxy.example:443"); err == nil {
|
||||
t.Fatal("DialTLSContext() error = nil, want TLS handshake timeout")
|
||||
}
|
||||
if elapsed := time.Since(started); elapsed > time.Second {
|
||||
t.Fatalf("TLS handshake timeout took %s, want under 1s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSProxyTLSDialerUsesProxyServerName(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
clientConn, serverConn := net.Pipe()
|
||||
t.Cleanup(func() {
|
||||
clientConn.Close()
|
||||
serverConn.Close()
|
||||
})
|
||||
|
||||
proxySNI := make(chan string, 1)
|
||||
serverTLSConfig := server.TLS.Clone()
|
||||
serverTLSConfig.GetConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) {
|
||||
proxySNI <- info.ServerName
|
||||
return nil, nil
|
||||
}
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- tls.Server(serverConn, serverTLSConfig).Handshake()
|
||||
}()
|
||||
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(server.Certificate())
|
||||
source := &http.Transport{
|
||||
DialContext: func(context.Context, string, string) (net.Conn, error) {
|
||||
return clientConn, nil
|
||||
},
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: roots,
|
||||
ServerName: "target.example.com",
|
||||
},
|
||||
}
|
||||
target := source.Clone()
|
||||
configureHTTPSProxyTLSDialer(target, source)
|
||||
|
||||
conn, err := target.DialTLSContext(context.Background(), "tcp", "example.com:443")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.Close()
|
||||
if err := <-serverErr; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := <-proxySNI; got != "example.com" {
|
||||
t.Fatalf("proxy TLS ServerName = %q, want example.com", got)
|
||||
}
|
||||
}
|
||||
|
||||
type trackedDownloadConn struct {
|
||||
net.Conn
|
||||
remoteAddr net.Addr
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (c *trackedDownloadConn) RemoteAddr() net.Addr {
|
||||
return c.remoteAddr
|
||||
}
|
||||
|
||||
func (c *trackedDownloadConn) Close() error {
|
||||
c.closed = true
|
||||
return c.Conn.Close()
|
||||
}
|
||||
222
internal/validate/url_test.go
Normal file
222
internal/validate/url_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package validate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
internaltransport "github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
type opaqueRoundTripper struct {
|
||||
called bool
|
||||
}
|
||||
|
||||
func (t *opaqueRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
t.called = true
|
||||
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
|
||||
}
|
||||
|
||||
type downloadTestProvider struct {
|
||||
interceptor exttransport.Interceptor
|
||||
}
|
||||
|
||||
func (p downloadTestProvider) Name() string { return "download-test" }
|
||||
|
||||
func (p downloadTestProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
|
||||
return p.interceptor
|
||||
}
|
||||
|
||||
type downloadHeaderInterceptor struct{}
|
||||
|
||||
func (downloadHeaderInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
|
||||
req.Header.Set("X-Use-Proxy", "1")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientPreservesPolicyRouterBaseTransport(t *testing.T) {
|
||||
wantErr := errors.New("proxy policy blocked request")
|
||||
base := &http.Transport{
|
||||
Proxy: func(*http.Request) (*url.URL, error) {
|
||||
return nil, wantErr
|
||||
},
|
||||
}
|
||||
router := internaltransport.NewHTTPPolicyRouter(base, base)
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: router},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
|
||||
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := download.Transport.RoundTrip(req)
|
||||
if resp != nil && resp.Body != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("RoundTrip() error = %v, want preserved proxy error %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientRejectsInitialHTTPBeforeTransport(t *testing.T) {
|
||||
base := &opaqueRoundTripper{}
|
||||
download := validate.NewDownloadHTTPClient(
|
||||
&http.Client{Transport: base},
|
||||
validate.DownloadHTTPClientOptions{},
|
||||
)
|
||||
req, err := http.NewRequest(http.MethodGet, "http://203.0.113.10/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = download.Transport.RoundTrip(req)
|
||||
if err == nil {
|
||||
t.Fatal("RoundTrip() error = nil, want initial HTTP rejection")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryPolicy || problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
if base.called {
|
||||
t.Fatal("base transport was called for a disallowed initial HTTP request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientAllowsSelectedLoopbackProxy(t *testing.T) {
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if req.URL.Host != "203.0.113.10" {
|
||||
t.Errorf("proxy request target = %q, want 203.0.113.10", req.URL.Host)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
|
||||
router := internaltransport.NewHTTPPolicyRouter(base, base)
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: router},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://203.0.113.10/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := download.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("download through selected loopback proxy: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientSelectsProxyAfterOuterDecorators(t *testing.T) {
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
if got := req.Header.Get("X-Use-Proxy"); got != "1" {
|
||||
t.Errorf("proxy received X-Use-Proxy = %q, want 1", got)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
proxyURL, err := url.Parse(proxy.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantErr := errors.New("proxy selector ran before decorators")
|
||||
base := &http.Transport{Proxy: func(req *http.Request) (*url.URL, error) {
|
||||
if req.Header.Get("X-Use-Proxy") != "1" {
|
||||
return nil, wantErr
|
||||
}
|
||||
return proxyURL, nil
|
||||
}}
|
||||
previousProvider := exttransport.GetProvider()
|
||||
exttransport.Register(downloadTestProvider{interceptor: downloadHeaderInterceptor{}})
|
||||
t.Cleanup(func() { exttransport.Register(previousProvider) })
|
||||
router := internaltransport.NewHTTPPolicyRouter(base, base)
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: router},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://203.0.113.10/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := download.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("download through decorator-selected proxy: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientGuardsLegacyDialTLS(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
base := &http.Transport{DialTLS: func(_, _ string) (net.Conn, error) {
|
||||
return tls.Dial("tcp", server.Listener.Addr().String(), &tls.Config{InsecureSkipVerify: true}) //nolint:gosec // local TLS server verifies the connection guard.
|
||||
}}
|
||||
download := validate.NewDownloadHTTPClient(&http.Client{Transport: base}, validate.DownloadHTTPClientOptions{AllowHTTP: true})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://public.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = download.Transport.RoundTrip(req)
|
||||
if err == nil || !strings.Contains(err.Error(), "local/internal host is not allowed") {
|
||||
t.Fatalf("RoundTrip() error = %v, want legacy DialTLS IP guard", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryPolicy || problem.Subtype != errs.SubtypeAccessDenied {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
|
||||
}
|
||||
var policyErr *errs.SecurityPolicyError
|
||||
if !errors.As(err, &policyErr) || policyErr.Cause == nil {
|
||||
t.Fatalf("RoundTrip() error = %T, want policy error with cause", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDownloadHTTPClientFailsClosedForOpaqueTransport(t *testing.T) {
|
||||
opaque := &opaqueRoundTripper{}
|
||||
client := internaltransport.ClientForRequestClass(
|
||||
&http.Client{Transport: opaque},
|
||||
exttransport.RequestClassExternal,
|
||||
)
|
||||
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
|
||||
req, err := http.NewRequest(http.MethodGet, "https://public.example/file", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = download.Transport.RoundTrip(req)
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot safely clone download transport") {
|
||||
t.Fatalf("RoundTrip() error = %v, want fail-closed clone error", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("RoundTrip() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
if opaque.called {
|
||||
t.Fatal("opaque transport was called after safe cloning failed")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user