mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6b57311b2 | ||
|
|
db9ca5c2a4 | ||
|
|
1f8d4b211d | ||
|
|
63ea52b2e6 | ||
|
|
555722ac8e | ||
|
|
f5a8fbf8f1 | ||
|
|
adef52ada5 | ||
|
|
6ac5b4d566 | ||
|
|
7158dc2f3c | ||
|
|
c54a1354a0 | ||
|
|
a73c9ae27e | ||
|
|
900c12ce8d | ||
|
|
f3c3a4c49f | ||
|
|
2e345a4fdd | ||
|
|
78bc66ce14 | ||
|
|
fe8da8d924 | ||
|
|
12bb01addf | ||
|
|
d6fada01f5 |
30
CHANGELOG.md
30
CHANGELOG.md
@@ -2,6 +2,35 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.6] - 2026-04-08
|
||||
|
||||
### Features
|
||||
|
||||
- Improve login scope validation and success output (#317)
|
||||
- **task**: Support starting pagination from page token (#332)
|
||||
- Support multipart doc media uploads (#294)
|
||||
- **mail**: Auto-resolve local image paths in all draft entry points (#205)
|
||||
- **vc**: Add `+recording` shortcut for `meeting_id` to `minute_token` conversion (#246)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Resolve concurrency races in RuntimeContext (#330)
|
||||
- **config**: Save empty config before clearing keychain entries (#291)
|
||||
- Reject positional arguments in shortcuts (#227)
|
||||
- Improve raw API diagnostics for invalid or empty JSON responses (#257)
|
||||
- **docs**: Normalize `board_tokens` in `+create` response for mermaid/whiteboard content (#10)
|
||||
- **task**: Clarify `--complete` flag help for `get-my-tasks` (#310)
|
||||
- **help**: Point root help Agent Skills link to README section (#289)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Clarify `--complete` flag behavior in `get-my-tasks` reference (#308)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Migrate VC/minutes shortcuts to FileIO (#336)
|
||||
- Migrate common/client/IM to FileIO and add localfileio tests (#322)
|
||||
|
||||
## [v1.0.5] - 2026-04-07
|
||||
|
||||
### Features
|
||||
@@ -193,6 +222,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.6]: https://github.com/larksuite/cli/releases/tag/v1.0.6
|
||||
[v1.0.5]: https://github.com/larksuite/cli/releases/tag/v1.0.5
|
||||
[v1.0.4]: https://github.com/larksuite/cli/releases/tag/v1.0.4
|
||||
[v1.0.3]: https://github.com/larksuite/cli/releases/tag/v1.0.3
|
||||
|
||||
@@ -199,7 +199,7 @@ func apiRun(opts *APIOptions) error {
|
||||
|
||||
resp, err := ac.DoAPI(opts.Ctx, request)
|
||||
if err != nil {
|
||||
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
|
||||
return output.MarkRaw(client.WrapDoAPIError(err))
|
||||
}
|
||||
err = client.HandleResponse(resp, client.ResponseOptions{
|
||||
OutputPath: opts.Output,
|
||||
@@ -207,6 +207,7 @@ func apiRun(opts *APIOptions) error {
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||
})
|
||||
// MarkRaw tells root error handler to skip enrichPermissionError,
|
||||
// preserving the original API error detail (log_id, troubleshooter, etc.).
|
||||
|
||||
@@ -446,6 +446,43 @@ func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_InvalidJSONResponse_ShowsDiagnostic(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-invalidjson", AppSecret: "test-secret-invalidjson", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/invalidjson",
|
||||
RawBody: []byte{},
|
||||
ContentType: "application/json",
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/invalidjson", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected detail on exit error")
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, "invalid JSON response") &&
|
||||
!strings.Contains(exitErr.Detail.Message, "empty JSON response body") {
|
||||
t.Fatalf("expected JSON diagnostic, got %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--output") {
|
||||
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-rawpage", AppSecret: "test-secret-rawpage", Brand: core.BrandFeishu,
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// NewCmdAuth creates the auth command with subcommands.
|
||||
@@ -100,7 +101,7 @@ type appInfoResponse struct {
|
||||
|
||||
// getAppInfo queries app info from the Lark API.
|
||||
func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
|
||||
sdk, err := f.LarkClient()
|
||||
ac, err := f.NewAPIClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -108,12 +109,11 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("lang", "zh_cn")
|
||||
|
||||
apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: larkauth.ApplicationInfoPath(appId),
|
||||
QueryParams: queryParams,
|
||||
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant},
|
||||
})
|
||||
apiResp, err := ac.DoSDKRequest(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: larkauth.ApplicationInfoPath(appId),
|
||||
QueryParams: queryParams,
|
||||
}, core.AsBot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,12 +4,16 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
@@ -231,3 +235,71 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
|
||||
t.Errorf("expected format json, got %s", gotOpts.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
||||
})
|
||||
tokenResolver := &authScopesTokenResolver{}
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
|
||||
|
||||
appInfoStub := &httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/application/v6/applications/test-app",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"app": map[string]interface{}{
|
||||
"creator_id": "ou_creator",
|
||||
"scopes": []map[string]interface{}{
|
||||
{
|
||||
"scope": "im:message",
|
||||
"token_types": []string{"tenant"},
|
||||
},
|
||||
{
|
||||
"scope": "im:message:send_as_user",
|
||||
"token_types": []string{"user"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(appInfoStub)
|
||||
|
||||
err := authScopesRun(&ScopesOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Format: "json",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("authScopesRun() error = %v", err)
|
||||
}
|
||||
|
||||
if len(tokenResolver.requests) != 1 {
|
||||
t.Fatalf("resolved token requests = %v, want exactly one request", tokenResolver.requests)
|
||||
}
|
||||
if got := tokenResolver.requests[0].Type; got != credential.TokenTypeTAT {
|
||||
t.Fatalf("resolved token type = %q, want %q", got, credential.TokenTypeTAT)
|
||||
}
|
||||
if got := appInfoStub.CapturedHeaders.Get("Authorization"); got != "Bearer tenant-token" {
|
||||
t.Fatalf("Authorization header = %q, want %q", got, "Bearer tenant-token")
|
||||
}
|
||||
}
|
||||
|
||||
type authScopesTokenResolver struct {
|
||||
requests []credential.TokenSpec
|
||||
}
|
||||
|
||||
func (r *authScopesTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
r.requests = append(r.requests, req)
|
||||
switch req.Type {
|
||||
case credential.TokenTypeTAT:
|
||||
return &credential.TokenResult{Token: "tenant-token"}, nil
|
||||
case credential.TokenTypeUAT:
|
||||
return &credential.TokenResult{Token: "user-token"}, nil
|
||||
default:
|
||||
return &credential.TokenResult{Token: "unexpected-token"}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ type LoginOptions struct {
|
||||
DeviceCode string
|
||||
}
|
||||
|
||||
var pollDeviceToken = larkauth.PollDeviceToken
|
||||
|
||||
// NewCmdAuthLogin creates the auth login subcommand.
|
||||
func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
|
||||
opts := &LoginOptions{Factory: f}
|
||||
@@ -235,6 +237,9 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
// --no-wait: return immediately with device code and URL
|
||||
if opts.NoWait {
|
||||
if err := saveLoginRequestedScope(authResp.DeviceCode, finalScope); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to cache requested scopes: %v\n", err)
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
"verification_url": authResp.VerificationUriComplete,
|
||||
"device_code": authResp.DeviceCode,
|
||||
@@ -244,7 +249,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(data); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "error: failed to write JSON output: %v\n", err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -261,7 +266,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(data); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "error: failed to write JSON output: %v\n", err)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
@@ -270,20 +275,26 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
// Step 3: Poll for token
|
||||
log(msg.WaitingAuth)
|
||||
result := larkauth.PollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
|
||||
if !result.OK {
|
||||
if opts.JSON {
|
||||
b, _ := json.Marshal(map[string]interface{}{
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(map[string]interface{}{
|
||||
"event": "authorization_failed",
|
||||
"error": result.Message,
|
||||
})
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
}); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
||||
}
|
||||
return output.ErrBare(output.ExitAuth)
|
||||
}
|
||||
return output.ErrAuth("authorization failed: %s", result.Message)
|
||||
}
|
||||
if result.Token == nil {
|
||||
return output.ErrAuth("authorization succeeded but no token returned")
|
||||
}
|
||||
|
||||
// Step 6: Get user info
|
||||
log(msg.AuthSuccess)
|
||||
@@ -296,6 +307,8 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
return output.ErrAuth("failed to get user info: %v", err)
|
||||
}
|
||||
|
||||
scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope)
|
||||
|
||||
// Step 7: Store token
|
||||
now := time.Now().UnixMilli()
|
||||
storedToken := &larkauth.StoredUAToken{
|
||||
@@ -318,21 +331,11 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
|
||||
}
|
||||
|
||||
if opts.JSON {
|
||||
b, _ := json.Marshal(map[string]interface{}{
|
||||
"event": "authorization_complete",
|
||||
"user_open_id": openId,
|
||||
"user_name": userName,
|
||||
"scope": result.Token.Scope,
|
||||
})
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
} else {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
if result.Token.Scope != "" {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.GrantedScopes, result.Token.Scope)
|
||||
}
|
||||
if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil {
|
||||
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName)
|
||||
}
|
||||
|
||||
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -345,13 +348,26 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
requestedScope, err := loadLoginRequestedScope(opts.DeviceCode)
|
||||
if err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to load cached requested scopes: %v\n", err)
|
||||
}
|
||||
cleanupRequestedScope := func() {
|
||||
if err := removeLoginRequestedScope(opts.DeviceCode); err != nil {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
|
||||
}
|
||||
}
|
||||
log(msg.WaitingAuth)
|
||||
result := larkauth.PollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
result := pollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
|
||||
opts.DeviceCode, 5, 180, f.IOStreams.ErrOut)
|
||||
|
||||
if !result.OK {
|
||||
if shouldRemoveLoginRequestedScope(result) {
|
||||
cleanupRequestedScope()
|
||||
}
|
||||
return output.ErrAuth("authorization failed: %s", result.Message)
|
||||
}
|
||||
defer cleanupRequestedScope()
|
||||
if result.Token == nil {
|
||||
return output.ErrAuth("authorization succeeded but no token returned")
|
||||
}
|
||||
@@ -367,6 +383,8 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
return output.ErrAuth("failed to get user info: %v", err)
|
||||
}
|
||||
|
||||
scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope)
|
||||
|
||||
// Store token
|
||||
now := time.Now().UnixMilli()
|
||||
storedToken := &larkauth.StoredUAToken{
|
||||
@@ -389,7 +407,11 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil {
|
||||
return handleLoginScopeIssue(opts, msg, f, issue, openId, userName)
|
||||
}
|
||||
|
||||
writeLoginSuccess(opts, msg, f, openId, userName, scopeSummary)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -20,11 +20,17 @@ type loginMsg struct {
|
||||
ConfirmAuth string
|
||||
|
||||
// Non-interactive prompts (login.go)
|
||||
OpenURL string
|
||||
WaitingAuth string
|
||||
AuthSuccess string
|
||||
LoginSuccess string
|
||||
GrantedScopes string
|
||||
OpenURL string
|
||||
WaitingAuth string
|
||||
AuthSuccess string
|
||||
LoginSuccess string
|
||||
ScopeMismatch string
|
||||
ScopeHint string
|
||||
RequestedScopes string
|
||||
NewlyGrantedScopes string
|
||||
MissingScopes string
|
||||
NoScopes string
|
||||
StatusHint string
|
||||
|
||||
// Non-interactive hint (no flags)
|
||||
HintHeader string
|
||||
@@ -50,11 +56,17 @@ var loginMsgZh = &loginMsg{
|
||||
ErrNoDomain: "请至少选择一个业务域",
|
||||
ConfirmAuth: "确认授权?",
|
||||
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AuthSuccess: "授权成功,正在获取用户信息...",
|
||||
LoginSuccess: "登录成功! 用户: %s (%s)",
|
||||
GrantedScopes: " 已授权 scopes: %s\n",
|
||||
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
|
||||
WaitingAuth: "等待用户授权...",
|
||||
AuthSuccess: "授权成功,正在获取用户信息...",
|
||||
LoginSuccess: "登录成功! 用户: %s (%s)",
|
||||
ScopeMismatch: "授权完成,但以下请求 scopes 未被授予: %s",
|
||||
ScopeHint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
RequestedScopes: " 本次请求 scopes: %s\n",
|
||||
NewlyGrantedScopes: " 本次新授予 scopes: %s\n",
|
||||
MissingScopes: " 本次未授予 scopes: %s\n",
|
||||
NoScopes: "(空)",
|
||||
StatusHint: "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
|
||||
HintHeader: "请指定要授权的权限:\n",
|
||||
HintCommon1: " --recommend 授权推荐权限",
|
||||
@@ -79,11 +91,17 @@ var loginMsgEn = &loginMsg{
|
||||
ErrNoDomain: "please select at least one domain",
|
||||
ConfirmAuth: "Confirm authorization?",
|
||||
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
AuthSuccess: "Authorization successful, fetching user info...",
|
||||
LoginSuccess: "Login successful! User: %s (%s)",
|
||||
GrantedScopes: " Granted scopes: %s\n",
|
||||
OpenURL: "Open this URL in your browser to authenticate:\n\n",
|
||||
WaitingAuth: "Waiting for user authorization...",
|
||||
AuthSuccess: "Authorization successful, fetching user info...",
|
||||
LoginSuccess: "Login successful! User: %s (%s)",
|
||||
ScopeMismatch: "authorization completed, but these requested scopes were not granted: %s",
|
||||
ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
RequestedScopes: " Requested scopes: %s\n",
|
||||
NewlyGrantedScopes: " Newly granted scopes: %s\n",
|
||||
MissingScopes: " Not granted scopes: %s\n",
|
||||
NoScopes: "(none)",
|
||||
StatusHint: "Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
|
||||
HintHeader: "Please specify the scopes to authorize:\n",
|
||||
HintCommon1: " --recommend authorize recommended scopes",
|
||||
|
||||
@@ -69,12 +69,6 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
|
||||
t.Errorf("%s LoginSuccess has no format verb", lang)
|
||||
}
|
||||
|
||||
// GrantedScopes should contain %s
|
||||
got = fmt.Sprintf(msg.GrantedScopes, "scope1 scope2")
|
||||
if got == msg.GrantedScopes {
|
||||
t.Errorf("%s GrantedScopes has no format verb", lang)
|
||||
}
|
||||
|
||||
// SummaryDomains should contain %s
|
||||
got = fmt.Sprintf(msg.SummaryDomains, "calendar, task")
|
||||
if got == msg.SummaryDomains {
|
||||
|
||||
232
cmd/auth/login_result.go
Normal file
232
cmd/auth/login_result.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type loginScopeSummary struct {
|
||||
Requested []string
|
||||
NewlyGranted []string
|
||||
AlreadyGranted []string
|
||||
Granted []string
|
||||
Missing []string
|
||||
}
|
||||
|
||||
type loginScopeIssue struct {
|
||||
Message string
|
||||
Hint string
|
||||
Summary *loginScopeSummary
|
||||
}
|
||||
|
||||
// ensureRequestedScopesGranted checks whether all requested scopes were granted
|
||||
// and returns a structured issue when any requested scope is missing.
|
||||
func ensureRequestedScopesGranted(requestedScope, grantedScope string, msg *loginMsg, summary *loginScopeSummary) *loginScopeIssue {
|
||||
requested := uniqueScopeList(requestedScope)
|
||||
if len(requested) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
missing := larkauth.MissingScopes(grantedScope, requested)
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if summary == nil {
|
||||
summary = &loginScopeSummary{
|
||||
Requested: requested,
|
||||
Granted: strings.Fields(grantedScope),
|
||||
Missing: missing,
|
||||
}
|
||||
}
|
||||
return &loginScopeIssue{
|
||||
Message: fmt.Sprintf(msg.ScopeMismatch, strings.Join(missing, " ")),
|
||||
Hint: msg.ScopeHint,
|
||||
Summary: summary,
|
||||
}
|
||||
}
|
||||
|
||||
// loadLoginScopeSummary builds a scope summary by comparing the requested scopes,
|
||||
// previously stored scopes, and the newly granted scopes from the current login.
|
||||
func loadLoginScopeSummary(appID, openId, requestedScope, grantedScope string) *loginScopeSummary {
|
||||
previousScope := ""
|
||||
if previous := larkauth.GetStoredToken(appID, openId); previous != nil {
|
||||
previousScope = previous.Scope
|
||||
}
|
||||
return buildLoginScopeSummary(requestedScope, previousScope, grantedScope)
|
||||
}
|
||||
|
||||
// buildLoginScopeSummary classifies requested scopes into newly granted,
|
||||
// already granted, and missing buckets while preserving the final granted list.
|
||||
func buildLoginScopeSummary(requestedScope, previousScope, grantedScope string) *loginScopeSummary {
|
||||
requested := uniqueScopeList(requestedScope)
|
||||
previous := uniqueScopeList(previousScope)
|
||||
granted := uniqueScopeList(grantedScope)
|
||||
previousSet := make(map[string]bool, len(previous))
|
||||
for _, scope := range previous {
|
||||
previousSet[scope] = true
|
||||
}
|
||||
grantedSet := make(map[string]bool, len(granted))
|
||||
for _, scope := range granted {
|
||||
grantedSet[scope] = true
|
||||
}
|
||||
|
||||
summary := &loginScopeSummary{
|
||||
Requested: requested,
|
||||
Granted: granted,
|
||||
}
|
||||
for _, scope := range requested {
|
||||
if !grantedSet[scope] {
|
||||
summary.Missing = append(summary.Missing, scope)
|
||||
continue
|
||||
}
|
||||
if previousSet[scope] {
|
||||
summary.AlreadyGranted = append(summary.AlreadyGranted, scope)
|
||||
continue
|
||||
}
|
||||
summary.NewlyGranted = append(summary.NewlyGranted, scope)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
// uniqueScopeList splits a scope string into a de-duplicated ordered slice.
|
||||
func uniqueScopeList(scope string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var result []string
|
||||
for _, item := range strings.Fields(scope) {
|
||||
if seen[item] {
|
||||
continue
|
||||
}
|
||||
seen[item] = true
|
||||
result = append(result, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// formatScopeList joins scopes for display and falls back to the provided empty
|
||||
// label when the input slice is empty.
|
||||
func formatScopeList(scopes []string, empty string) string {
|
||||
if len(scopes) == 0 {
|
||||
return empty
|
||||
}
|
||||
return strings.Join(scopes, " ")
|
||||
}
|
||||
|
||||
// emptyIfNil normalizes nil slices to empty slices for stable JSON output.
|
||||
func emptyIfNil(s []string) []string {
|
||||
if s == nil {
|
||||
return []string{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// writeLoginScopeBreakdown renders the requested/newly granted/missing scope
|
||||
// breakdown to stderr.
|
||||
func writeLoginScopeBreakdown(errOut *cmdutil.IOStreams, msg *loginMsg, summary *loginScopeSummary) {
|
||||
if summary == nil {
|
||||
summary = &loginScopeSummary{}
|
||||
}
|
||||
fmt.Fprintf(errOut.ErrOut, msg.RequestedScopes, formatScopeList(summary.Requested, msg.NoScopes))
|
||||
fmt.Fprintf(errOut.ErrOut, msg.NewlyGrantedScopes, formatScopeList(summary.NewlyGranted, msg.NoScopes))
|
||||
fmt.Fprintf(errOut.ErrOut, msg.MissingScopes, formatScopeList(summary.Missing, msg.NoScopes))
|
||||
}
|
||||
|
||||
// writeLoginSuccess emits the successful login payload in either JSON or text
|
||||
// format together with the computed scope breakdown.
|
||||
func writeLoginSuccess(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, openId, userName string, summary *loginScopeSummary) {
|
||||
if summary == nil {
|
||||
summary = &loginScopeSummary{}
|
||||
}
|
||||
if opts.JSON {
|
||||
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, summary, nil))
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
writeLoginScopeBreakdown(f.IOStreams, msg, summary)
|
||||
if len(summary.Missing) == 0 && msg.StatusHint != "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, msg.StatusHint)
|
||||
}
|
||||
}
|
||||
|
||||
// handleLoginScopeIssue prints or returns a structured missing-scope result
|
||||
// while preserving a successful login outcome when authorization completed.
|
||||
func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory, issue *loginScopeIssue, openId, userName string) error {
|
||||
if issue == nil {
|
||||
return nil
|
||||
}
|
||||
loginSucceeded := openId != ""
|
||||
if opts.JSON {
|
||||
if loginSucceeded {
|
||||
b, _ := json.Marshal(authorizationCompletePayload(openId, userName, issue.Summary, issue))
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
return nil
|
||||
}
|
||||
detail := map[string]interface{}{
|
||||
"requested": issue.Summary.Requested,
|
||||
"granted": issue.Summary.Granted,
|
||||
"missing": issue.Summary.Missing,
|
||||
}
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAuth,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "missing_scope",
|
||||
Message: issue.Message,
|
||||
Hint: issue.Hint,
|
||||
Detail: detail,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
if loginSucceeded {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
|
||||
} else {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
||||
}
|
||||
if loginSucceeded {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Message)
|
||||
}
|
||||
writeLoginScopeBreakdown(f.IOStreams, msg, issue.Summary)
|
||||
if issue.Hint != "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, issue.Hint)
|
||||
}
|
||||
if loginSucceeded {
|
||||
return nil
|
||||
}
|
||||
return output.ErrBare(output.ExitAuth)
|
||||
}
|
||||
|
||||
// authorizationCompletePayload builds the JSON payload for a completed login,
|
||||
// optionally attaching a warning when requested scopes are missing.
|
||||
func authorizationCompletePayload(openId, userName string, summary *loginScopeSummary, issue *loginScopeIssue) map[string]interface{} {
|
||||
if summary == nil {
|
||||
summary = &loginScopeSummary{}
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"event": "authorization_complete",
|
||||
"user_open_id": openId,
|
||||
"user_name": userName,
|
||||
"scope": strings.Join(summary.Granted, " "),
|
||||
"requested": emptyIfNil(summary.Requested),
|
||||
"newly_granted": emptyIfNil(summary.NewlyGranted),
|
||||
"already_granted": emptyIfNil(summary.AlreadyGranted),
|
||||
"missing": emptyIfNil(summary.Missing),
|
||||
"granted": emptyIfNil(summary.Granted),
|
||||
}
|
||||
if issue != nil {
|
||||
payload["warning"] = map[string]interface{}{
|
||||
"type": "missing_scope",
|
||||
"message": issue.Message,
|
||||
"hint": issue.Hint,
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
91
cmd/auth/login_scope_cache.go
Normal file
91
cmd/auth/login_scope_cache.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
var loginScopeCacheSafeChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
||||
|
||||
type loginScopeCacheRecord struct {
|
||||
RequestedScope string `json:"requested_scope"`
|
||||
}
|
||||
|
||||
// loginScopeCacheDir returns the directory used to persist auth login --no-wait
|
||||
// requested scopes keyed by device_code.
|
||||
func loginScopeCacheDir() string {
|
||||
return filepath.Join(core.GetConfigDir(), "cache", "auth_login_scopes")
|
||||
}
|
||||
|
||||
// loginScopeCachePath returns the cache file path for a given device_code.
|
||||
func loginScopeCachePath(deviceCode string) string {
|
||||
return filepath.Join(loginScopeCacheDir(), sanitizeLoginScopeCacheKey(deviceCode)+".json")
|
||||
}
|
||||
|
||||
// sanitizeLoginScopeCacheKey converts a device_code into a safe filename token.
|
||||
func sanitizeLoginScopeCacheKey(deviceCode string) string {
|
||||
sanitized := loginScopeCacheSafeChars.ReplaceAllString(deviceCode, "_")
|
||||
if sanitized == "" {
|
||||
return "default"
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// saveLoginRequestedScope persists the requested scope string for a device_code.
|
||||
func saveLoginRequestedScope(deviceCode, requestedScope string) error {
|
||||
if err := vfs.MkdirAll(loginScopeCacheDir(), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(loginScopeCacheRecord{RequestedScope: requestedScope})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validate.AtomicWrite(loginScopeCachePath(deviceCode), data, 0600)
|
||||
}
|
||||
|
||||
// loadLoginRequestedScope loads the cached requested scope string for a device_code.
|
||||
// It returns an empty string if no cache entry exists.
|
||||
func loadLoginRequestedScope(deviceCode string) (string, error) {
|
||||
data, err := vfs.ReadFile(loginScopeCachePath(deviceCode))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
var record loginScopeCacheRecord
|
||||
if err := json.Unmarshal(data, &record); err != nil {
|
||||
_ = vfs.Remove(loginScopeCachePath(deviceCode))
|
||||
return "", err
|
||||
}
|
||||
return record.RequestedScope, nil
|
||||
}
|
||||
|
||||
// removeLoginRequestedScope deletes the cache entry for a device_code.
|
||||
func removeLoginRequestedScope(deviceCode string) error {
|
||||
err := vfs.Remove(loginScopeCachePath(deviceCode))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// shouldRemoveLoginRequestedScope indicates whether the requested-scope cache
|
||||
// should be removed after polling finishes.
|
||||
func shouldRemoveLoginRequestedScope(result *larkauth.DeviceFlowResult) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
if result.OK || result.Error == "access_denied" {
|
||||
return true
|
||||
}
|
||||
return result.Error == "expired_token" && result.Message != "Polling was cancelled"
|
||||
}
|
||||
48
cmd/auth/login_scope_cache_test.go
Normal file
48
cmd/auth/login_scope_cache_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
func TestLoginRequestedScopeCache_RoundTrip(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
|
||||
deviceCode := "device/code:123"
|
||||
requestedScope := "im:message:send im:message:reply"
|
||||
|
||||
if err := saveLoginRequestedScope(deviceCode, requestedScope); err != nil {
|
||||
t.Fatalf("saveLoginRequestedScope() error = %v", err)
|
||||
}
|
||||
got, err := loadLoginRequestedScope(deviceCode)
|
||||
if err != nil {
|
||||
t.Fatalf("loadLoginRequestedScope() error = %v", err)
|
||||
}
|
||||
if got != requestedScope {
|
||||
t.Fatalf("requestedScope = %q, want %q", got, requestedScope)
|
||||
}
|
||||
if _, err := vfs.Stat(loginScopeCachePath(deviceCode)); err != nil {
|
||||
t.Fatalf("Stat(cachePath) error = %v", err)
|
||||
}
|
||||
if err := removeLoginRequestedScope(deviceCode); err != nil {
|
||||
t.Fatalf("removeLoginRequestedScope() error = %v", err)
|
||||
}
|
||||
if _, err := vfs.Stat(loginScopeCachePath(deviceCode)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("Stat(cachePath) error = %v, want not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLoginRequestedScope_MissingReturnsEmpty(t *testing.T) {
|
||||
setupLoginConfigDir(t)
|
||||
|
||||
got, err := loadLoginRequestedScope("missing-device-code")
|
||||
if err != nil {
|
||||
t.Fatalf("loadLoginRequestedScope() error = %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("requestedScope = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,29 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
type failWriter struct{}
|
||||
|
||||
func (failWriter) Write([]byte) (int, error) {
|
||||
return 0, errors.New("write failed")
|
||||
}
|
||||
|
||||
func TestSuggestDomain_PrefixMatch(t *testing.T) {
|
||||
known := map[string]bool{
|
||||
"calendar": true,
|
||||
@@ -282,6 +295,606 @@ func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureRequestedScopesGranted(t *testing.T) {
|
||||
issue := ensureRequestedScopesGranted("im:message:send im:message:reply", "im:message:reply", getLoginMsg("en"), nil)
|
||||
if issue == nil {
|
||||
t.Fatal("expected missing scope issue")
|
||||
}
|
||||
if !strings.Contains(issue.Message, "im:message:send") {
|
||||
t.Fatalf("message %q missing requested scope", issue.Message)
|
||||
}
|
||||
for _, want := range []string{"Do not retry continuously", "scope being disabled", "lark-cli auth status"} {
|
||||
if !strings.Contains(issue.Hint, want) {
|
||||
t.Fatalf("hint %q missing %q", issue.Hint, want)
|
||||
}
|
||||
}
|
||||
if got := strings.Join(issue.Summary.Missing, " "); got != "im:message:send" {
|
||||
t.Fatalf("Missing = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLoginScopeSummary(t *testing.T) {
|
||||
summary := buildLoginScopeSummary("im:message:send im:message:reply im:message:send", "im:message:reply", "im:message:send im:message:reply im:chat:read")
|
||||
if got := strings.Join(summary.Requested, " "); got != "im:message:send im:message:reply" {
|
||||
t.Fatalf("Requested = %q", got)
|
||||
}
|
||||
if got := strings.Join(summary.NewlyGranted, " "); got != "im:message:send" {
|
||||
t.Fatalf("NewlyGranted = %q", got)
|
||||
}
|
||||
if got := strings.Join(summary.AlreadyGranted, " "); got != "im:message:reply" {
|
||||
t.Fatalf("AlreadyGranted = %q", got)
|
||||
}
|
||||
if len(summary.Missing) != 0 {
|
||||
t.Fatalf("Missing = %v, want empty", summary.Missing)
|
||||
}
|
||||
if got := strings.Join(summary.Granted, " "); got != "im:message:send im:message:reply im:chat:read" {
|
||||
t.Fatalf("Granted = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLoginSuccess_JSONIncludesScopeDiff(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
|
||||
Requested: []string{"im:message:send", "im:message:reply"},
|
||||
NewlyGranted: []string{"im:message:send"},
|
||||
AlreadyGranted: []string{"im:message:reply"},
|
||||
Granted: []string{"im:message:send", "im:message:reply"},
|
||||
})
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
|
||||
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
|
||||
}
|
||||
if data["event"] != "authorization_complete" {
|
||||
t.Fatalf("event = %v", data["event"])
|
||||
}
|
||||
if data["scope"] != "im:message:send im:message:reply" {
|
||||
t.Fatalf("scope = %v", data["scope"])
|
||||
}
|
||||
if len(data["newly_granted"].([]interface{})) != 1 {
|
||||
t.Fatalf("newly_granted = %#v", data["newly_granted"])
|
||||
}
|
||||
if len(data["already_granted"].([]interface{})) != 1 {
|
||||
t.Fatalf("already_granted = %#v", data["already_granted"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
err := handleLoginScopeIssue(&LoginOptions{}, getLoginMsg("zh"), f, &loginScopeIssue{
|
||||
Message: "授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
Hint: "以上结果是本次授权请求用户最终确认后的结果,请勿持续重试;Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
Summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
Missing: []string{"im:message:send"},
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
|
||||
"scope 被禁用",
|
||||
"lark-cli auth status",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stderr missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "ERROR:") {
|
||||
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := handleLoginScopeIssue(&LoginOptions{JSON: true}, getLoginMsg("en"), f, &loginScopeIssue{
|
||||
Message: "authorization completed, but these requested scopes were not granted: im:message:send",
|
||||
Hint: "Granted scopes: base:app:copy. Check app scopes.",
|
||||
Summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
Missing: []string{"im:message:send"},
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
|
||||
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
|
||||
}
|
||||
if data["event"] != "authorization_complete" {
|
||||
t.Fatalf("event = %v", data["event"])
|
||||
}
|
||||
if data["user_open_id"] != "ou_user" {
|
||||
t.Fatalf("user_open_id = %v", data["user_open_id"])
|
||||
}
|
||||
warning, ok := data["warning"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("warning = %#v", data["warning"])
|
||||
}
|
||||
if warning["type"] != "missing_scope" {
|
||||
t.Fatalf("warning.type = %v", warning["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLoginSuccess_JSONEmptySlicesNotNull(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
writeLoginSuccess(&LoginOptions{JSON: true}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
|
||||
Granted: []string{"offline_access"},
|
||||
})
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
|
||||
t.Fatalf("Unmarshal(stdout) error = %v, stdout=%s", err, stdout.String())
|
||||
}
|
||||
for _, k := range []string{"requested", "newly_granted", "already_granted", "missing", "granted"} {
|
||||
v, ok := data[k]
|
||||
if !ok {
|
||||
t.Fatalf("missing key %q in payload: %v", k, data)
|
||||
}
|
||||
if _, ok := v.([]interface{}); !ok {
|
||||
t.Fatalf("%s = %#v, want JSON array", k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLoginSuccess_TextOutputScenarios(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
summary *loginScopeSummary
|
||||
expectedPresent []string
|
||||
expectedAbsent []string
|
||||
}{
|
||||
{
|
||||
name: "mixed newly granted and already granted",
|
||||
summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send", "im:message:reply"},
|
||||
NewlyGranted: []string{"im:message:send"},
|
||||
AlreadyGranted: []string{"im:message:reply"},
|
||||
Granted: []string{"im:message:send", "im:message:reply"},
|
||||
},
|
||||
expectedPresent: []string{
|
||||
"登录成功! 用户: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send im:message:reply",
|
||||
"本次新授予 scopes: im:message:send",
|
||||
"本次未授予 scopes: (空)",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
},
|
||||
expectedAbsent: []string{
|
||||
"最终已授权 scopes:",
|
||||
"已有 scopes:",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all already granted",
|
||||
summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
AlreadyGranted: []string{"im:message:send"},
|
||||
Granted: []string{"im:message:send", "contact:user.base:readonly"},
|
||||
},
|
||||
expectedPresent: []string{
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: (空)",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
},
|
||||
expectedAbsent: []string{
|
||||
"最终已授权 scopes:",
|
||||
"已有 scopes:",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing scopes are shown",
|
||||
summary: &loginScopeSummary{
|
||||
Requested: []string{"im:message:send", "im:message:reply"},
|
||||
Missing: []string{"im:message:send"},
|
||||
Granted: []string{"im:message:reply"},
|
||||
},
|
||||
expectedPresent: []string{
|
||||
"本次请求 scopes: im:message:send im:message:reply",
|
||||
"本次新授予 scopes: (空)",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
},
|
||||
expectedAbsent: []string{
|
||||
"已有 scopes:",
|
||||
"最终已授权 scopes:",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
writeLoginSuccess(&LoginOptions{}, getLoginMsg("zh"), f, "ou_user", "tester", tt.summary)
|
||||
|
||||
got := stderr.String()
|
||||
for _, want := range tt.expectedPresent {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stderr missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range tt.expectedAbsent {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("stderr should not contain %q, got:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLoginScopeSummary_WithMissingScopes(t *testing.T) {
|
||||
summary := buildLoginScopeSummary("im:message:send im:message:reply", "im:message:reply", "im:message:reply")
|
||||
if got := strings.Join(summary.NewlyGranted, " "); got != "" {
|
||||
t.Fatalf("NewlyGranted = %q, want empty", got)
|
||||
}
|
||||
if got := strings.Join(summary.AlreadyGranted, " "); got != "im:message:reply" {
|
||||
t.Fatalf("AlreadyGranted = %q", got)
|
||||
}
|
||||
if got := strings.Join(summary.Missing, " "); got != "im:message:send" {
|
||||
t.Fatalf("Missing = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "cli_test"},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathDeviceAuthorization,
|
||||
Body: map[string]interface{}{
|
||||
"device_code": "device-code",
|
||||
"user_code": "user-code",
|
||||
"verification_uri": "https://example.com/verify",
|
||||
"verification_uri_complete": "https://example.com/verify?code=123",
|
||||
"expires_in": 240,
|
||||
"interval": 0,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthTokenV2,
|
||||
Body: map[string]interface{}{
|
||||
"access_token": "user-access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"expires_in": 7200,
|
||||
"refresh_token_expires_in": 604800,
|
||||
"scope": "offline_access",
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: larkauth.PathUserInfoV1,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"open_id": "ou_user",
|
||||
"name": "tester",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"授权完成,但以下请求 scopes 未被授予: im:message:send",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次未授予 scopes: im:message:send",
|
||||
"以上结果是本次授权请求用户最终确认后的结果,请勿持续重试",
|
||||
"scope 被禁用",
|
||||
"lark-cli auth status",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stderr missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "ERROR:") {
|
||||
t.Fatalf("stderr should not contain error prefix, got:\n%s", got)
|
||||
}
|
||||
stored := larkauth.GetStoredToken("cli_test", "ou_user")
|
||||
if stored == nil {
|
||||
t.Fatal("expected token to be stored when authorization succeeds with missing scopes")
|
||||
}
|
||||
if stored.Scope != "offline_access" {
|
||||
t.Fatalf("stored scope = %q", stored.Scope)
|
||||
}
|
||||
cfg, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if len(cfg.Apps) != 1 || len(cfg.Apps[0].Users) != 1 {
|
||||
t.Fatalf("unexpected users in config: %#v", cfg.Apps)
|
||||
}
|
||||
if cfg.Apps[0].Users[0].UserOpenId != "ou_user" {
|
||||
t.Fatalf("stored user open id = %q", cfg.Apps[0].Users[0].UserOpenId)
|
||||
}
|
||||
if cfg.Apps[0].Users[0].UserName != "tester" {
|
||||
t.Fatalf("stored user name = %q", cfg.Apps[0].Users[0].UserName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_DeviceCodeUsesCachedRequestedScopes(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{
|
||||
{Name: "default", AppId: "cli_test"},
|
||||
},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathDeviceAuthorization,
|
||||
Body: map[string]interface{}{
|
||||
"device_code": "device-code",
|
||||
"user_code": "user-code",
|
||||
"verification_uri": "https://example.com/verify",
|
||||
"verification_uri_complete": "https://example.com/verify?code=123",
|
||||
"expires_in": 240,
|
||||
"interval": 0,
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathOAuthTokenV2,
|
||||
Body: map[string]interface{}{
|
||||
"access_token": "user-access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"expires_in": 7200,
|
||||
"refresh_token_expires_in": 604800,
|
||||
"scope": "im:message:send offline_access",
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: larkauth.PathUserInfoV1,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"open_id": "ou_user",
|
||||
"name": "tester",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
NoWait: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("no-wait authLoginRun() error = %v", err)
|
||||
}
|
||||
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "im:message:send" {
|
||||
t.Fatalf("loadLoginRequestedScope() = (%q, %v), want requested scope", got, err)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
|
||||
err = authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
DeviceCode: "device-code",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("device-code authLoginRun() error = %v", err)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"OK: 登录成功! 用户: tester (ou_user)",
|
||||
"本次请求 scopes: im:message:send",
|
||||
"本次新授予 scopes: im:message:send",
|
||||
"可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes;",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stderr missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "最终已授权 scopes:") {
|
||||
t.Fatalf("stderr should not contain final granted scopes, got:\n%s", got)
|
||||
}
|
||||
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "" {
|
||||
t.Fatalf("loadLoginRequestedScope() after cleanup = (%q, %v), want empty", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLoginSuccess_TextOutputEnglishIncludesStatusHintWhenNoMissingScopes(t *testing.T) {
|
||||
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
writeLoginSuccess(&LoginOptions{}, getLoginMsg("en"), f, "ou_user", "tester", &loginScopeSummary{
|
||||
Requested: []string{"im:message:send"},
|
||||
NewlyGranted: []string{"im:message:send"},
|
||||
Granted: []string{"im:message:send"},
|
||||
})
|
||||
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
"Login successful! User: tester (ou_user)",
|
||||
"Requested scopes: im:message:send",
|
||||
"Newly granted scopes: im:message:send",
|
||||
"Not granted scopes: (none)",
|
||||
"Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stderr missing %q, got:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
|
||||
if err := saveLoginRequestedScope("device-code", "im:message:send"); err != nil {
|
||||
t.Fatalf("saveLoginRequestedScope() error = %v", err)
|
||||
}
|
||||
|
||||
original := pollDeviceToken
|
||||
t.Cleanup(func() { pollDeviceToken = original })
|
||||
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
|
||||
return &larkauth.DeviceFlowResult{OK: true, Token: nil}
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
DeviceCode: "device-code",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil token")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "authorization succeeded but no token returned") {
|
||||
t.Fatalf("error = %v, want nil token error", err)
|
||||
}
|
||||
if got, err := loadLoginRequestedScope("device-code"); err != nil || got != "" {
|
||||
t.Fatalf("loadLoginRequestedScope() after nil token = (%q, %v), want empty", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
f.IOStreams.Out = failWriter{}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathDeviceAuthorization,
|
||||
Body: map[string]interface{}{
|
||||
"device_code": "device-code",
|
||||
"user_code": "user-code",
|
||||
"verification_uri": "https://example.com/verify",
|
||||
"verification_uri_complete": "https://example.com/verify?code=123",
|
||||
"expires_in": 240,
|
||||
"interval": 5,
|
||||
},
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
NoWait: true,
|
||||
JSON: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to write JSON output") {
|
||||
t.Fatalf("error = %v, want JSON write failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
f.IOStreams.Out = failWriter{}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathDeviceAuthorization,
|
||||
Body: map[string]interface{}{
|
||||
"device_code": "device-code",
|
||||
"user_code": "user-code",
|
||||
"verification_uri": "https://example.com/verify",
|
||||
"verification_uri_complete": "https://example.com/verify?code=123",
|
||||
"expires_in": 240,
|
||||
"interval": 5,
|
||||
},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: ctx,
|
||||
Scope: "im:message:send",
|
||||
JSON: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to write JSON output") {
|
||||
t.Fatalf("error = %v, want JSON write failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
|
||||
domains := getDomainMetadata("zh")
|
||||
for _, dm := range domains {
|
||||
|
||||
@@ -6,6 +6,8 @@ package config
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -21,6 +23,17 @@ func (n *noopConfigKeychain) Get(service, account string) (string, error) { retu
|
||||
func (n *noopConfigKeychain) Set(service, account, value string) error { return nil }
|
||||
func (n *noopConfigKeychain) Remove(service, account string) error { return nil }
|
||||
|
||||
type recordingConfigKeychain struct {
|
||||
removed []string
|
||||
}
|
||||
|
||||
func (r *recordingConfigKeychain) Get(service, account string) (string, error) { return "", nil }
|
||||
func (r *recordingConfigKeychain) Set(service, account, value string) error { return nil }
|
||||
func (r *recordingConfigKeychain) Remove(service, account string) error {
|
||||
r.removed = append(r.removed, service+":"+account)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestConfigInitCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.IOStreams.In = strings.NewReader("secret123\n")
|
||||
@@ -221,6 +234,66 @@ func TestConfigRemoveCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRemoveRun_SaveFailurePreservesExistingConfigAndSecrets(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
|
||||
multi := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
AppId: "app-test",
|
||||
AppSecret: core.SecretInput{
|
||||
Ref: &core.SecretRef{Source: "keychain", ID: "appsecret:app-test"},
|
||||
},
|
||||
Brand: core.BrandFeishu,
|
||||
Users: []core.AppUser{{UserOpenId: "ou_1", UserName: "Tester"}},
|
||||
}},
|
||||
}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
kc := &recordingConfigKeychain{}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Keychain = kc
|
||||
|
||||
// Make subsequent config saves fail while keeping the existing config readable.
|
||||
if err := os.Chmod(configDir, 0500); err != nil {
|
||||
t.Fatalf("Chmod(%s) error = %v", configDir, err)
|
||||
}
|
||||
defer os.Chmod(configDir, 0700)
|
||||
|
||||
err := configRemoveRun(&ConfigRemoveOptions{Factory: f})
|
||||
if err == nil {
|
||||
t.Fatal("expected save failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to save config") {
|
||||
t.Fatalf("error = %v, want failed to save config", err)
|
||||
}
|
||||
if len(kc.removed) != 0 {
|
||||
t.Fatalf("expected no keychain cleanup before successful save, got removals: %v", kc.removed)
|
||||
}
|
||||
|
||||
// Restore permissions and confirm the original config is still intact.
|
||||
if err := os.Chmod(configDir, 0700); err != nil {
|
||||
t.Fatalf("restore Chmod(%s) error = %v", configDir, err)
|
||||
}
|
||||
saved, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMultiAppConfig() error = %v", err)
|
||||
}
|
||||
if saved == nil || len(saved.Apps) != 1 || saved.Apps[0].AppId != "app-test" {
|
||||
t.Fatalf("saved config = %#v, want original single app preserved", saved)
|
||||
}
|
||||
if got := saved.Apps[0].AppSecret.Ref; got == nil || got.ID != "appsecret:app-test" {
|
||||
t.Fatalf("saved app secret ref = %#v, want preserved keychain ref", got)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(configDir, "config.json")
|
||||
if _, err := os.Stat(configPath); err != nil {
|
||||
t.Fatalf("expected existing config file to remain, stat error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
|
||||
@@ -44,19 +44,21 @@ func configRemoveRun(opts *ConfigRemoveOptions) error {
|
||||
return output.ErrValidation("not configured yet")
|
||||
}
|
||||
|
||||
// Clean up keychain entries for all apps
|
||||
for _, app := range config.Apps {
|
||||
core.RemoveSecretStore(app.AppSecret, f.Keychain)
|
||||
for _, user := range app.Users {
|
||||
auth.RemoveStoredToken(app.AppId, user.UserOpenId)
|
||||
}
|
||||
}
|
||||
|
||||
// Save empty config
|
||||
// Save empty config first. If this fails, keep secrets and tokens intact so the
|
||||
// existing config can still be retried instead of ending up half-removed.
|
||||
empty := &core.MultiAppConfig{Apps: []core.AppConfig{}}
|
||||
if err := core.SaveMultiAppConfig(empty); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
}
|
||||
|
||||
// Clean up keychain entries for all apps after config is cleared.
|
||||
for _, app := range config.Apps {
|
||||
core.RemoveSecretStore(app.AppSecret, f.Keychain)
|
||||
for _, user := range app.Users {
|
||||
_ = auth.RemoveStoredToken(app.AppId, user.UserOpenId)
|
||||
}
|
||||
}
|
||||
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, "Configuration removed")
|
||||
userCount := 0
|
||||
for _, app := range config.Apps {
|
||||
|
||||
@@ -78,7 +78,7 @@ AI AGENT SKILLS:
|
||||
npx skills add larksuite/cli -s lark-calendar -y
|
||||
npx skills add larksuite/cli -s lark-im -y
|
||||
|
||||
Learn more: https://github.com/larksuite/cli#install-ai-agent-skills
|
||||
Learn more: https://github.com/larksuite/cli#agent-skills
|
||||
|
||||
COMMUNITY:
|
||||
GitHub: https://github.com/larksuite/cli
|
||||
|
||||
@@ -187,3 +187,12 @@ func TestEnrichPermissionError_SpecialCharsEscaped(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
|
||||
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
|
||||
}
|
||||
if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") {
|
||||
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +250,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||
CheckError: checkErr,
|
||||
})
|
||||
}
|
||||
|
||||
40
extension/fileio/errors.go
Normal file
40
extension/fileio/errors.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package fileio
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrPathValidation indicates the path failed security validation
|
||||
// (traversal, absolute, control chars, symlink escape, etc.).
|
||||
var ErrPathValidation = errors.New("path validation failed")
|
||||
|
||||
// PathValidationError wraps a path validation error.
|
||||
// errors.Is(err, ErrPathValidation) returns true.
|
||||
// errors.Is(err, <original OS error>) also works via the chain.
|
||||
type PathValidationError struct {
|
||||
Err error // original error
|
||||
}
|
||||
|
||||
func (e *PathValidationError) Error() string { return e.Err.Error() }
|
||||
func (e *PathValidationError) Unwrap() []error {
|
||||
return []error{ErrPathValidation, e.Err}
|
||||
}
|
||||
|
||||
// MkdirError indicates parent directory creation failed.
|
||||
// Use errors.As(err, &fileio.MkdirError{}) to match.
|
||||
type MkdirError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *MkdirError) Error() string { return e.Err.Error() }
|
||||
func (e *MkdirError) Unwrap() error { return e.Err }
|
||||
|
||||
// WriteError indicates file write failed.
|
||||
// Use errors.As(err, &fileio.WriteError{}) to match.
|
||||
type WriteError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *WriteError) Error() string { return e.Err.Error() }
|
||||
func (e *WriteError) Unwrap() error { return e.Err }
|
||||
31
extension/fileio/registry.go
Normal file
31
extension/fileio/registry.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package fileio
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
provider Provider
|
||||
)
|
||||
|
||||
// Register registers a FileIO Provider.
|
||||
// Later registrations override earlier ones (last-write-wins).
|
||||
// Unlike credential.Register which appends to a chain (multiple credential
|
||||
// sources are tried in order), FileIO uses a single active provider because
|
||||
// only one file I/O backend is active at a time (local vs server mode).
|
||||
// Typically called from init() via blank import.
|
||||
func Register(p Provider) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
provider = p
|
||||
}
|
||||
|
||||
// GetProvider returns the currently registered Provider.
|
||||
// Returns nil if no provider has been registered.
|
||||
func GetProvider() Provider {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return provider
|
||||
}
|
||||
71
extension/fileio/types.go
Normal file
71
extension/fileio/types.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package fileio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Provider creates FileIO instances.
|
||||
// Follows the same API style as extension/credential.Provider.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
ResolveFileIO(ctx context.Context) FileIO
|
||||
}
|
||||
|
||||
// FileIO abstracts file transfer operations for CLI commands.
|
||||
// The default implementation operates on the local filesystem with
|
||||
// path validation, directory creation, and atomic writes.
|
||||
// Inject a custom implementation via Factory.FileIOProvider to replace
|
||||
// file transfer behavior (e.g. streaming in server mode).
|
||||
type FileIO interface {
|
||||
// Open opens a file for reading (upload, attachment, template scenarios).
|
||||
// The default implementation validates the path via SafeInputPath.
|
||||
Open(name string) (File, error)
|
||||
|
||||
// Stat returns file metadata (size validation, existence checks).
|
||||
// The default implementation validates the path via SafeInputPath.
|
||||
// Use os.IsNotExist(err) to distinguish "file not found" from "invalid path".
|
||||
Stat(name string) (FileInfo, error)
|
||||
|
||||
// ResolvePath returns the validated, absolute path for the given output path.
|
||||
// The default implementation delegates to SafeOutputPath.
|
||||
// Use this to obtain the canonical saved path for user-facing output.
|
||||
ResolvePath(path string) (string, error)
|
||||
|
||||
// Save writes content to the target path and returns a SaveResult.
|
||||
// The default implementation validates via SafeOutputPath, creates
|
||||
// parent directories, and writes atomically.
|
||||
Save(path string, opts SaveOptions, body io.Reader) (SaveResult, error)
|
||||
}
|
||||
|
||||
// FileInfo is a minimal subset of os.FileInfo covering actual CLI usage.
|
||||
// os.FileInfo satisfies this interface.
|
||||
type FileInfo interface {
|
||||
Size() int64
|
||||
IsDir() bool
|
||||
}
|
||||
|
||||
// File is the interface returned by FileIO.Open.
|
||||
// It covers the subset of *os.File methods actually used by CLI commands.
|
||||
// *os.File satisfies this interface without adaptation.
|
||||
type File interface {
|
||||
io.Reader
|
||||
io.ReaderAt
|
||||
io.Closer
|
||||
}
|
||||
|
||||
// SaveResult holds the outcome of a Save operation.
|
||||
type SaveResult interface {
|
||||
Size() int64 // actual bytes written
|
||||
}
|
||||
|
||||
// SaveOptions carries metadata for Save.
|
||||
// The default (local) implementation ignores these fields;
|
||||
// server-mode implementations use them to construct streaming response frames.
|
||||
type SaveOptions struct {
|
||||
ContentType string // MIME type
|
||||
ContentLength int64 // content length; -1 if unknown
|
||||
}
|
||||
45
internal/charcheck/charcheck.go
Normal file
45
internal/charcheck/charcheck.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package charcheck provides character-level security checks shared across
|
||||
// path validation (localfileio) and input validation (validate) packages.
|
||||
// Keeping these checks in one place ensures consistent detection of dangerous
|
||||
// Unicode and control characters throughout the codebase.
|
||||
package charcheck
|
||||
|
||||
import "fmt"
|
||||
|
||||
// RejectControlChars rejects C0 control characters (except \t and \n) and
|
||||
// dangerous Unicode characters (Bidi overrides, zero-width, line/paragraph
|
||||
// separators) that enable visual spoofing attacks.
|
||||
func RejectControlChars(value, flagName string) error {
|
||||
for _, r := range value {
|
||||
if r != '\t' && r != '\n' && (r < 0x20 || r == 0x7f) {
|
||||
return fmt.Errorf("%s contains invalid control characters", flagName)
|
||||
}
|
||||
if IsDangerousUnicode(r) {
|
||||
return fmt.Errorf("%s contains dangerous Unicode characters", flagName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsDangerousUnicode identifies Unicode code points used for visual spoofing
|
||||
// attacks. These characters are invisible or alter text direction, allowing
|
||||
// attackers to make "report.exe" display as "report.txt" (Bidi override) or
|
||||
// insert hidden content (zero-width characters).
|
||||
func IsDangerousUnicode(r rune) bool {
|
||||
switch {
|
||||
case r >= 0x200B && r <= 0x200D: // zero-width space/non-joiner/joiner
|
||||
return true
|
||||
case r == 0xFEFF: // BOM / ZWNBSP
|
||||
return true
|
||||
case r >= 0x202A && r <= 0x202E: // Bidi: LRE/RLE/PDF/LRO/RLO
|
||||
return true
|
||||
case r >= 0x2028 && r <= 0x2029: // line/paragraph separator
|
||||
return true
|
||||
case r >= 0x2066 && r <= 0x2069: // Bidi isolates: LRI/RLI/FSI/PDI
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
68
internal/client/api_errors.go
Normal file
68
internal/client/api_errors.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
const rawAPIJSONHint = "The endpoint may have returned an empty or non-standard JSON body. If it returns a file, rerun with --output."
|
||||
|
||||
// WrapDoAPIError upgrades malformed JSON decode errors from the SDK into
|
||||
// actionable API errors for raw `lark-cli api` calls. All other failures
|
||||
// remain network errors.
|
||||
func WrapDoAPIError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if isJSONDecodeError(err, false) {
|
||||
return output.ErrWithHint(output.ExitAPI, "api_error",
|
||||
fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint)
|
||||
}
|
||||
return output.ErrNetwork("API call failed: %v", err)
|
||||
}
|
||||
|
||||
// WrapJSONResponseParseError upgrades empty or malformed JSON response bodies
|
||||
// into API errors with hints instead of generic parse failures.
|
||||
func WrapJSONResponseParseError(err error, body []byte) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
return output.ErrWithHint(output.ExitAPI, "api_error",
|
||||
"API returned an empty JSON response body", rawAPIJSONHint)
|
||||
}
|
||||
if isJSONDecodeError(err, true) {
|
||||
return output.ErrWithHint(output.ExitAPI, "api_error",
|
||||
fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint)
|
||||
}
|
||||
return output.ErrNetwork("API call failed: %v", err)
|
||||
}
|
||||
|
||||
func isJSONDecodeError(err error, allowEOF bool) bool {
|
||||
var syntaxErr *json.SyntaxError
|
||||
var unmarshalTypeErr *json.UnmarshalTypeError
|
||||
|
||||
if errors.As(err, &syntaxErr) || errors.As(err, &unmarshalTypeErr) {
|
||||
return true
|
||||
}
|
||||
if allowEOF && (errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
|
||||
return true
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
if allowEOF && strings.Contains(msg, "unexpected EOF") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(msg, "unexpected end of JSON input") ||
|
||||
strings.Contains(msg, "invalid character") ||
|
||||
strings.Contains(msg, "cannot unmarshal")
|
||||
}
|
||||
68
internal/client/api_errors_test.go
Normal file
68
internal/client/api_errors_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestWrapDoAPIError_BareEOFIsNetworkError(t *testing.T) {
|
||||
err := WrapDoAPIError(io.EOF)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("expected ExitNetwork, got %d", exitErr.Code)
|
||||
}
|
||||
if strings.Contains(exitErr.Error(), "invalid JSON response") {
|
||||
t.Fatalf("unexpected JSON diagnostic for bare EOF: %q", exitErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapDoAPIError_SyntaxErrorIsAPIDiagnostic(t *testing.T) {
|
||||
err := WrapDoAPIError(&json.SyntaxError{Offset: 1})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") {
|
||||
t.Fatalf("expected JSON diagnostic message, got %#v", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapJSONResponseParseError_UnexpectedEOFIsAPIDiagnostic(t *testing.T) {
|
||||
err := WrapJSONResponseParseError(io.ErrUnexpectedEOF, []byte("{"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") {
|
||||
t.Fatalf("expected invalid JSON diagnostic, got %#v", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
@@ -6,18 +6,17 @@ package client
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// ── Response routing ──
|
||||
@@ -29,6 +28,7 @@ type ResponseOptions struct {
|
||||
JqExpr string // if set, apply jq filter instead of Format
|
||||
Out io.Writer // stdout
|
||||
ErrOut io.Writer // stderr
|
||||
FileIO fileio.FileIO // file transfer abstraction; required when saving files (--output or binary response)
|
||||
// CheckError is called on parsed JSON results. Nil defaults to CheckLarkResponse.
|
||||
CheckError func(interface{}) error
|
||||
}
|
||||
@@ -55,13 +55,13 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
if IsJSONContentType(ct) || ct == "" {
|
||||
result, err := ParseJSONResponse(resp)
|
||||
if err != nil {
|
||||
return output.ErrNetwork("API call failed: %v", err)
|
||||
return WrapJSONResponseParseError(err, resp.RawBody)
|
||||
}
|
||||
if apiErr := check(result); apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
if opts.OutputPath != "" {
|
||||
return saveAndPrint(resp, opts.OutputPath, opts.Out)
|
||||
return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out)
|
||||
}
|
||||
if opts.JqExpr != "" {
|
||||
return output.JqFilter(opts.Out, result, opts.JqExpr)
|
||||
@@ -75,11 +75,11 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
return output.ErrValidation("--jq requires a JSON response (got Content-Type: %s)", ct)
|
||||
}
|
||||
if opts.OutputPath != "" {
|
||||
return saveAndPrint(resp, opts.OutputPath, opts.Out)
|
||||
return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out)
|
||||
}
|
||||
|
||||
// No --output: auto-save with derived filename.
|
||||
meta, err := SaveResponse(resp, ResolveFilename(resp))
|
||||
meta, err := SaveResponse(opts.FileIO, resp, ResolveFilename(resp))
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "file_error", "%s", err)
|
||||
}
|
||||
@@ -88,8 +88,8 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveAndPrint(resp *larkcore.ApiResp, path string, w io.Writer) error {
|
||||
meta, err := SaveResponse(resp, path)
|
||||
func saveAndPrint(fio fileio.FileIO, resp *larkcore.ApiResp, path string, w io.Writer) error {
|
||||
meta, err := SaveResponse(fio, resp, path)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "file_error", "%s", err)
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func ParseJSONResponse(resp *larkcore.ApiResp) (interface{}, error) {
|
||||
dec := json.NewDecoder(bytes.NewReader(resp.RawBody))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("response parse error: %v (body: %s)", err, util.TruncateStr(string(resp.RawBody), 500))
|
||||
return nil, fmt.Errorf("response parse error: %w (body: %s)", err, util.TruncateStr(string(resp.RawBody), 500))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -119,23 +119,34 @@ func ParseJSONResponse(resp *larkcore.ApiResp) (interface{}, error) {
|
||||
// ── File saving ──
|
||||
|
||||
// SaveResponse writes an API response body to the given outputPath and returns metadata.
|
||||
func SaveResponse(resp *larkcore.ApiResp, outputPath string) (map[string]interface{}, error) {
|
||||
safePath, err := validate.SafeOutputPath(outputPath)
|
||||
// It delegates to FileIO.Save for path validation and atomic write; fio must not be nil.
|
||||
func SaveResponse(fio fileio.FileIO, resp *larkcore.ApiResp, outputPath string) (map[string]interface{}, error) {
|
||||
result, err := fio.Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: int64(len(resp.RawBody)),
|
||||
}, bytes.NewReader(resp.RawBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unsafe output path: %s", err)
|
||||
var me *fileio.MkdirError
|
||||
var we *fileio.WriteError
|
||||
switch {
|
||||
case errors.Is(err, fileio.ErrPathValidation):
|
||||
return nil, fmt.Errorf("unsafe output path: %s", err)
|
||||
case errors.As(err, &me):
|
||||
return nil, fmt.Errorf("create directory: %s", err)
|
||||
case errors.As(err, &we):
|
||||
return nil, fmt.Errorf("cannot write file: %s", err)
|
||||
default:
|
||||
return nil, fmt.Errorf("cannot write file: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
|
||||
return nil, fmt.Errorf("create directory: %s", err)
|
||||
resolvedPath, err := fio.ResolvePath(outputPath)
|
||||
if err != nil || resolvedPath == "" {
|
||||
resolvedPath = outputPath
|
||||
}
|
||||
|
||||
if err := validate.AtomicWrite(safePath, resp.RawBody, 0644); err != nil {
|
||||
return nil, fmt.Errorf("cannot write file: %s", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"saved_path": safePath,
|
||||
"size_bytes": len(resp.RawBody),
|
||||
"saved_path": resolvedPath,
|
||||
"size_bytes": result.Size(),
|
||||
"content_type": resp.Header.Get("Content-Type"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package client
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
func newApiResp(body []byte, headers map[string]string) *larkcore.ApiResp {
|
||||
@@ -75,6 +77,17 @@ func TestParseJSONResponse_Invalid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONResponse_EmptyBody_WrapsEOF(t *testing.T) {
|
||||
resp := newApiResp([]byte{}, map[string]string{"Content-Type": "application/json"})
|
||||
_, err := ParseJSONResponse(resp)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty body")
|
||||
}
|
||||
if !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("expected wrapped io.EOF, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFilename(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -150,11 +163,11 @@ func TestSaveResponse(t *testing.T) {
|
||||
body := []byte("hello binary data")
|
||||
resp := newApiResp(body, map[string]string{"Content-Type": "application/octet-stream"})
|
||||
|
||||
meta, err := SaveResponse(resp, "test_output.bin")
|
||||
meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "test_output.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveResponse failed: %v", err)
|
||||
}
|
||||
if meta["size_bytes"] != len(body) {
|
||||
if meta["size_bytes"] != int64(len(body)) {
|
||||
t.Errorf("expected size_bytes=%d, got %v", len(body), meta["size_bytes"])
|
||||
}
|
||||
|
||||
@@ -176,7 +189,7 @@ func TestSaveResponse_CreatesDir(t *testing.T) {
|
||||
|
||||
resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"})
|
||||
|
||||
meta, err := SaveResponse(resp, filepath.Join("sub", "deep", "out.bin"))
|
||||
meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, filepath.Join("sub", "deep", "out.bin"))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveResponse with nested dir failed: %v", err)
|
||||
}
|
||||
@@ -195,6 +208,7 @@ func TestHandleResponse_JSON(t *testing.T) {
|
||||
err := HandleResponse(resp, ResponseOptions{
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
FileIO: &localfileio.LocalFileIO{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleResponse failed: %v", err)
|
||||
@@ -213,12 +227,44 @@ func TestHandleResponse_JSONWithError(t *testing.T) {
|
||||
err := HandleResponse(resp, ResponseOptions{
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
FileIO: &localfileio.LocalFileIO{},
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("expected error for non-zero code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_EmptyJSONBody_ShowsDiagnostic(t *testing.T) {
|
||||
resp := newApiResp([]byte{}, map[string]string{"Content-Type": "application/json"})
|
||||
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty JSON body")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected detail on exit error")
|
||||
}
|
||||
if exitErr.Detail.Message != "API returned an empty JSON response body" {
|
||||
t.Fatalf("unexpected message: %q", exitErr.Detail.Message)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, "--output") {
|
||||
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_BinaryAutoSave(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
origWd, _ := os.Getwd()
|
||||
@@ -232,6 +278,7 @@ func TestHandleResponse_BinaryAutoSave(t *testing.T) {
|
||||
err := HandleResponse(resp, ResponseOptions{
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
FileIO: &localfileio.LocalFileIO{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleResponse binary failed: %v", err)
|
||||
@@ -255,6 +302,7 @@ func TestHandleResponse_BinaryWithOutput(t *testing.T) {
|
||||
OutputPath: "out.png",
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
FileIO: &localfileio.LocalFileIO{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleResponse with output path failed: %v", err)
|
||||
@@ -269,7 +317,7 @@ func TestHandleResponse_NonJSONError_404(t *testing.T) {
|
||||
resp := newApiRespWithStatus(404, []byte("404 page not found"), map[string]string{"Content-Type": "text/plain"})
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut})
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 404 text/plain")
|
||||
}
|
||||
@@ -287,7 +335,7 @@ func TestHandleResponse_NonJSONError_502(t *testing.T) {
|
||||
resp := newApiRespWithStatus(502, []byte("<html>Bad Gateway</html>"), map[string]string{"Content-Type": "text/html"})
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut})
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 502 text/html")
|
||||
}
|
||||
@@ -310,7 +358,7 @@ func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) {
|
||||
resp := newApiRespWithStatus(200, []byte("plain text file content"), map[string]string{"Content-Type": "text/plain"})
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut})
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for 200 text/plain, got: %v", err)
|
||||
}
|
||||
@@ -336,12 +384,53 @@ func TestHandleResponse_BinaryWithJq_RejectsNonJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveResponse_RejectsPathTraversal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
origWd, _ := os.Getwd()
|
||||
os.Chdir(dir)
|
||||
defer os.Chdir(origWd)
|
||||
|
||||
resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"})
|
||||
_, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "../../evil.txt")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for path traversal")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsafe output path") {
|
||||
t.Errorf("expected 'unsafe output path' wrapper, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveResponse_RejectsAbsolutePath(t *testing.T) {
|
||||
resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"})
|
||||
_, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "/tmp/evil.txt")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for absolute path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveResponse_MetadataContainsAbsolutePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
origWd, _ := os.Getwd()
|
||||
os.Chdir(dir)
|
||||
defer os.Chdir(origWd)
|
||||
|
||||
resp := newApiResp([]byte("x"), map[string]string{"Content-Type": "text/plain"})
|
||||
meta, err := SaveResponse(&localfileio.LocalFileIO{}, resp, "rel.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveResponse failed: %v", err)
|
||||
}
|
||||
savedPath, _ := meta["saved_path"].(string)
|
||||
if !filepath.IsAbs(savedPath) {
|
||||
t.Errorf("saved_path should be absolute, got %q", savedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_403JSON_CheckLarkResponse(t *testing.T) {
|
||||
body := []byte(`{"code":99991400,"msg":"invalid token"}`)
|
||||
resp := newApiRespWithStatus(403, body, map[string]string{"Content-Type": "application/json"})
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut})
|
||||
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut, FileIO: &localfileio.LocalFileIO{}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 403 JSON with non-zero code")
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
@@ -40,6 +41,17 @@ type Factory struct {
|
||||
ResolvedIdentity core.Identity // identity resolved by the last ResolveAs call
|
||||
|
||||
Credential *credential.CredentialProvider
|
||||
|
||||
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if f == nil || f.FileIOProvider == nil {
|
||||
return nil
|
||||
}
|
||||
return f.FileIOProvider.ResolveFileIO(ctx)
|
||||
}
|
||||
|
||||
// ResolveAs returns the effective identity type.
|
||||
|
||||
@@ -17,12 +17,14 @@ import (
|
||||
"golang.org/x/term"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
|
||||
)
|
||||
|
||||
// NewDefault creates a production Factory with cached closures.
|
||||
@@ -44,6 +46,9 @@ func NewDefault(inv InvocationContext) *Factory {
|
||||
IsTerminal: term.IsTerminal(int(os.Stdin.Fd())),
|
||||
}
|
||||
|
||||
// Phase 0: FileIO provider (no dependency)
|
||||
f.FileIOProvider = fileio.GetProvider()
|
||||
|
||||
// Phase 1: HttpClient (no credential dependency)
|
||||
f.HttpClient = cachedHttpClientFunc()
|
||||
|
||||
|
||||
@@ -11,13 +11,26 @@ import (
|
||||
"testing"
|
||||
|
||||
_ "github.com/larksuite/cli/extension/credential/env"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
type countingFileIOProvider struct {
|
||||
resolveCalls int
|
||||
}
|
||||
|
||||
func (p *countingFileIOProvider) Name() string { return "counting" }
|
||||
|
||||
func (p *countingFileIOProvider) ResolveFileIO(context.Context) fileio.FileIO {
|
||||
p.resolveCalls++
|
||||
return &localfileio.LocalFileIO{}
|
||||
}
|
||||
|
||||
func TestNewDefault_InvocationProfileUsedByStrictModeAndConfig(t *testing.T) {
|
||||
t.Setenv(envvars.CliAppID, "")
|
||||
t.Setenv(envvars.CliAppSecret, "")
|
||||
@@ -198,6 +211,28 @@ func TestNewDefault_ConfigUsesRuntimePlaceholderForTokenOnlyEnvAccount(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDefault_FileIOProviderDoesNotResolveDuringInitialization(t *testing.T) {
|
||||
prev := fileio.GetProvider()
|
||||
provider := &countingFileIOProvider{}
|
||||
fileio.Register(provider)
|
||||
t.Cleanup(func() { fileio.Register(prev) })
|
||||
|
||||
f := NewDefault(InvocationContext{})
|
||||
if f.FileIOProvider != provider {
|
||||
t.Fatalf("NewDefault() provider = %T, want %T", f.FileIOProvider, provider)
|
||||
}
|
||||
if provider.resolveCalls != 0 {
|
||||
t.Fatalf("ResolveFileIO() calls after NewDefault() = %d, want 0", provider.resolveCalls)
|
||||
}
|
||||
|
||||
if got := f.ResolveFileIO(context.Background()); got == nil {
|
||||
t.Fatal("ResolveFileIO() = nil, want non-nil")
|
||||
}
|
||||
if provider.resolveCalls != 1 {
|
||||
t.Fatalf("ResolveFileIO() calls after explicit resolve = %d, want 1", provider.resolveCalls)
|
||||
}
|
||||
}
|
||||
|
||||
type stubTransportProvider struct {
|
||||
interceptor exttransport.Interceptor
|
||||
}
|
||||
|
||||
@@ -7,14 +7,17 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// noopKeychain is a no-op KeychainAccess for tests that don't need keychain.
|
||||
@@ -62,12 +65,13 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer,
|
||||
)
|
||||
|
||||
f := &Factory{
|
||||
Config: func() (*core.CliConfig, error) { return config, nil },
|
||||
HttpClient: func() (*http.Client, error) { return mockClient, nil },
|
||||
LarkClient: func() (*lark.Client, error) { return testLarkClient, nil },
|
||||
IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf},
|
||||
Keychain: &noopKeychain{},
|
||||
Credential: testCred,
|
||||
Config: func() (*core.CliConfig, error) { return config, nil },
|
||||
HttpClient: func() (*http.Client, error) { return mockClient, nil },
|
||||
LarkClient: func() (*lark.Client, error) { return testLarkClient, nil },
|
||||
IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf},
|
||||
Keychain: &noopKeychain{},
|
||||
Credential: testCred,
|
||||
FileIOProvider: fileio.GetProvider(),
|
||||
}
|
||||
return f, stdoutBuf, stderrBuf, reg
|
||||
}
|
||||
@@ -83,6 +87,23 @@ func (a *testDefaultAcct) ResolveAccount(ctx context.Context) (*credential.Accou
|
||||
return credential.AccountFromCliConfig(a.config), nil
|
||||
}
|
||||
|
||||
// TestChdir changes the working directory to dir for the duration of the test.
|
||||
// The original directory is restored via t.Cleanup.
|
||||
// This enables tests to use LocalFileIO (which resolves relative paths under cwd)
|
||||
// with temporary directories, keeping test artifacts out of the source tree.
|
||||
// Not compatible with t.Parallel() — os.Chdir is process-wide.
|
||||
func TestChdir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
orig, err := vfs.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd: %v", err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil { //nolint:forbidigo // no vfs.Chdir yet; test-only, process-wide chdir
|
||||
t.Fatalf("Chdir(%s): %v", dir, err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(orig) }) //nolint:forbidigo // matching restore
|
||||
}
|
||||
|
||||
type testDefaultToken struct{}
|
||||
|
||||
func (t *testDefaultToken) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -21,6 +22,13 @@ var (
|
||||
)
|
||||
|
||||
func authLogDir() string {
|
||||
if dir := os.Getenv("LARKSUITE_CLI_LOG_DIR"); dir != "" {
|
||||
safeDir, err := validate.SafeEnvDirPath(dir, "LARKSUITE_CLI_LOG_DIR")
|
||||
if err == nil {
|
||||
return safeDir
|
||||
}
|
||||
}
|
||||
|
||||
if dir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR"); dir != "" {
|
||||
return filepath.Join(dir, "logs")
|
||||
}
|
||||
|
||||
35
internal/keychain/auth_log_test.go
Normal file
35
internal/keychain/auth_log_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAuthLogDir_UsesValidatedLogDirEnv verifies that a valid absolute
|
||||
// LARKSUITE_CLI_LOG_DIR is normalized and used as the auth log directory.
|
||||
func TestAuthLogDir_UsesValidatedLogDirEnv(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
base, _ = filepath.EvalSymlinks(base)
|
||||
t.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(base, "logs", "..", "auth"))
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", "")
|
||||
|
||||
got := authLogDir()
|
||||
want := filepath.Join(base, "auth")
|
||||
if got != want {
|
||||
t.Fatalf("authLogDir() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthLogDir_InvalidLogDirFallsBackToConfigDir verifies that an invalid
|
||||
// LARKSUITE_CLI_LOG_DIR falls back to LARKSUITE_CLI_CONFIG_DIR/logs.
|
||||
func TestAuthLogDir_InvalidLogDirFallsBackToConfigDir(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_LOG_DIR", "relative-logs")
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
|
||||
got := authLogDir()
|
||||
want := filepath.Join(configDir, "logs")
|
||||
if got != want {
|
||||
t.Fatalf("authLogDir() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"regexp"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -25,6 +26,12 @@ const tagBytes = 16
|
||||
|
||||
// StorageDir returns the directory where encrypted files are stored.
|
||||
func StorageDir(service string) string {
|
||||
if dir := os.Getenv("LARKSUITE_CLI_DATA_DIR"); dir != "" {
|
||||
safeDir, err := validate.SafeEnvDirPath(dir, "LARKSUITE_CLI_DATA_DIR")
|
||||
if err == nil {
|
||||
return filepath.Join(safeDir, service)
|
||||
}
|
||||
}
|
||||
home, err := vfs.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
// If home is missing, fallback to relative path and print warning.
|
||||
|
||||
37
internal/keychain/keychain_other_test.go
Normal file
37
internal/keychain/keychain_other_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
//go:build linux
|
||||
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestStorageDir_UsesValidatedDataDirEnv verifies that a valid absolute
|
||||
// LARKSUITE_CLI_DATA_DIR is normalized and still preserves service isolation.
|
||||
func TestStorageDir_UsesValidatedDataDirEnv(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
base, _ = filepath.EvalSymlinks(base)
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", filepath.Join(base, "data", "..", "store"))
|
||||
|
||||
got := StorageDir("svc")
|
||||
want := filepath.Join(base, "store", "svc")
|
||||
if got != want {
|
||||
t.Fatalf("StorageDir() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStorageDir_InvalidDataDirFallsBackToDefault verifies that an invalid
|
||||
// LARKSUITE_CLI_DATA_DIR falls back to the default per-service storage path.
|
||||
func TestStorageDir_InvalidDataDirFallsBackToDefault(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
home, _ = filepath.EvalSymlinks(home)
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", "relative-data")
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
got := StorageDir("svc")
|
||||
want := filepath.Join(home, ".local", "share", "svc")
|
||||
if got != want {
|
||||
t.Fatalf("StorageDir() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -4,74 +4,20 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
// AtomicWrite writes data to path atomically by creating a temp file in the
|
||||
// same directory, writing and fsyncing the data, then renaming over the target.
|
||||
// It replaces os.WriteFile for all config and download file writes.
|
||||
//
|
||||
// os.WriteFile truncates the target before writing, so a process kill (CI timeout,
|
||||
// OOM, Ctrl+C) between truncate and completion leaves the file empty or partial.
|
||||
// AtomicWrite avoids this: on any failure the temp file is cleaned up and the
|
||||
// original file remains untouched.
|
||||
// AtomicWrite writes data to path atomically.
|
||||
// Delegates to localfileio.AtomicWrite.
|
||||
func AtomicWrite(path string, data []byte, perm os.FileMode) error {
|
||||
return atomicWrite(path, perm, func(tmp *os.File) error {
|
||||
_, err := tmp.Write(data)
|
||||
return err
|
||||
})
|
||||
return localfileio.AtomicWrite(path, data, perm)
|
||||
}
|
||||
|
||||
// AtomicWriteFromReader atomically copies reader contents into path.
|
||||
// Delegates to localfileio.AtomicWriteFromReader.
|
||||
func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int64, error) {
|
||||
var copied int64
|
||||
err := atomicWrite(path, perm, func(tmp *os.File) error {
|
||||
n, err := io.Copy(tmp, reader)
|
||||
copied = n
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return copied, nil
|
||||
}
|
||||
|
||||
func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := vfs.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
tmp.Close()
|
||||
vfs.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := tmp.Chmod(perm); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFn(tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.Rename(tmpName, path); err != nil {
|
||||
return err
|
||||
}
|
||||
success = true
|
||||
return nil
|
||||
return localfileio.AtomicWriteFromReader(path, reader, perm)
|
||||
}
|
||||
|
||||
@@ -6,25 +6,17 @@ package validate
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
)
|
||||
|
||||
// RejectControlChars rejects C0 control characters (except \t and \n) and
|
||||
// dangerous Unicode characters from user input.
|
||||
//
|
||||
// Control characters cause subtle security issues: null bytes truncate strings
|
||||
// at the C layer, \r\n enables HTTP header injection
|
||||
// Unicode characters allow visual spoofing (e.g. making "report.exe" display
|
||||
// as "report.txt").
|
||||
// Delegates to charcheck.RejectControlChars — the single source of truth
|
||||
// for character-level security checks.
|
||||
func RejectControlChars(value, flagName string) error {
|
||||
for _, r := range value {
|
||||
if r != '\t' && r != '\n' && (r < 0x20 || r == 0x7f) {
|
||||
return fmt.Errorf("%s contains invalid control characters", flagName)
|
||||
}
|
||||
if isDangerousUnicode(r) {
|
||||
return fmt.Errorf("%s contains dangerous Unicode characters", flagName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return charcheck.RejectControlChars(value, flagName)
|
||||
}
|
||||
|
||||
// RejectCRLF rejects strings containing carriage return (\r) or line feed (\n).
|
||||
@@ -48,23 +40,3 @@ func StripQueryFragment(path string) string {
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// isDangerousUnicode identifies Unicode code points used for visual spoofing attacks.
|
||||
// These characters are invisible or alter text direction, allowing attackers to make
|
||||
// "report.exe" display as "report.txt" (Bidi override) or insert hidden content
|
||||
// (zero-width characters).
|
||||
func isDangerousUnicode(r rune) bool {
|
||||
switch {
|
||||
case r >= 0x200B && r <= 0x200D: // zero-width space/non-joiner/joiner
|
||||
return true
|
||||
case r == 0xFEFF: // BOM / ZWNBSP
|
||||
return true
|
||||
case r >= 0x202A && r <= 0x202E: // Bidi: LRE/RLE/PDF/LRO/RLO
|
||||
return true
|
||||
case r >= 0x2028 && r <= 0x2029: // line/paragraph separator
|
||||
return true
|
||||
case r >= 0x2066 && r <= 0x2069: // Bidi isolates: LRI/RLI/FSI/PDI
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -3,127 +3,28 @@
|
||||
|
||||
package validate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
import "github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// SafeOutputPath validates a download/export target path for --output flags.
|
||||
// It rejects absolute paths, resolves symlinks to their real location, and
|
||||
// verifies the canonical result is still under the current working directory.
|
||||
// This prevents an AI Agent from being tricked into writing files outside the
|
||||
// working directory (e.g. "../../.ssh/authorized_keys") or following symlinks
|
||||
// to sensitive locations.
|
||||
//
|
||||
// The returned absolute path MUST be used for all subsequent I/O to prevent
|
||||
// time-of-check-to-time-of-use (TOCTOU) race conditions.
|
||||
// SafeOutputPath validates a download/export target path.
|
||||
// Delegates to localfileio.SafeOutputPath.
|
||||
func SafeOutputPath(path string) (string, error) {
|
||||
return safePath(path, "--output")
|
||||
return localfileio.SafeOutputPath(path)
|
||||
}
|
||||
|
||||
// SafeInputPath validates an upload/read source path for --file flags.
|
||||
// It applies the same rules as SafeOutputPath — rejecting absolute paths,
|
||||
// resolving symlinks, and enforcing working directory containment — to prevent an AI Agent
|
||||
// from being tricked into reading sensitive files like /etc/passwd.
|
||||
// SafeInputPath validates an upload/read source path.
|
||||
// Delegates to localfileio.SafeInputPath.
|
||||
func SafeInputPath(path string) (string, error) {
|
||||
return safePath(path, "--file")
|
||||
return localfileio.SafeInputPath(path)
|
||||
}
|
||||
|
||||
// SafeEnvDirPath validates an environment-provided application directory path.
|
||||
// Delegates to localfileio.SafeEnvDirPath.
|
||||
func SafeEnvDirPath(path, envName string) (string, error) {
|
||||
return localfileio.SafeEnvDirPath(path, envName)
|
||||
}
|
||||
|
||||
// SafeLocalFlagPath validates a flag value as a local file path.
|
||||
// Empty values and http/https URLs are returned unchanged without validation,
|
||||
// allowing the caller to handle non-path inputs (e.g. API keys, URLs) upstream.
|
||||
// For all other values, SafeInputPath rules apply.
|
||||
// The original relative path is returned unchanged (not resolved to absolute) so
|
||||
// upload helpers can re-validate at the actual I/O point via SafeUploadPath.
|
||||
// Delegates to localfileio.SafeLocalFlagPath.
|
||||
func SafeLocalFlagPath(flagName, value string) (string, error) {
|
||||
if value == "" || strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
|
||||
return value, nil
|
||||
}
|
||||
if _, err := SafeInputPath(value); err != nil {
|
||||
return "", fmt.Errorf("%s: %v", flagName, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// safePath is the shared implementation for SafeOutputPath and SafeInputPath.
|
||||
func safePath(raw, flagName string) (string, error) {
|
||||
if err := RejectControlChars(raw, flagName); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
path := filepath.Clean(raw)
|
||||
|
||||
if filepath.IsAbs(path) {
|
||||
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
|
||||
}
|
||||
|
||||
cwd, err := vfs.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot determine working directory: %w", err)
|
||||
}
|
||||
resolved := filepath.Join(cwd, path)
|
||||
|
||||
// Resolve symlinks: for existing paths, follow to real location;
|
||||
// for non-existing paths, walk up to the nearest existing ancestor,
|
||||
// resolve its symlinks, and re-attach the remaining tail segments.
|
||||
// This prevents TOCTOU attacks where a non-existent intermediate
|
||||
// directory is replaced with a symlink between check and use.
|
||||
if _, err := vfs.Lstat(resolved); err == nil {
|
||||
resolved, err = filepath.EvalSymlinks(resolved)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
|
||||
}
|
||||
} else {
|
||||
resolved, err = resolveNearestAncestor(resolved)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
canonicalCwd, _ := filepath.EvalSymlinks(cwd)
|
||||
if !isUnderDir(resolved, canonicalCwd) {
|
||||
return "", fmt.Errorf("%s %q resolves outside the current working directory (hint: the path must stay within the working directory after resolving .. and symlinks)", flagName, raw)
|
||||
}
|
||||
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// resolveNearestAncestor walks up from path until it finds an existing
|
||||
// ancestor, resolves that ancestor's symlinks, and re-joins the tail.
|
||||
// This ensures even deeply nested non-existent paths are anchored to a
|
||||
// real filesystem location, closing the TOCTOU symlink gap.
|
||||
func resolveNearestAncestor(path string) (string, error) {
|
||||
var tail []string
|
||||
cur := path
|
||||
for {
|
||||
if _, err := vfs.Lstat(cur); err == nil {
|
||||
real, err := filepath.EvalSymlinks(cur)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts := append([]string{real}, tail...)
|
||||
return filepath.Join(parts...), nil
|
||||
}
|
||||
parent := filepath.Dir(cur)
|
||||
if parent == cur {
|
||||
// Reached filesystem root without finding an existing ancestor;
|
||||
// return path as-is and let the containment check reject it.
|
||||
parts := append([]string{cur}, tail...)
|
||||
return filepath.Join(parts...), nil
|
||||
}
|
||||
tail = append([]string{filepath.Base(cur)}, tail...)
|
||||
cur = parent
|
||||
}
|
||||
}
|
||||
|
||||
// isUnderDir checks whether child is under parent directory.
|
||||
func isUnderDir(child, parent string) bool {
|
||||
rel, err := filepath.Rel(parent, child)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".."
|
||||
return localfileio.SafeLocalFlagPath(flagName, value)
|
||||
}
|
||||
|
||||
@@ -283,3 +283,30 @@ func TestSafeInputPath_ErrorMessageContainsCorrectFlagName(t *testing.T) {
|
||||
t.Errorf("error should mention --output, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSafeEnvDirPath_RequiresAbsolutePath verifies that environment-provided
|
||||
// directory paths must be absolute.
|
||||
func TestSafeEnvDirPath_RequiresAbsolutePath(t *testing.T) {
|
||||
_, err := SafeEnvDirPath("logs", "LARKSUITE_CLI_LOG_DIR")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for relative path")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "LARKSUITE_CLI_LOG_DIR") {
|
||||
t.Fatalf("error should mention env name, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSafeEnvDirPath_ReturnsNormalizedAbsolutePath verifies that a valid
|
||||
// absolute environment directory is cleaned and resolved to its canonical path.
|
||||
func TestSafeEnvDirPath_ReturnsNormalizedAbsolutePath(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
base, _ = filepath.EvalSymlinks(base)
|
||||
got, err := SafeEnvDirPath(filepath.Join(base, "logs", "..", "auth"), "LARKSUITE_CLI_LOG_DIR")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := filepath.Join(base, "auth")
|
||||
if got != want {
|
||||
t.Fatalf("SafeEnvDirPath() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
)
|
||||
|
||||
// unsafeResourceChars matches URL-special characters, control characters,
|
||||
@@ -35,7 +37,7 @@ func ResourceName(name, flagName string) error {
|
||||
return fmt.Errorf("%s contains invalid characters", flagName)
|
||||
}
|
||||
for _, r := range name {
|
||||
if isDangerousUnicode(r) {
|
||||
if charcheck.IsDangerousUnicode(r) {
|
||||
return fmt.Errorf("%s contains dangerous Unicode characters", flagName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ package validate
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
)
|
||||
|
||||
// ansiEscape matches ANSI CSI sequences (ESC[ ... letter) and OSC sequences (ESC] ... BEL).
|
||||
@@ -34,7 +36,7 @@ func SanitizeForTerminal(text string) string {
|
||||
b.WriteRune(r)
|
||||
case r < 0x20 || r == 0x7f:
|
||||
continue
|
||||
case isDangerousUnicode(r):
|
||||
case charcheck.IsDangerousUnicode(r):
|
||||
continue
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
|
||||
@@ -5,6 +5,8 @@ package validate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
)
|
||||
|
||||
func TestSanitizeForTerminal_StripsEscapesAndDangerousChars(t *testing.T) {
|
||||
@@ -74,16 +76,16 @@ func TestIsDangerousUnicode_IdentifiesAllDangerousRanges(t *testing.T) {
|
||||
0x2066, 0x2067, 0x2068, 0x2069, // isolates
|
||||
}
|
||||
for _, r := range dangerous {
|
||||
if !isDangerousUnicode(r) {
|
||||
t.Errorf("isDangerousUnicode(%U) = false, want true", r)
|
||||
if !charcheck.IsDangerousUnicode(r) {
|
||||
t.Errorf("charcheck.IsDangerousUnicode(%U) = false, want true", r)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GIVEN: safe Unicode code points → THEN: returns false ──
|
||||
safe := []rune{'A', '中', '!', ' ', '\t', '\n', 0x200A, 0x2070}
|
||||
for _, r := range safe {
|
||||
if isDangerousUnicode(r) {
|
||||
t.Errorf("isDangerousUnicode(%U) = true, want false", r)
|
||||
if charcheck.IsDangerousUnicode(r) {
|
||||
t.Errorf("charcheck.IsDangerousUnicode(%U) = true, want false", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
74
internal/vfs/localfileio/atomicwrite.go
Normal file
74
internal/vfs/localfileio/atomicwrite.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// AtomicWrite writes data to path atomically via temp file + rename.
|
||||
func AtomicWrite(path string, data []byte, perm os.FileMode) error {
|
||||
return atomicWrite(path, perm, func(tmp *os.File) error {
|
||||
_, err := tmp.Write(data)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// AtomicWriteFromReader atomically copies reader contents into path.
|
||||
func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int64, error) {
|
||||
var copied int64
|
||||
err := atomicWrite(path, perm, func(tmp *os.File) error {
|
||||
n, err := io.Copy(tmp, reader)
|
||||
copied = n
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return copied, nil
|
||||
}
|
||||
|
||||
func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := vfs.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
|
||||
closed := false
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
if !closed {
|
||||
tmp.Close()
|
||||
}
|
||||
vfs.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := tmp.Chmod(perm); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFn(tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
closed = true
|
||||
if err := vfs.Rename(tmpName, path); err != nil {
|
||||
return err
|
||||
}
|
||||
success = true
|
||||
return nil
|
||||
}
|
||||
146
internal/vfs/localfileio/atomicwrite_test.go
Normal file
146
internal/vfs/localfileio/atomicwrite_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAtomicWrite_WritesContentAndPermissionCorrectly(t *testing.T) {
|
||||
// GIVEN: a target path in a temp directory
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.json")
|
||||
data := []byte(`{"key":"value"}`)
|
||||
|
||||
// WHEN: AtomicWrite writes data with 0644 permission
|
||||
if err := AtomicWrite(path, data, 0644); err != nil {
|
||||
t.Fatalf("AtomicWrite failed: %v", err)
|
||||
}
|
||||
|
||||
// THEN: file content matches exactly
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile failed: %v", err)
|
||||
}
|
||||
if string(got) != string(data) {
|
||||
t.Errorf("content = %q, want %q", got, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicWrite_SetsRestrictivePermission(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission test not reliable on Windows")
|
||||
}
|
||||
|
||||
// GIVEN: a target path
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "secret.json")
|
||||
|
||||
// WHEN: AtomicWrite writes with 0600 permission
|
||||
if err := AtomicWrite(path, []byte("secret"), 0600); err != nil {
|
||||
t.Fatalf("AtomicWrite failed: %v", err)
|
||||
}
|
||||
|
||||
// THEN: file permission is exactly 0600 (owner read-write only)
|
||||
info, _ := os.Stat(path)
|
||||
if perm := info.Mode().Perm(); perm != 0600 {
|
||||
t.Errorf("permission = %04o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicWrite_OverwritesExistingFile(t *testing.T) {
|
||||
// GIVEN: an existing file with old content
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.json")
|
||||
AtomicWrite(path, []byte("old"), 0644)
|
||||
|
||||
// WHEN: AtomicWrite overwrites with new content
|
||||
if err := AtomicWrite(path, []byte("new"), 0644); err != nil {
|
||||
t.Fatalf("second write failed: %v", err)
|
||||
}
|
||||
|
||||
// THEN: file contains new content
|
||||
got, _ := os.ReadFile(path)
|
||||
if string(got) != "new" {
|
||||
t.Errorf("content = %q, want %q", got, "new")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicWrite_LeavesNoResidualTempFileOnError(t *testing.T) {
|
||||
// GIVEN: a target path in a non-existent nested directory
|
||||
path := filepath.Join(t.TempDir(), "nonexistent", "subdir", "file.txt")
|
||||
|
||||
// WHEN: AtomicWrite fails (parent directory doesn't exist)
|
||||
err := AtomicWrite(path, []byte("data"), 0644)
|
||||
|
||||
// THEN: the write fails
|
||||
if err == nil {
|
||||
t.Fatal("expected error writing to nonexistent dir")
|
||||
}
|
||||
|
||||
// THEN: no .tmp files are left behind
|
||||
parentDir := filepath.Dir(filepath.Dir(path))
|
||||
entries, _ := os.ReadDir(parentDir)
|
||||
for _, e := range entries {
|
||||
if filepath.Ext(e.Name()) == ".tmp" {
|
||||
t.Errorf("residual temp file found: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicWrite_PreservesOriginalFileOnFailure(t *testing.T) {
|
||||
// GIVEN: an existing file with known content
|
||||
dir := t.TempDir()
|
||||
original := []byte("original content")
|
||||
path := filepath.Join(dir, "file.json")
|
||||
if err := AtomicWrite(path, original, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// WHEN: AtomicWrite targets a non-existent directory (guaranteed to fail even as root)
|
||||
badPath := filepath.Join(dir, "no", "such", "dir", "file.json")
|
||||
err := AtomicWrite(badPath, []byte("new"), 0644)
|
||||
|
||||
// THEN: write fails
|
||||
if err == nil {
|
||||
t.Fatal("expected error writing to non-existent dir")
|
||||
}
|
||||
|
||||
// THEN: the original file at the valid path is untouched
|
||||
got, _ := os.ReadFile(path)
|
||||
if string(got) != string(original) {
|
||||
t.Errorf("original file corrupted: got %q, want %q", got, original)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicWrite_HandlesCorrectlyUnderConcurrentWrites(t *testing.T) {
|
||||
// GIVEN: a target file that will be written by 20 concurrent goroutines
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "concurrent.json")
|
||||
|
||||
// WHEN: 20 goroutines write simultaneously
|
||||
var wg sync.WaitGroup
|
||||
for i := range 20 {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
data := []byte(`{"n":` + string(rune('0'+n%10)) + `}`)
|
||||
AtomicWrite(path, data, 0644)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// THEN: file exists and is valid (not corrupted by interleaved writes)
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile failed: %v", err)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Error("file is empty after concurrent writes")
|
||||
}
|
||||
}
|
||||
81
internal/vfs/localfileio/localfileio.go
Normal file
81
internal/vfs/localfileio/localfileio.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// Provider is the default fileio.Provider backed by the local filesystem.
|
||||
type Provider struct{}
|
||||
|
||||
func (p *Provider) Name() string { return "local" }
|
||||
|
||||
func (p *Provider) ResolveFileIO(_ context.Context) fileio.FileIO {
|
||||
return &LocalFileIO{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
fileio.Register(&Provider{})
|
||||
}
|
||||
|
||||
// LocalFileIO implements fileio.FileIO using the local filesystem.
|
||||
// Path validation (SafeInputPath/SafeOutputPath), directory creation,
|
||||
// and atomic writes are handled internally.
|
||||
type LocalFileIO struct{}
|
||||
|
||||
// Open opens a local file for reading after validating the path.
|
||||
func (l *LocalFileIO) Open(name string) (fileio.File, error) {
|
||||
safePath, err := SafeInputPath(name)
|
||||
if err != nil {
|
||||
return nil, &fileio.PathValidationError{Err: err}
|
||||
}
|
||||
return vfs.Open(safePath)
|
||||
}
|
||||
|
||||
// Stat returns file metadata after validating the path.
|
||||
func (l *LocalFileIO) Stat(name string) (fileio.FileInfo, error) {
|
||||
safePath, err := SafeInputPath(name)
|
||||
if err != nil {
|
||||
return nil, &fileio.PathValidationError{Err: err}
|
||||
}
|
||||
return vfs.Stat(safePath)
|
||||
}
|
||||
|
||||
// saveResult implements fileio.SaveResult.
|
||||
type saveResult struct{ size int64 }
|
||||
|
||||
func (r *saveResult) Size() int64 { return r.size }
|
||||
|
||||
// ResolvePath returns the validated absolute path for the given output path.
|
||||
func (l *LocalFileIO) ResolvePath(path string) (string, error) {
|
||||
resolved, err := SafeOutputPath(path)
|
||||
if err != nil {
|
||||
return "", &fileio.PathValidationError{Err: err}
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// Save writes body to path atomically after validating the output path.
|
||||
// Parent directories are created as needed. The body is streamed directly
|
||||
// to a temp file and renamed, avoiding full in-memory buffering.
|
||||
func (l *LocalFileIO) Save(path string, _ fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) {
|
||||
safePath, err := SafeOutputPath(path)
|
||||
if err != nil {
|
||||
return nil, &fileio.PathValidationError{Err: err}
|
||||
}
|
||||
if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
|
||||
return nil, &fileio.MkdirError{Err: err}
|
||||
}
|
||||
n, err := AtomicWriteFromReader(safePath, body, 0600)
|
||||
if err != nil {
|
||||
return nil, &fileio.WriteError{Err: err}
|
||||
}
|
||||
return &saveResult{size: n}, nil
|
||||
}
|
||||
306
internal/vfs/localfileio/localfileio_test.go
Normal file
306
internal/vfs/localfileio/localfileio_test.go
Normal file
@@ -0,0 +1,306 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
// testChdir temporarily changes the working directory for a test.
|
||||
// Not compatible with t.Parallel().
|
||||
func testChdir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(orig) })
|
||||
}
|
||||
|
||||
// ── Provider ──
|
||||
|
||||
func TestProvider_Name(t *testing.T) {
|
||||
p := &Provider{}
|
||||
if got := p.Name(); got != "local" {
|
||||
t.Errorf("Provider.Name() = %q, want %q", got, "local")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_ResolveFileIO(t *testing.T) {
|
||||
p := &Provider{}
|
||||
fio := p.ResolveFileIO(nil)
|
||||
if fio == nil {
|
||||
t.Fatal("Provider.ResolveFileIO returned nil")
|
||||
}
|
||||
if _, ok := fio.(*LocalFileIO); !ok {
|
||||
t.Errorf("expected *LocalFileIO, got %T", fio)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Open ──
|
||||
|
||||
func TestLocalFileIO_Open_ValidFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
content := []byte("hello world")
|
||||
os.WriteFile("test.txt", content, 0644)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
f, err := fio.Open("test.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Open failed: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
got, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll failed: %v", err)
|
||||
}
|
||||
if string(got) != string(content) {
|
||||
t.Errorf("content = %q, want %q", got, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_Open_RejectsTraversal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
_, err := fio.Open("../../etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("expected error for path traversal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_Open_RejectsAbsolutePath(t *testing.T) {
|
||||
fio := &LocalFileIO{}
|
||||
_, err := fio.Open("/etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("expected error for absolute path")
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "relative path") {
|
||||
t.Errorf("error should mention relative path, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_Open_NonexistentFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
_, err := fio.Open("nonexistent.txt")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stat ──
|
||||
|
||||
func TestLocalFileIO_Stat_ValidFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
os.WriteFile("stat.txt", []byte("12345"), 0644)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
info, err := fio.Stat("stat.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Stat failed: %v", err)
|
||||
}
|
||||
if info.Size() != 5 {
|
||||
t.Errorf("Size() = %d, want 5", info.Size())
|
||||
}
|
||||
if info.IsDir() {
|
||||
t.Error("expected IsDir() = false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_Stat_RejectsTraversal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
_, err := fio.Stat("../../etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("expected error for path traversal")
|
||||
}
|
||||
if err != nil && os.IsNotExist(err) {
|
||||
t.Error("traversal should not be os.IsNotExist, should be a validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_Stat_NonexistentReturnsIsNotExist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
_, err := fio.Stat("nope.txt")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
t.Errorf("expected os.IsNotExist, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Save ──
|
||||
|
||||
func TestLocalFileIO_Save_WritesContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
body := strings.NewReader("saved content")
|
||||
result, err := fio.Save("output.bin", fileio.SaveOptions{}, body)
|
||||
if err != nil {
|
||||
t.Fatalf("Save failed: %v", err)
|
||||
}
|
||||
if result.Size() != int64(len("saved content")) {
|
||||
t.Errorf("Size() = %d, want %d", result.Size(), len("saved content"))
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(filepath.Join(dir, "output.bin"))
|
||||
if string(got) != "saved content" {
|
||||
t.Errorf("file content = %q, want %q", got, "saved content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_Save_CreatesParentDirs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
body := strings.NewReader("nested")
|
||||
_, err := fio.Save(filepath.Join("a", "b", "c.txt"), fileio.SaveOptions{}, body)
|
||||
if err != nil {
|
||||
t.Fatalf("Save with nested dir failed: %v", err)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(filepath.Join(dir, "a", "b", "c.txt"))
|
||||
if string(got) != "nested" {
|
||||
t.Errorf("file content = %q, want %q", got, "nested")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_Save_RejectsTraversal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
_, err := fio.Save("../../evil.txt", fileio.SaveOptions{}, strings.NewReader("bad"))
|
||||
if err == nil {
|
||||
t.Error("expected error for path traversal in Save")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_Save_RejectsAbsolutePath(t *testing.T) {
|
||||
fio := &LocalFileIO{}
|
||||
_, err := fio.Save("/tmp/evil.txt", fileio.SaveOptions{}, strings.NewReader("bad"))
|
||||
if err == nil {
|
||||
t.Error("expected error for absolute path in Save")
|
||||
}
|
||||
}
|
||||
|
||||
// ── ResolvePath ──
|
||||
|
||||
func TestLocalFileIO_ResolvePath_ReturnsAbsolute(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
resolved, err := fio.ResolvePath("file.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolvePath failed: %v", err)
|
||||
}
|
||||
if !filepath.IsAbs(resolved) {
|
||||
t.Errorf("expected absolute path, got %q", resolved)
|
||||
}
|
||||
if filepath.Base(resolved) != "file.txt" {
|
||||
t.Errorf("expected base name file.txt, got %q", filepath.Base(resolved))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_ResolvePath_RejectsTraversal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
_, err := fio.ResolvePath("../../etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("expected error for path traversal in ResolvePath")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFileIO_ResolvePath_RejectsAbsolute(t *testing.T) {
|
||||
fio := &LocalFileIO{}
|
||||
_, err := fio.ResolvePath("/etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("expected error for absolute path in ResolvePath")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error message consistency ──
|
||||
|
||||
func TestLocalFileIO_ErrorMessages_ContainCorrectFlagName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
|
||||
// Open/Stat use SafeInputPath → errors should mention "--file"
|
||||
_, err := fio.Open("/absolute/path")
|
||||
if err == nil || !strings.Contains(err.Error(), "--file") {
|
||||
t.Errorf("Open absolute path error should mention --file, got: %v", err)
|
||||
}
|
||||
|
||||
_, err = fio.Stat("/absolute/path")
|
||||
if err == nil || !strings.Contains(err.Error(), "--file") {
|
||||
t.Errorf("Stat absolute path error should mention --file, got: %v", err)
|
||||
}
|
||||
|
||||
// Save/ResolvePath use SafeOutputPath → errors should mention "--output"
|
||||
_, err = fio.Save("/absolute/path", fileio.SaveOptions{}, strings.NewReader(""))
|
||||
if err == nil || !strings.Contains(err.Error(), "--output") {
|
||||
t.Errorf("Save absolute path error should mention --output, got: %v", err)
|
||||
}
|
||||
|
||||
_, err = fio.ResolvePath("/absolute/path")
|
||||
if err == nil || !strings.Contains(err.Error(), "--output") {
|
||||
t.Errorf("ResolvePath absolute path error should mention --output, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Control character / Unicode rejection ──
|
||||
|
||||
func TestLocalFileIO_RejectsControlCharsInPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
testChdir(t, dir)
|
||||
|
||||
fio := &LocalFileIO{}
|
||||
paths := []string{
|
||||
"file\x00name.txt", // null byte
|
||||
"file\x1fname.txt", // control char
|
||||
"file\u200Bname.txt", // zero-width space
|
||||
"file\u202Ename.txt", // bidi override
|
||||
}
|
||||
|
||||
for _, p := range paths {
|
||||
if _, err := fio.Open(p); err == nil {
|
||||
t.Errorf("Open(%q) should reject control/dangerous chars", p)
|
||||
}
|
||||
if _, err := fio.Save(p, fileio.SaveOptions{}, strings.NewReader("")); err == nil {
|
||||
t.Errorf("Save(%q) should reject control/dangerous chars", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
131
internal/vfs/localfileio/path.go
Normal file
131
internal/vfs/localfileio/path.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/charcheck"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// SafeOutputPath validates a download/export target path for --output flags.
|
||||
func SafeOutputPath(path string) (string, error) {
|
||||
return safePath(path, "--output")
|
||||
}
|
||||
|
||||
// SafeInputPath validates an upload/read source path for --file flags.
|
||||
func SafeInputPath(path string) (string, error) {
|
||||
return safePath(path, "--file")
|
||||
}
|
||||
|
||||
// SafeLocalFlagPath validates a flag value as a local file path.
|
||||
// Empty values and http/https URLs are returned unchanged without validation.
|
||||
func SafeLocalFlagPath(flagName, value string) (string, error) {
|
||||
if value == "" || strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
|
||||
return value, nil
|
||||
}
|
||||
if _, err := SafeInputPath(value); err != nil {
|
||||
return "", fmt.Errorf("%s: %v", flagName, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// SafeEnvDirPath validates an environment-provided application directory path.
|
||||
// It requires an absolute path, rejects control characters, normalizes the
|
||||
// input, and resolves symlinks through the nearest existing ancestor.
|
||||
func SafeEnvDirPath(path, envName string) (string, error) {
|
||||
if err := charcheck.RejectControlChars(path, envName); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
path = filepath.Clean(path)
|
||||
if !filepath.IsAbs(path) {
|
||||
return "", fmt.Errorf("%s must be an absolute path, got %q", envName, path)
|
||||
}
|
||||
|
||||
resolved, err := resolveNearestAncestor(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// safePath is the shared implementation for SafeOutputPath and SafeInputPath.
|
||||
func safePath(raw, flagName string) (string, error) {
|
||||
if err := charcheck.RejectControlChars(raw, flagName); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
path := filepath.Clean(raw)
|
||||
|
||||
if filepath.IsAbs(path) {
|
||||
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
|
||||
}
|
||||
|
||||
cwd, err := vfs.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot determine working directory: %w", err)
|
||||
}
|
||||
resolved := filepath.Join(cwd, path)
|
||||
|
||||
if _, err := vfs.Lstat(resolved); err == nil {
|
||||
resolved, err = filepath.EvalSymlinks(resolved)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
|
||||
}
|
||||
} else {
|
||||
resolved, err = resolveNearestAncestor(resolved)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
canonicalCwd, _ := filepath.EvalSymlinks(cwd)
|
||||
if !isUnderDir(resolved, canonicalCwd) {
|
||||
return "", fmt.Errorf("%s %q resolves outside the current working directory (hint: the path must stay within the working directory after resolving .. and symlinks)", flagName, raw)
|
||||
}
|
||||
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func resolveNearestAncestor(path string) (string, error) {
|
||||
var tail []string
|
||||
cur := path
|
||||
for {
|
||||
if _, err := vfs.Lstat(cur); err == nil {
|
||||
real, err := filepath.EvalSymlinks(cur)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts := append([]string{real}, tail...)
|
||||
return filepath.Join(parts...), nil
|
||||
}
|
||||
parent := filepath.Dir(cur)
|
||||
if parent == cur {
|
||||
parts := append([]string{cur}, tail...)
|
||||
return filepath.Join(parts...), nil
|
||||
}
|
||||
tail = append([]string{filepath.Base(cur)}, tail...)
|
||||
cur = parent
|
||||
}
|
||||
}
|
||||
|
||||
func isUnderDir(child, parent string) bool {
|
||||
rel, err := filepath.Rel(parent, child)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".."
|
||||
}
|
||||
|
||||
// RejectControlChars delegates to charcheck.RejectControlChars.
|
||||
// Kept as a package-level alias for backward compatibility with callers
|
||||
// that import localfileio directly.
|
||||
var RejectControlChars = charcheck.RejectControlChars
|
||||
|
||||
// IsDangerousUnicode delegates to charcheck.IsDangerousUnicode.
|
||||
var IsDangerousUnicode = charcheck.IsDangerousUnicode
|
||||
245
internal/vfs/localfileio/path_test.go
Normal file
245
internal/vfs/localfileio/path_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package localfileio
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
// ── GIVEN: normal relative paths → THEN: allowed ──
|
||||
{"normal file", "report.xlsx", false},
|
||||
{"subdir file", "output/report.xlsx", false},
|
||||
{"current dir explicit", "./file.txt", false},
|
||||
{"nested subdir", "a/b/c/file.txt", false},
|
||||
{"dot in name", "my.report.v2.xlsx", false},
|
||||
{"space in name", "my file.txt", false},
|
||||
{"unicode normal", "报告.xlsx", false},
|
||||
{"dot-dot resolves to cwd", "subdir/..", false},
|
||||
|
||||
// ── GIVEN: path traversal via .. → THEN: rejected ──
|
||||
{"dot-dot escape", "../../.ssh/authorized_keys", true},
|
||||
{"dot-dot mid path", "subdir/../../etc/passwd", true},
|
||||
{"triple dot-dot", "../../../etc/shadow", true},
|
||||
|
||||
// ── GIVEN: absolute paths → THEN: rejected ──
|
||||
{"absolute path unix", "/etc/passwd", true},
|
||||
{"absolute path root", "/tmp/evil", true},
|
||||
|
||||
// ── GIVEN: control characters in path → THEN: rejected ──
|
||||
{"null byte", "file\x00.txt", true},
|
||||
{"carriage return", "file\r.txt", true},
|
||||
{"bell char", "file\x07.txt", true},
|
||||
|
||||
// ── GIVEN: dangerous Unicode in path → THEN: rejected ──
|
||||
{"bidi RLO", "file\u202Ename.txt", true},
|
||||
{"zero width space", "file\u200Bname.txt", true},
|
||||
{"BOM char", "file\uFEFFname.txt", true},
|
||||
{"line separator", "file\u2028name.txt", true},
|
||||
{"bidi LRI", "file\u2066name.txt", true},
|
||||
|
||||
// ── GIVEN: looks dangerous but is actually safe → THEN: allowed ──
|
||||
{"literal percent 2e", "%2e%2e/etc/passwd", false},
|
||||
{"tilde path", "~/file.txt", false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// WHEN: SafeOutputPath validates the path
|
||||
_, err := SafeOutputPath(tt.input)
|
||||
|
||||
// THEN: error matches expectation
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("SafeOutputPath(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeOutputPath_ReturnsCanonicalAbsolutePath(t *testing.T) {
|
||||
// GIVEN: a clean temp directory as CWD
|
||||
dir := t.TempDir()
|
||||
dir, _ = filepath.EvalSymlinks(dir)
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(dir)
|
||||
|
||||
// WHEN: SafeOutputPath validates a relative path
|
||||
got, err := SafeOutputPath("output/file.txt")
|
||||
|
||||
// THEN: returns the canonical absolute path for subsequent I/O
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := filepath.Join(dir, "output", "file.txt")
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeOutputPath_RejectsSymlinkEscapingCWD(t *testing.T) {
|
||||
// GIVEN: a symlink in CWD pointing to /etc (outside CWD)
|
||||
dir := t.TempDir()
|
||||
dir, _ = filepath.EvalSymlinks(dir)
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(dir)
|
||||
os.Symlink("/etc", filepath.Join(dir, "link-to-etc"))
|
||||
|
||||
// WHEN: SafeOutputPath validates a path through the symlink
|
||||
_, err := SafeOutputPath("link-to-etc/passwd")
|
||||
|
||||
// THEN: rejected because the resolved path is outside CWD
|
||||
if err == nil {
|
||||
t.Error("expected error for symlink escaping CWD, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeOutputPath_AllowsSymlinkWithinCWD(t *testing.T) {
|
||||
// GIVEN: a symlink in CWD pointing to a subdirectory within CWD
|
||||
dir := t.TempDir()
|
||||
dir, _ = filepath.EvalSymlinks(dir)
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(dir)
|
||||
os.MkdirAll(filepath.Join(dir, "real"), 0755)
|
||||
os.Symlink(filepath.Join(dir, "real"), filepath.Join(dir, "link"))
|
||||
|
||||
// WHEN: SafeOutputPath validates a path through the internal symlink
|
||||
got, err := SafeOutputPath("link/file.txt")
|
||||
|
||||
// THEN: allowed, resolved to the real path within CWD
|
||||
if err != nil {
|
||||
t.Fatalf("symlink within CWD should be allowed: %v", err)
|
||||
}
|
||||
want := filepath.Join(dir, "real", "file.txt")
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeOutputPath_ResolvesAncestorSymlinkWhenParentMissing(t *testing.T) {
|
||||
// GIVEN: CWD contains a symlink "escape" → /etc, and the target path
|
||||
// goes through "escape/sub/file.txt" where "sub" does not exist.
|
||||
// The old code failed to resolve the symlink because the immediate
|
||||
// parent ("escape/sub") didn't exist, leaving resolved un-anchored.
|
||||
dir := t.TempDir()
|
||||
dir, _ = filepath.EvalSymlinks(dir)
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(dir)
|
||||
os.Symlink("/etc", filepath.Join(dir, "escape"))
|
||||
|
||||
// WHEN: SafeOutputPath validates a path through the symlink with missing intermediate dirs
|
||||
_, err := SafeOutputPath("escape/nonexistent/file.txt")
|
||||
|
||||
// THEN: rejected — the resolved path is under /etc, outside CWD
|
||||
if err == nil {
|
||||
t.Error("expected error for symlink escaping CWD via non-existent parent, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeOutputPath_DeepNonExistentPathStaysInCWD(t *testing.T) {
|
||||
// GIVEN: a deeply nested non-existent path with no symlinks
|
||||
dir := t.TempDir()
|
||||
dir, _ = filepath.EvalSymlinks(dir)
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(dir)
|
||||
|
||||
// WHEN: SafeOutputPath validates "a/b/c/d/file.txt" (none of a/b/c/d exist)
|
||||
got, err := SafeOutputPath("a/b/c/d/file.txt")
|
||||
|
||||
// THEN: allowed, resolved to canonical path under CWD
|
||||
if err != nil {
|
||||
t.Fatalf("deep non-existent path within CWD should be allowed: %v", err)
|
||||
}
|
||||
want := filepath.Join(dir, "a", "b", "c", "d", "file.txt")
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
|
||||
// GIVEN: a real temp file (absolute path under os.TempDir())
|
||||
f, err := os.CreateTemp("", "upload-test-*.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp: %v", err)
|
||||
}
|
||||
tmpPath := f.Name()
|
||||
f.Close()
|
||||
t.Cleanup(func() { os.Remove(tmpPath) })
|
||||
|
||||
// WHEN: SafeUploadPath validates the absolute temp path
|
||||
_, err = SafeInputPath(tmpPath)
|
||||
|
||||
// THEN: absolute paths are rejected even in temp dir
|
||||
if err == nil {
|
||||
t.Fatal("expected error for absolute temp path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeUploadPath_RejectsNonTempAbsolutePath(t *testing.T) {
|
||||
// GIVEN: an absolute path outside the temp directory
|
||||
// WHEN / THEN: SafeUploadPath rejects it
|
||||
_, err := SafeInputPath("/etc/passwd")
|
||||
if err == nil {
|
||||
t.Error("expected error for absolute non-temp path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeUploadPath_AcceptsRelativePath(t *testing.T) {
|
||||
// GIVEN: a clean temp CWD with a real file
|
||||
dir := t.TempDir()
|
||||
dir, _ = filepath.EvalSymlinks(dir)
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
os.Chdir(dir)
|
||||
|
||||
os.WriteFile(filepath.Join(dir, "upload.bin"), []byte("data"), 0600)
|
||||
|
||||
// WHEN: SafeUploadPath validates a relative path to an existing file
|
||||
got, err := SafeInputPath("upload.bin")
|
||||
|
||||
// THEN: accepted and returned as absolute canonical path
|
||||
if err != nil {
|
||||
t.Fatalf("SafeUploadPath(relative) error = %v", err)
|
||||
}
|
||||
want := filepath.Join(dir, "upload.bin")
|
||||
if got != want {
|
||||
t.Errorf("SafeUploadPath(relative) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_SafeInputPath_ErrorMessageContainsCorrectFlagName(t *testing.T) {
|
||||
// GIVEN: an absolute path
|
||||
|
||||
// WHEN: SafeInputPath rejects it
|
||||
_, err := SafeInputPath("/etc/passwd")
|
||||
|
||||
// THEN: error message mentions --file (not --output)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for absolute path")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--file") {
|
||||
t.Errorf("error should mention --file, got: %s", err.Error())
|
||||
}
|
||||
|
||||
// WHEN: SafeOutputPath rejects it
|
||||
_, err = SafeOutputPath("/etc/passwd")
|
||||
|
||||
// THEN: error message mentions --output (not --file)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for absolute path")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--output") {
|
||||
t.Errorf("error should mention --output, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
245
shortcuts/common/drive_media_upload.go
Normal file
245
shortcuts/common/drive_media_upload.go
Normal file
@@ -0,0 +1,245 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
const MaxDriveMediaUploadSinglePartSize int64 = 20 * 1024 * 1024 // 20MB
|
||||
|
||||
const (
|
||||
driveMediaUploadAllAction = "upload media failed"
|
||||
driveMediaUploadPartAction = "upload media part failed"
|
||||
driveMediaUploadFinishAction = "upload media finish failed"
|
||||
)
|
||||
|
||||
type DriveMediaMultipartUploadSession struct {
|
||||
UploadID string
|
||||
BlockSize int64
|
||||
BlockNum int
|
||||
}
|
||||
|
||||
type DriveMediaUploadAllConfig struct {
|
||||
FilePath string
|
||||
FileName string
|
||||
FileSize int64
|
||||
ParentType string
|
||||
ParentNode *string
|
||||
Extra string
|
||||
}
|
||||
|
||||
type DriveMediaMultipartUploadConfig struct {
|
||||
FilePath string
|
||||
FileName string
|
||||
FileSize int64
|
||||
ParentType string
|
||||
ParentNode string
|
||||
Extra string
|
||||
}
|
||||
|
||||
func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
|
||||
safeFilePath, err := validate.SafeInputPath(cfg.FilePath)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("invalid file path: %s", err)
|
||||
}
|
||||
f, err := vfs.Open(safeFilePath)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("cannot read file: %s", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", cfg.FileName)
|
||||
fd.AddField("parent_type", cfg.ParentType)
|
||||
fd.AddField("size", fmt.Sprintf("%d", cfg.FileSize))
|
||||
if cfg.ParentNode != nil {
|
||||
fd.AddField("parent_node", *cfg.ParentNode)
|
||||
}
|
||||
if cfg.Extra != "" {
|
||||
fd.AddField("extra", cfg.Extra)
|
||||
}
|
||||
fd.AddFile("file", f)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return "", WrapDriveMediaUploadRequestError(err, driveMediaUploadAllAction)
|
||||
}
|
||||
|
||||
data, err := ParseDriveMediaUploadResponse(apiResp, driveMediaUploadAllAction)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ExtractDriveMediaUploadFileToken(data, driveMediaUploadAllAction)
|
||||
}
|
||||
|
||||
func UploadDriveMediaMultipart(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig) (string, error) {
|
||||
// upload_prepare expects parent_node to be present even when the caller wants
|
||||
// the service default/root behavior, so multipart callers pass an explicit
|
||||
// string instead of relying on field omission like upload_all does.
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": cfg.FileName,
|
||||
"parent_type": cfg.ParentType,
|
||||
"parent_node": cfg.ParentNode,
|
||||
"size": cfg.FileSize,
|
||||
}
|
||||
if cfg.Extra != "" {
|
||||
prepareBody["extra"] = cfg.Extra
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/medias/upload_prepare", nil, prepareBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
session, err := ParseDriveMediaMultipartUploadSession(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, FormatSize(session.BlockSize))
|
||||
|
||||
if err = uploadDriveMediaMultipartParts(runtime, cfg.FilePath, cfg.FileSize, session); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return finishDriveMediaMultipartUpload(runtime, session.UploadID, session.BlockNum)
|
||||
}
|
||||
|
||||
func ParseDriveMediaMultipartUploadSession(data map[string]interface{}) (DriveMediaMultipartUploadSession, error) {
|
||||
// The backend chooses both chunk size and chunk count. Validate them once so
|
||||
// the streaming loop can follow the returned plan without re-checking shape.
|
||||
session := DriveMediaMultipartUploadSession{
|
||||
UploadID: GetString(data, "upload_id"),
|
||||
BlockSize: int64(GetFloat(data, "block_size")),
|
||||
BlockNum: int(GetFloat(data, "block_num")),
|
||||
}
|
||||
if session.UploadID == "" {
|
||||
return DriveMediaMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: no upload_id returned")
|
||||
}
|
||||
if session.BlockSize <= 0 {
|
||||
return DriveMediaMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned")
|
||||
}
|
||||
if session.BlockNum <= 0 {
|
||||
return DriveMediaMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_num returned")
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func WrapDriveMediaUploadRequestError(err error, action string) error {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
return output.ErrNetwork("%s: %v", action, err)
|
||||
}
|
||||
|
||||
func ParseDriveMediaUploadResponse(apiResp *larkcore.ApiResp, action string) (map[string]interface{}, error) {
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: invalid response JSON: %v", action, err)
|
||||
}
|
||||
|
||||
if larkCode := int(GetFloat(result, "code")); larkCode != 0 {
|
||||
msg, _ := result["msg"].(string)
|
||||
return nil, output.ErrAPI(larkCode, fmt.Sprintf("%s: [%d] %s", action, larkCode, msg), result["error"])
|
||||
}
|
||||
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func ExtractDriveMediaUploadFileToken(data map[string]interface{}, action string) (string, error) {
|
||||
fileToken := GetString(data, "file_token")
|
||||
if fileToken == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "%s: no file_token returned", action)
|
||||
}
|
||||
return fileToken, nil
|
||||
}
|
||||
|
||||
func uploadDriveMediaMultipartParts(runtime *RuntimeContext, filePath string, fileSize int64, session DriveMediaMultipartUploadSession) error {
|
||||
safeFilePath, err := validate.SafeInputPath(filePath)
|
||||
if err != nil {
|
||||
return output.ErrValidation("invalid file path: %s", err)
|
||||
}
|
||||
f, err := vfs.Open(safeFilePath)
|
||||
if err != nil {
|
||||
return output.ErrValidation("cannot read file: %s", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
maxInt := int64(^uint(0) >> 1)
|
||||
bufferSize := session.BlockSize
|
||||
if bufferSize <= 0 || bufferSize > maxInt {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned")
|
||||
}
|
||||
buffer := make([]byte, int(bufferSize))
|
||||
remaining := fileSize
|
||||
// Follow the server-declared block plan exactly; upload_finish expects the
|
||||
// same block count returned by upload_prepare.
|
||||
for seq := 0; seq < session.BlockNum; seq++ {
|
||||
chunkSize := session.BlockSize
|
||||
if remaining > 0 && chunkSize > remaining {
|
||||
chunkSize = remaining
|
||||
}
|
||||
|
||||
n, readErr := io.ReadFull(f, buffer[:int(chunkSize)])
|
||||
if readErr != nil {
|
||||
return output.ErrValidation("cannot read file: %s", readErr)
|
||||
}
|
||||
|
||||
if err = uploadDriveMediaMultipartPart(runtime, session.UploadID, seq, buffer[:n]); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, session.BlockNum, FormatSize(int64(n)))
|
||||
remaining -= int64(n)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func uploadDriveMediaMultipartPart(runtime *RuntimeContext, uploadID string, seq int, chunk []byte) error {
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("upload_id", uploadID)
|
||||
fd.AddField("seq", fmt.Sprintf("%d", seq))
|
||||
fd.AddField("size", fmt.Sprintf("%d", len(chunk)))
|
||||
fd.AddFile("file", bytes.NewReader(chunk))
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return WrapDriveMediaUploadRequestError(err, driveMediaUploadPartAction)
|
||||
}
|
||||
|
||||
_, err = ParseDriveMediaUploadResponse(apiResp, driveMediaUploadPartAction)
|
||||
return err
|
||||
}
|
||||
|
||||
func finishDriveMediaMultipartUpload(runtime *RuntimeContext, uploadID string, blockNum int) (string, error) {
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/medias/upload_finish", nil, map[string]interface{}{
|
||||
"upload_id": uploadID,
|
||||
"block_num": blockNum,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ExtractDriveMediaUploadFileToken(data, driveMediaUploadFinishAction)
|
||||
}
|
||||
528
shortcuts/common/drive_media_upload_test.go
Normal file
528
shortcuts/common/drive_media_upload_test.go
Normal file
@@ -0,0 +1,528 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
var commonDriveMediaUploadTestSeq atomic.Int64
|
||||
|
||||
func TestUploadDriveMediaAllBuildsMultipartBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
parentNode *string
|
||||
wantParentNode string
|
||||
wantParentSet bool
|
||||
}{
|
||||
{
|
||||
name: "includes parent_node when provided",
|
||||
parentNode: strPtr("blk_parent"),
|
||||
wantParentNode: "blk_parent",
|
||||
wantParentSet: true,
|
||||
},
|
||||
{
|
||||
name: "omits parent_node when not provided",
|
||||
parentNode: nil,
|
||||
wantParentSet: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
runtime, reg := newDriveMediaUploadTestRuntime(t)
|
||||
withDriveMediaUploadWorkingDir(t, t.TempDir())
|
||||
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": "file_all_123"},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
|
||||
filePath := writeDriveMediaUploadTestFile(t, "small.bin", 3)
|
||||
fileToken, err := UploadDriveMediaAll(runtime, DriveMediaUploadAllConfig{
|
||||
FilePath: filePath,
|
||||
FileName: "small.bin",
|
||||
FileSize: 3,
|
||||
ParentType: "docx_file",
|
||||
ParentNode: tt.parentNode,
|
||||
Extra: `{"drive_route_token":"doxcn123"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadDriveMediaAll() error: %v", err)
|
||||
}
|
||||
if fileToken != "file_all_123" {
|
||||
t.Fatalf("fileToken = %q, want %q", fileToken, "file_all_123")
|
||||
}
|
||||
|
||||
body := decodeCapturedDriveMediaMultipartBody(t, uploadStub)
|
||||
if got := body.Fields["file_name"]; got != "small.bin" {
|
||||
t.Fatalf("file_name = %q, want %q", got, "small.bin")
|
||||
}
|
||||
if got := body.Fields["parent_type"]; got != "docx_file" {
|
||||
t.Fatalf("parent_type = %q, want %q", got, "docx_file")
|
||||
}
|
||||
if got := body.Fields["size"]; got != "3" {
|
||||
t.Fatalf("size = %q, want %q", got, "3")
|
||||
}
|
||||
if got := body.Fields["extra"]; got != `{"drive_route_token":"doxcn123"}` {
|
||||
t.Fatalf("extra = %q, want drive route token payload", got)
|
||||
}
|
||||
if got := len(body.Files["file"]); got != 3 {
|
||||
t.Fatalf("file size = %d, want %d", got, 3)
|
||||
}
|
||||
|
||||
gotParentNode, hasParentNode := body.Fields["parent_node"]
|
||||
if hasParentNode != tt.wantParentSet {
|
||||
t.Fatalf("parent_node present = %v, want %v", hasParentNode, tt.wantParentSet)
|
||||
}
|
||||
if hasParentNode && gotParentNode != tt.wantParentNode {
|
||||
t.Fatalf("parent_node = %q, want %q", gotParentNode, tt.wantParentNode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadDriveMediaMultipartBuildsPreparePartsAndFinish(t *testing.T) {
|
||||
runtime, reg := newDriveMediaUploadTestRuntime(t)
|
||||
withDriveMediaUploadWorkingDir(t, t.TempDir())
|
||||
|
||||
prepareStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": float64(4 * 1024 * 1024),
|
||||
"block_num": float64(6),
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(prepareStub)
|
||||
|
||||
partStubs := make([]*httpmock.Stub, 0, 6)
|
||||
for i := 0; i < 6; i++ {
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
}
|
||||
partStubs = append(partStubs, stub)
|
||||
reg.Register(stub)
|
||||
}
|
||||
|
||||
finishStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"file_token": "file_multi_123"},
|
||||
},
|
||||
}
|
||||
reg.Register(finishStub)
|
||||
|
||||
filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1)
|
||||
fileToken, err := UploadDriveMediaMultipart(runtime, DriveMediaMultipartUploadConfig{
|
||||
FilePath: filePath,
|
||||
FileName: "large.bin",
|
||||
FileSize: MaxDriveMediaUploadSinglePartSize + 1,
|
||||
ParentType: "ccm_import_open",
|
||||
ParentNode: "",
|
||||
Extra: `{"obj_type":"sheet","file_extension":"xlsx"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UploadDriveMediaMultipart() error: %v", err)
|
||||
}
|
||||
if fileToken != "file_multi_123" {
|
||||
t.Fatalf("fileToken = %q, want %q", fileToken, "file_multi_123")
|
||||
}
|
||||
|
||||
prepareBody := decodeCapturedDriveMediaJSONBody(t, prepareStub)
|
||||
if got, _ := prepareBody["parent_type"].(string); got != "ccm_import_open" {
|
||||
t.Fatalf("prepare parent_type = %q, want %q", got, "ccm_import_open")
|
||||
}
|
||||
rawParentNode, ok := prepareBody["parent_node"]
|
||||
if !ok {
|
||||
t.Fatal("prepare body missing parent_node")
|
||||
}
|
||||
if got, ok := rawParentNode.(string); !ok || got != "" {
|
||||
t.Fatalf("prepare parent_node = %#v, want empty string", rawParentNode)
|
||||
}
|
||||
if got, _ := prepareBody["extra"].(string); got != `{"obj_type":"sheet","file_extension":"xlsx"}` {
|
||||
t.Fatalf("prepare extra = %q, want import payload", got)
|
||||
}
|
||||
if got, _ := prepareBody["size"].(float64); got != float64(MaxDriveMediaUploadSinglePartSize+1) {
|
||||
t.Fatalf("prepare size = %v, want %d", got, MaxDriveMediaUploadSinglePartSize+1)
|
||||
}
|
||||
|
||||
firstPart := decodeCapturedDriveMediaMultipartBody(t, partStubs[0])
|
||||
if got := firstPart.Fields["upload_id"]; got != "upload_123" {
|
||||
t.Fatalf("first part upload_id = %q, want %q", got, "upload_123")
|
||||
}
|
||||
if got := firstPart.Fields["seq"]; got != "0" {
|
||||
t.Fatalf("first part seq = %q, want %q", got, "0")
|
||||
}
|
||||
if got := firstPart.Fields["size"]; got != "4194304" {
|
||||
t.Fatalf("first part size = %q, want %q", got, "4194304")
|
||||
}
|
||||
|
||||
lastPart := decodeCapturedDriveMediaMultipartBody(t, partStubs[len(partStubs)-1])
|
||||
if got := lastPart.Fields["seq"]; got != "5" {
|
||||
t.Fatalf("last part seq = %q, want %q", got, "5")
|
||||
}
|
||||
if got := lastPart.Fields["size"]; got != "1" {
|
||||
t.Fatalf("last part size = %q, want %q", got, "1")
|
||||
}
|
||||
if got := len(lastPart.Files["file"]); got != 1 {
|
||||
t.Fatalf("last part file size = %d, want %d", got, 1)
|
||||
}
|
||||
|
||||
finishBody := decodeCapturedDriveMediaJSONBody(t, finishStub)
|
||||
if got, _ := finishBody["upload_id"].(string); got != "upload_123" {
|
||||
t.Fatalf("finish upload_id = %q, want %q", got, "upload_123")
|
||||
}
|
||||
if got, _ := finishBody["block_num"].(float64); got != 6 {
|
||||
t.Fatalf("finish block_num = %v, want %d", got, 6)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDriveMediaMultipartUploadSessionValidatesResponseFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data map[string]interface{}
|
||||
wantText string
|
||||
}{
|
||||
{
|
||||
name: "missing upload id",
|
||||
data: map[string]interface{}{
|
||||
"block_size": 4 * 1024 * 1024,
|
||||
"block_num": 6,
|
||||
},
|
||||
wantText: "upload prepare failed: no upload_id returned",
|
||||
},
|
||||
{
|
||||
name: "missing block size",
|
||||
data: map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_num": 6,
|
||||
},
|
||||
wantText: "upload prepare failed: invalid block_size returned",
|
||||
},
|
||||
{
|
||||
name: "missing block num",
|
||||
data: map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": 4 * 1024 * 1024,
|
||||
},
|
||||
wantText: "upload prepare failed: invalid block_num returned",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := ParseDriveMediaMultipartUploadSession(tt.data)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
|
||||
t.Fatalf("err = %v, want substring %q", err, tt.wantText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadDriveMediaMultipartPartAPIFailure(t *testing.T) {
|
||||
runtime, reg := newDriveMediaUploadTestRuntime(t)
|
||||
withDriveMediaUploadWorkingDir(t, t.TempDir())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": float64(4 * 1024 * 1024),
|
||||
"block_num": float64(6),
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: map[string]interface{}{
|
||||
"code": 999,
|
||||
"msg": "chunk rejected",
|
||||
},
|
||||
})
|
||||
|
||||
filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1)
|
||||
_, err := UploadDriveMediaMultipart(runtime, DriveMediaMultipartUploadConfig{
|
||||
FilePath: filePath,
|
||||
FileName: "large.bin",
|
||||
FileSize: MaxDriveMediaUploadSinglePartSize + 1,
|
||||
ParentType: "ccm_import_open",
|
||||
ParentNode: "",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "upload media part failed: [999] chunk rejected") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadDriveMediaMultipartFinishRequiresFileToken(t *testing.T) {
|
||||
runtime, reg := newDriveMediaUploadTestRuntime(t)
|
||||
withDriveMediaUploadWorkingDir(t, t.TempDir())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": float64(4 * 1024 * 1024),
|
||||
"block_num": float64(6),
|
||||
},
|
||||
},
|
||||
})
|
||||
for i := 0; i < 6; i++ {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1)
|
||||
_, err := UploadDriveMediaMultipart(runtime, DriveMediaMultipartUploadConfig{
|
||||
FilePath: filePath,
|
||||
FileName: "large.bin",
|
||||
FileSize: MaxDriveMediaUploadSinglePartSize + 1,
|
||||
ParentType: "ccm_import_open",
|
||||
ParentNode: "",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "upload media finish failed: no file_token returned") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDriveMediaUploadResponseErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("invalid json", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := ParseDriveMediaUploadResponse(&larkcore.ApiResp{RawBody: []byte("{")}, "upload media failed")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid response JSON") {
|
||||
t.Fatalf("expected invalid JSON error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("api code error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := ParseDriveMediaUploadResponse(&larkcore.ApiResp{RawBody: []byte(`{"code":999,"msg":"boom","error":{"detail":"x"}}`)}, "upload media failed")
|
||||
if err == nil || !strings.Contains(err.Error(), "upload media failed: [999] boom") {
|
||||
t.Fatalf("expected API error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExtractDriveMediaUploadFileTokenRequiresToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := ExtractDriveMediaUploadFileToken(map[string]interface{}{}, "upload media failed")
|
||||
if err == nil || !strings.Contains(err.Error(), "upload media failed: no file_token returned") {
|
||||
t.Fatalf("err = %v, want missing file_token error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapDriveMediaUploadRequestError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("preserves exit error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
original := output.ErrValidation("bad input")
|
||||
got := WrapDriveMediaUploadRequestError(original, "upload media failed")
|
||||
if got != original {
|
||||
t.Fatalf("expected same exit error pointer, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wraps generic error as network", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := WrapDriveMediaUploadRequestError(io.EOF, "upload media failed")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", got)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitNetwork)
|
||||
}
|
||||
if !strings.Contains(got.Error(), "upload media failed") {
|
||||
t.Fatalf("unexpected error: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type capturedDriveMediaMultipartBody struct {
|
||||
Fields map[string]string
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
func newDriveMediaUploadTestRuntime(t *testing.T) (*RuntimeContext, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
cfg := &core.CliConfig{
|
||||
AppID: fmt.Sprintf("common-drive-media-test-%d", commonDriveMediaUploadTestSeq.Add(1)), AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
runtime := &RuntimeContext{
|
||||
ctx: context.Background(),
|
||||
Config: cfg,
|
||||
Factory: f,
|
||||
resolvedAs: core.AsBot,
|
||||
}
|
||||
return runtime, reg
|
||||
}
|
||||
|
||||
func withDriveMediaUploadWorkingDir(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd() error: %v", err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("Chdir(%q) error: %v", dir, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := os.Chdir(cwd); err != nil {
|
||||
t.Fatalf("restore cwd error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func writeDriveMediaUploadTestFile(t *testing.T, name string, size int) string {
|
||||
t.Helper()
|
||||
|
||||
if err := os.WriteFile(name, bytes.Repeat([]byte("a"), size), 0644); err != nil {
|
||||
t.Fatalf("WriteFile(%q) error: %v", name, err)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func writeDriveMediaUploadSizedFile(t *testing.T, name string, size int64) string {
|
||||
t.Helper()
|
||||
|
||||
fh, err := os.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Create(%q) error: %v", name, err)
|
||||
}
|
||||
if err := fh.Truncate(size); err != nil {
|
||||
t.Fatalf("Truncate(%q) error: %v", name, err)
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
t.Fatalf("Close(%q) error: %v", name, err)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func decodeCapturedDriveMediaJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode captured JSON body: %v", err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func decodeCapturedDriveMediaMultipartBody(t *testing.T, stub *httpmock.Stub) capturedDriveMediaMultipartBody {
|
||||
t.Helper()
|
||||
|
||||
contentType := stub.CapturedHeaders.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
t.Fatalf("parse multipart content type: %v", err)
|
||||
}
|
||||
if mediaType != "multipart/form-data" {
|
||||
t.Fatalf("content type = %q, want multipart/form-data", mediaType)
|
||||
}
|
||||
|
||||
reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
body := capturedDriveMediaMultipartBody{
|
||||
Fields: map[string]string{},
|
||||
Files: map[string][]byte{},
|
||||
}
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read multipart part: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(part)
|
||||
if err != nil {
|
||||
t.Fatalf("read multipart data: %v", err)
|
||||
}
|
||||
if part.FileName() != "" {
|
||||
body.Files[part.FormName()] = data
|
||||
continue
|
||||
}
|
||||
body.Fields[part.FormName()] = string(data)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
@@ -10,37 +10,39 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// RuntimeContext provides helpers for shortcut execution.
|
||||
type RuntimeContext struct {
|
||||
ctx context.Context // from cmd.Context(), propagated through the call chain
|
||||
Config *core.CliConfig
|
||||
Cmd *cobra.Command
|
||||
Format string
|
||||
JqExpr string // --jq expression; empty = no filter
|
||||
outputErr error // deferred error from Out()/OutFormat() jq filtering
|
||||
botOnly bool // set by framework for bot-only shortcuts
|
||||
resolvedAs core.Identity // effective identity resolved by framework
|
||||
Factory *cmdutil.Factory // injected by framework
|
||||
apiClient *client.APIClient // lazily initialized, cached
|
||||
larkSDK *lark.Client // eagerly initialized in mountDeclarative
|
||||
ctx context.Context // from cmd.Context(), propagated through the call chain
|
||||
Config *core.CliConfig
|
||||
Cmd *cobra.Command
|
||||
Format string
|
||||
JqExpr string // --jq expression; empty = no filter
|
||||
outputErrOnce sync.Once // guards first-error capture in Out()/OutFormat()
|
||||
outputErr error // deferred error from jq filtering; written at most once
|
||||
botOnly bool // set by framework for bot-only shortcuts
|
||||
resolvedAs core.Identity // effective identity resolved by framework
|
||||
Factory *cmdutil.Factory // injected by framework
|
||||
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
|
||||
larkSDK *lark.Client // eagerly initialized in mountDeclarative
|
||||
}
|
||||
|
||||
// ── Identity ──
|
||||
@@ -73,18 +75,13 @@ func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId }
|
||||
func (ctx *RuntimeContext) Ctx() context.Context { return ctx.ctx }
|
||||
|
||||
// getAPIClient returns the cached APIClient, creating it on first use.
|
||||
// Thread-safe via sync.OnceValues (initialized in newRuntimeContext).
|
||||
// Falls back to direct construction for test contexts that bypass newRuntimeContext.
|
||||
func (ctx *RuntimeContext) getAPIClient() (*client.APIClient, error) {
|
||||
if ctx.apiClient != nil {
|
||||
return ctx.apiClient, nil
|
||||
if ctx.apiClientFunc != nil {
|
||||
return ctx.apiClientFunc()
|
||||
}
|
||||
ac, err := ctx.Factory.NewAPIClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Override config with the one resolved for this context (may differ from Factory's)
|
||||
ac.Config = ctx.Config
|
||||
ctx.apiClient = ac
|
||||
return ac, nil
|
||||
return ctx.Factory.NewAPIClientWithConfig(ctx.Config)
|
||||
}
|
||||
|
||||
// AccessToken returns a valid access token for the current identity.
|
||||
@@ -296,6 +293,113 @@ func (ctx *RuntimeContext) IO() *cmdutil.IOStreams {
|
||||
return ctx.Factory.IOStreams
|
||||
}
|
||||
|
||||
// FileIO resolves the FileIO using the current execution context.
|
||||
// Falls back to the globally registered provider when Factory or its
|
||||
// FileIOProvider is nil (e.g. in lightweight test helpers).
|
||||
func (ctx *RuntimeContext) FileIO() fileio.FileIO {
|
||||
if ctx != nil && ctx.Factory != nil {
|
||||
if fio := ctx.Factory.ResolveFileIO(ctx.ctx); fio != nil {
|
||||
return fio
|
||||
}
|
||||
}
|
||||
if p := fileio.GetProvider(); p != nil {
|
||||
c := context.Background()
|
||||
if ctx != nil {
|
||||
c = ctx.ctx
|
||||
}
|
||||
return p.ResolveFileIO(c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveSavePath resolves a relative path to a validated absolute path via
|
||||
// FileIO.ResolvePath. It returns an error if no FileIO provider is registered
|
||||
// or if the path fails validation (e.g. traversal, symlink escape).
|
||||
func (ctx *RuntimeContext) ResolveSavePath(path string) (string, error) {
|
||||
fio := ctx.FileIO()
|
||||
if fio == nil {
|
||||
return "", fmt.Errorf("no file I/O provider registered")
|
||||
}
|
||||
resolved, err := fio.ResolvePath(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve save path: %w", err)
|
||||
}
|
||||
if resolved == "" {
|
||||
return "", fmt.Errorf("resolve save path: empty result for %q", path)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// WrapSaveError matches a FileIO.Save error against known categories and wraps
|
||||
// it with the caller-provided message prefix, preserving backward-compatible
|
||||
// error text per shortcut.
|
||||
func WrapSaveError(err error, pathMsg, mkdirMsg, writeMsg string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var me *fileio.MkdirError
|
||||
var we *fileio.WriteError
|
||||
switch {
|
||||
case errors.Is(err, fileio.ErrPathValidation):
|
||||
return fmt.Errorf("%s: %w", pathMsg, err)
|
||||
case errors.As(err, &me):
|
||||
return fmt.Errorf("%s: %w", mkdirMsg, err)
|
||||
case errors.As(err, &we):
|
||||
return fmt.Errorf("%s: %w", writeMsg, err)
|
||||
default:
|
||||
return fmt.Errorf("%s: %w", writeMsg, err)
|
||||
}
|
||||
}
|
||||
|
||||
// WrapOpenError matches a FileIO.Open/Stat error and wraps it with the
|
||||
// caller-provided message prefix.
|
||||
func WrapOpenError(err error, pathMsg, readMsg string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return fmt.Errorf("%s: %w", pathMsg, err)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", readMsg, err)
|
||||
}
|
||||
|
||||
// WrapSaveErrorByCategory maps a FileIO.Save error to structured output errors,
|
||||
// using standardized messages and the given error category (e.g. "api_error", "io").
|
||||
// Path validation errors always use ErrValidation (exit code 2).
|
||||
func WrapSaveErrorByCategory(err error, category string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var me *fileio.MkdirError
|
||||
switch {
|
||||
case errors.Is(err, fileio.ErrPathValidation):
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
case errors.As(err, &me):
|
||||
return output.Errorf(output.ExitInternal, category, "cannot create parent directory: %s", err)
|
||||
default:
|
||||
return output.Errorf(output.ExitInternal, category, "cannot create file: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ValidatePath checks that path is a valid relative input path within the
|
||||
// working directory by delegating to FileIO.Stat. Returns nil if the path is
|
||||
// valid or does not exist yet; returns an error only for illegal paths
|
||||
// (absolute, traversal, symlink escape, control chars).
|
||||
//
|
||||
// NOTE: This validates input (read) paths via SafeInputPath semantics inside
|
||||
// the FileIO implementation. For output (write) path validation, use
|
||||
// ResolveSavePath instead.
|
||||
func (ctx *RuntimeContext) ValidatePath(path string) error {
|
||||
fio := ctx.FileIO()
|
||||
if fio == nil {
|
||||
return fmt.Errorf("no file I/O provider registered")
|
||||
}
|
||||
if _, err := fio.Stat(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Output helpers ──
|
||||
|
||||
// Out prints a success JSON envelope to stdout.
|
||||
@@ -304,9 +408,7 @@ func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
|
||||
if ctx.JqExpr != "" {
|
||||
if err := output.JqFilter(ctx.IO().Out, env, ctx.JqExpr); err != nil {
|
||||
fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err)
|
||||
if ctx.outputErr == nil {
|
||||
ctx.outputErr = err
|
||||
}
|
||||
ctx.outputErrOnce.Do(func() { ctx.outputErr = err })
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -412,6 +514,7 @@ func (s Shortcut) mountDeclarative(parent *cobra.Command, f *cmdutil.Factory) {
|
||||
cmd := &cobra.Command{
|
||||
Use: shortcut.Command,
|
||||
Short: shortcut.Description,
|
||||
Args: rejectPositionalArgs(),
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return runShortcut(cmd, f, &shortcut, botOnly)
|
||||
},
|
||||
@@ -513,6 +616,9 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
|
||||
ctx := cmd.Context()
|
||||
ctx = cmdutil.ContextWithShortcut(ctx, s.Service+":"+s.Command, uuid.New().String())
|
||||
rctx := &RuntimeContext{ctx: ctx, Config: config, Cmd: cmd, botOnly: botOnly, resolvedAs: as, Factory: f}
|
||||
rctx.apiClientFunc = sync.OnceValues(func() (*client.APIClient, error) {
|
||||
return f.NewAPIClientWithConfig(config)
|
||||
})
|
||||
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
@@ -575,11 +681,15 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
if path == "" {
|
||||
return FlagErrorf("--%s: file path cannot be empty after @", fl.Name)
|
||||
}
|
||||
safePath, err := validate.SafeInputPath(path)
|
||||
f, err := rctx.FileIO().Open(path)
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: invalid file path %q: %v", fl.Name, path, err)
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return FlagErrorf("--%s: invalid file path %q: %v", fl.Name, path, err)
|
||||
}
|
||||
return FlagErrorf("--%s: cannot read file %q: %v", fl.Name, path, err)
|
||||
}
|
||||
data, err := vfs.ReadFile(safePath)
|
||||
data, err := io.ReadAll(f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return FlagErrorf("--%s: cannot read file %q: %v", fl.Name, path, err)
|
||||
}
|
||||
@@ -627,6 +737,19 @@ func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut)
|
||||
return nil
|
||||
}
|
||||
|
||||
// rejectPositionalArgs returns a cobra.PositionalArgs that rejects any
|
||||
// positional arguments. The error is intentionally a plain error (not
|
||||
// ExitError) so that cobra prints usage and the root handler prints a
|
||||
// simple "Error:" line instead of a JSON envelope.
|
||||
func rejectPositionalArgs() cobra.PositionalArgs {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("positional arguments are not supported (got %q); pass values via flags", args)
|
||||
}
|
||||
}
|
||||
|
||||
func registerShortcutFlags(cmd *cobra.Command, s *Shortcut) {
|
||||
for _, fl := range s.Flags {
|
||||
desc := fl.Desc
|
||||
|
||||
58
shortcuts/common/runner_args_test.go
Normal file
58
shortcuts/common/runner_args_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestRejectPositionalArgs_WithArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := rejectPositionalArgs()
|
||||
|
||||
err := validator(&cobra.Command{}, []string{"hello"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for positional arg, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "positional arguments are not supported") {
|
||||
t.Errorf("expected positional args rejection message, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), `"hello"`) {
|
||||
t.Errorf("expected the positional arg value in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectPositionalArgs_MultipleArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := rejectPositionalArgs()
|
||||
|
||||
err := validator(&cobra.Command{}, []string{"hello", "world"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for multiple positional args, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "positional arguments are not supported") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "hello") || !strings.Contains(err.Error(), "world") {
|
||||
t.Errorf("expected all positional args in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectPositionalArgs_NoArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validator := rejectPositionalArgs()
|
||||
|
||||
if err := validator(&cobra.Command{}, nil); err != nil {
|
||||
t.Fatalf("expected no error for nil args, got: %v", err)
|
||||
}
|
||||
if err := validator(&cobra.Command{}, []string{}); err != nil {
|
||||
t.Fatalf("expected no error for empty args, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -102,6 +103,48 @@ func TestRuntimeContext_Out_WithJq_InvalidExpr_WritesStderr(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type testResolvedFileIO struct{}
|
||||
|
||||
func (testResolvedFileIO) Open(string) (fileio.File, error) { return nil, nil }
|
||||
func (testResolvedFileIO) Stat(string) (fileio.FileInfo, error) { return nil, nil }
|
||||
func (testResolvedFileIO) ResolvePath(path string) (string, error) { return path, nil }
|
||||
func (testResolvedFileIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type capturingFileIOProvider struct {
|
||||
gotCtx context.Context
|
||||
fileIO fileio.FileIO
|
||||
}
|
||||
|
||||
func (p *capturingFileIOProvider) Name() string { return "capture" }
|
||||
|
||||
func (p *capturingFileIOProvider) ResolveFileIO(ctx context.Context) fileio.FileIO {
|
||||
p.gotCtx = ctx
|
||||
return p.fileIO
|
||||
}
|
||||
|
||||
func TestRuntimeContext_FileIO_UsesExecutionContext(t *testing.T) {
|
||||
execCtx := context.WithValue(context.Background(), "key", "value")
|
||||
resolved := testResolvedFileIO{}
|
||||
provider := &capturingFileIOProvider{fileIO: resolved}
|
||||
|
||||
rctx := &RuntimeContext{
|
||||
ctx: execCtx,
|
||||
Factory: &cmdutil.Factory{
|
||||
FileIOProvider: provider,
|
||||
},
|
||||
}
|
||||
|
||||
got := rctx.FileIO()
|
||||
if got != resolved {
|
||||
t.Fatalf("FileIO() returned %T, want %T", got, resolved)
|
||||
}
|
||||
if provider.gotCtx != execCtx {
|
||||
t.Fatal("ResolveFileIO() did not receive the runtime execution context")
|
||||
}
|
||||
}
|
||||
|
||||
func newTestShortcutCmd(s *Shortcut) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "test-shortcut"}
|
||||
cmd.SetContext(context.Background())
|
||||
@@ -119,7 +162,8 @@ func newTestFactory() *cmdutil.Factory {
|
||||
LarkClient: func() (*lark.Client, error) {
|
||||
return lark.NewClient("test", "test"), nil
|
||||
},
|
||||
IOStreams: &cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}},
|
||||
IOStreams: &cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}},
|
||||
FileIOProvider: fileio.GetProvider(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,23 +5,15 @@ package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const maxFileSize = 20 * 1024 * 1024 // 20MB
|
||||
|
||||
var alignMap = map[string]int{
|
||||
"left": 1,
|
||||
"center": 2,
|
||||
@@ -36,7 +28,7 @@ var DocMediaInsert = common.Shortcut{
|
||||
Scopes: []string{"docs:document.media:upload", "docx:document:write_only", "docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Desc: "local file path (max 20MB)", Required: true},
|
||||
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
|
||||
{Name: "doc", Desc: "document URL or document_id", Required: true},
|
||||
{Name: "type", Default: "image", Desc: "type: image | file"},
|
||||
{Name: "align", Desc: "alignment: left | center | right"},
|
||||
@@ -86,16 +78,9 @@ var DocMediaInsert = common.Shortcut{
|
||||
Desc(fmt.Sprintf("[%d] Get document root block", stepBase)).
|
||||
POST("/open-apis/docx/v1/documents/:document_id/blocks/:document_id/children").
|
||||
Desc(fmt.Sprintf("[%d] Create empty block at document end", stepBase+1)).
|
||||
Body(createBlockData).
|
||||
POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc(fmt.Sprintf("[%d] Upload local file (multipart/form-data)", stepBase+2)).
|
||||
Body(map[string]interface{}{
|
||||
"file_name": filepath.Base(filePath),
|
||||
"parent_type": parentType,
|
||||
"parent_node": "<new_block_id>",
|
||||
"file": "@" + filePath,
|
||||
}).
|
||||
PATCH("/open-apis/docx/v1/documents/:document_id/blocks/batch_update").
|
||||
Body(createBlockData)
|
||||
appendDocMediaInsertUploadDryRun(d, filePath, parentType, stepBase+2)
|
||||
d.PATCH("/open-apis/docx/v1/documents/:document_id/blocks/batch_update").
|
||||
Desc(fmt.Sprintf("[%d] Bind uploaded file token to the new block", stepBase+3)).
|
||||
Body(batchUpdateData)
|
||||
|
||||
@@ -112,7 +97,6 @@ var DocMediaInsert = common.Shortcut{
|
||||
if pathErr != nil {
|
||||
return output.ErrValidation("unsafe file path: %s", pathErr)
|
||||
}
|
||||
filePath = safeFilePath
|
||||
|
||||
documentID, err := resolveDocxDocumentID(runtime, docInput)
|
||||
if err != nil {
|
||||
@@ -120,16 +104,19 @@ var DocMediaInsert = common.Shortcut{
|
||||
}
|
||||
|
||||
// Validate file
|
||||
stat, err := vfs.Stat(filePath)
|
||||
stat, err := vfs.Stat(safeFilePath)
|
||||
if err != nil {
|
||||
return output.ErrValidation("file not found: %s", filePath)
|
||||
}
|
||||
if stat.Size() > maxFileSize {
|
||||
return output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024)
|
||||
if !stat.Mode().IsRegular() {
|
||||
return output.ErrValidation("file must be a regular file: %s", filePath)
|
||||
}
|
||||
|
||||
fileName := filepath.Base(filePath)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Inserting: %s -> document %s\n", fileName, common.MaskToken(documentID))
|
||||
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
||||
}
|
||||
|
||||
// Step 1: Get document root block to find where to insert
|
||||
rootData, err := runtime.CallAPI("GET",
|
||||
@@ -166,7 +153,8 @@ var DocMediaInsert = common.Shortcut{
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved file block targets: upload=%s replace=%s\n", uploadParentNode, replaceBlockID)
|
||||
}
|
||||
|
||||
// Rollback helper
|
||||
// The placeholder block is created before any upload starts, so failures in
|
||||
// later steps should try to remove it instead of leaving an empty artifact.
|
||||
rollback := func() error {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Rolling back: deleting block %s\n", blockId)
|
||||
_, err := runtime.CallAPI("DELETE",
|
||||
@@ -185,7 +173,7 @@ var DocMediaInsert = common.Shortcut{
|
||||
}
|
||||
|
||||
// Step 3: Upload media file
|
||||
fileToken, err := uploadMediaFile(ctx, runtime, filePath, fileName, mediaType, uploadParentNode, documentID)
|
||||
fileToken, err := uploadDocMediaFile(runtime, filePath, fileName, stat.Size(), parentTypeForMediaType(mediaType), uploadParentNode, documentID)
|
||||
if err != nil {
|
||||
return withRollbackWarning(err)
|
||||
}
|
||||
@@ -346,6 +334,8 @@ func extractCreatedBlockTargets(createData map[string]interface{}, mediaType str
|
||||
return blockID, uploadParentNode, replaceBlockID
|
||||
}
|
||||
|
||||
// File blocks are wrapped: the created top-level block owns a nested child
|
||||
// that is both the upload target and the replace_file target.
|
||||
nestedChildren, _ := child["children"].([]interface{})
|
||||
if len(nestedChildren) == 0 {
|
||||
return blockID, uploadParentNode, replaceBlockID
|
||||
@@ -357,66 +347,44 @@ func extractCreatedBlockTargets(createData map[string]interface{}, mediaType str
|
||||
return blockID, uploadParentNode, replaceBlockID
|
||||
}
|
||||
|
||||
// uploadMediaFile uploads a file to Feishu drive as media.
|
||||
func uploadMediaFile(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, mediaType, parentNode, docId string) (string, error) {
|
||||
f, err := vfs.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
return "", output.Errorf(output.ExitInternal, "internal_error", "failed to stat file: %v", err)
|
||||
}
|
||||
fileSize := stat.Size()
|
||||
|
||||
parentType := parentTypeForMediaType(mediaType)
|
||||
|
||||
// Build SDK Formdata
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddField("parent_type", parentType)
|
||||
fd.AddField("parent_node", parentNode)
|
||||
fd.AddField("size", fmt.Sprintf("%d", fileSize))
|
||||
if docId != "" {
|
||||
extra, err := buildDriveRouteExtra(docId)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fd.AddField("extra", extra)
|
||||
}
|
||||
fd.AddFile("file", f)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return "", err
|
||||
}
|
||||
return "", output.ErrNetwork("file upload failed: %v", err)
|
||||
func appendDocMediaInsertUploadDryRun(d *common.DryRunAPI, filePath, parentType string, step int) {
|
||||
// The upload step runs only after the empty placeholder block is created, so
|
||||
// dry-run can refer to that future block ID only symbolically. For large
|
||||
// files, keep multipart internals as substeps of the single user-facing
|
||||
// "upload file" step.
|
||||
if docMediaShouldUseMultipart(filePath) {
|
||||
d.POST("/open-apis/drive/v1/medias/upload_prepare").
|
||||
Desc(fmt.Sprintf("[%da] Initialize multipart upload", step)).
|
||||
Body(map[string]interface{}{
|
||||
"file_name": filepath.Base(filePath),
|
||||
"parent_type": parentType,
|
||||
"parent_node": "<new_block_id>",
|
||||
"size": "<file_size>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_part").
|
||||
Desc(fmt.Sprintf("[%db] Upload file parts (repeated)", step)).
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
"size": "<chunk_size>",
|
||||
"file": "<chunk_binary>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_finish").
|
||||
Desc(fmt.Sprintf("[%dc] Finalize multipart upload and get file_token", step)).
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "file upload failed: invalid response JSON: %v", err)
|
||||
}
|
||||
|
||||
code, _ := util.ToFloat64(result["code"])
|
||||
if code != 0 {
|
||||
msg, _ := result["msg"].(string)
|
||||
return "", output.ErrAPI(int(code), fmt.Sprintf("file upload failed: [%d] %s", int(code), msg), result["error"])
|
||||
}
|
||||
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
fileToken, _ := data["file_token"].(string)
|
||||
if fileToken == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "file upload failed: no file_token returned")
|
||||
}
|
||||
|
||||
return fileToken, nil
|
||||
d.POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Desc(fmt.Sprintf("[%d] Upload local file (multipart/form-data)", step)).
|
||||
Body(map[string]interface{}{
|
||||
"file_name": filepath.Base(filePath),
|
||||
"parent_type": parentType,
|
||||
"parent_node": "<new_block_id>",
|
||||
"size": "<file_size>",
|
||||
"file": "@" + filePath,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -19,10 +21,6 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func docsTestConfig() *core.CliConfig {
|
||||
return docsTestConfigWithAppID("docs-test-app")
|
||||
}
|
||||
|
||||
func docsTestConfigWithAppID(appID string) *core.CliConfig {
|
||||
return &core.CliConfig{
|
||||
AppID: appID, AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -59,7 +57,7 @@ func withDocsWorkingDir(t *testing.T, dir string) {
|
||||
}
|
||||
|
||||
func TestDocMediaInsertRejectsOldDocURL(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfig())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-test-app"))
|
||||
|
||||
err := mountAndRunDocs(t, DocMediaInsert, []string{
|
||||
"+media-insert",
|
||||
@@ -77,7 +75,7 @@ func TestDocMediaInsertRejectsOldDocURL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDocMediaInsertDryRunWikiAddsResolveStep(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfig())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-test-app"))
|
||||
|
||||
err := mountAndRunDocs(t, DocMediaInsert, []string{
|
||||
"+media-insert",
|
||||
@@ -99,6 +97,98 @@ func TestDocMediaInsertDryRunWikiAddsResolveStep(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaUploadDryRunUsesMultipartForLargeFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
writeSizedDocTestFile(t, "large.bin", common.MaxDriveMediaUploadSinglePartSize+1)
|
||||
|
||||
cmd := &cobra.Command{Use: "docs +media-upload"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("parent-type", "", "")
|
||||
cmd.Flags().String("parent-node", "", "")
|
||||
cmd.Flags().String("doc-id", "", "")
|
||||
if err := cmd.Flags().Set("file", "./large.bin"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("parent-type", "docx_file"); err != nil {
|
||||
t.Fatalf("set --parent-type: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("parent-node", "blk_parent"); err != nil {
|
||||
t.Fatalf("set --parent-node: %v", err)
|
||||
}
|
||||
|
||||
dry := decodeDocDryRun(t, MediaUpload.DryRun(context.Background(), common.TestNewRuntimeContext(cmd, nil)))
|
||||
if dry.Description != "chunked media upload (files > 20MB)" {
|
||||
t.Fatalf("dry-run description = %q", dry.Description)
|
||||
}
|
||||
if len(dry.API) != 3 {
|
||||
t.Fatalf("expected 3 API calls, got %d", len(dry.API))
|
||||
}
|
||||
if dry.API[0].URL != "/open-apis/drive/v1/medias/upload_prepare" {
|
||||
t.Fatalf("first URL = %q, want upload_prepare", dry.API[0].URL)
|
||||
}
|
||||
if dry.API[1].URL != "/open-apis/drive/v1/medias/upload_part" {
|
||||
t.Fatalf("second URL = %q, want upload_part", dry.API[1].URL)
|
||||
}
|
||||
if dry.API[2].URL != "/open-apis/drive/v1/medias/upload_finish" {
|
||||
t.Fatalf("third URL = %q, want upload_finish", dry.API[2].URL)
|
||||
}
|
||||
if got, _ := dry.API[0].Body["parent_node"].(string); got != "blk_parent" {
|
||||
t.Fatalf("prepare parent_node = %q, want %q", got, "blk_parent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaInsertDryRunUsesMultipartForLargeFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
writeSizedDocTestFile(t, "large.bin", common.MaxDriveMediaUploadSinglePartSize+1)
|
||||
|
||||
cmd := &cobra.Command{Use: "docs +media-insert"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("doc", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("align", "", "")
|
||||
cmd.Flags().String("caption", "", "")
|
||||
if err := cmd.Flags().Set("doc", "doxcnDryRunLarge"); err != nil {
|
||||
t.Fatalf("set --doc: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("file", "./large.bin"); err != nil {
|
||||
t.Fatalf("set --file: %v", err)
|
||||
}
|
||||
|
||||
dry := decodeDocDryRun(t, DocMediaInsert.DryRun(context.Background(), common.TestNewRuntimeContext(cmd, nil)))
|
||||
if dry.Description != "4-step orchestration: query root → create block → upload file → bind to block (auto-rollback on failure)" {
|
||||
t.Fatalf("dry-run description = %q", dry.Description)
|
||||
}
|
||||
if len(dry.API) != 6 {
|
||||
t.Fatalf("expected 6 API calls, got %d", len(dry.API))
|
||||
}
|
||||
if dry.API[2].URL != "/open-apis/drive/v1/medias/upload_prepare" {
|
||||
t.Fatalf("third URL = %q, want upload_prepare", dry.API[2].URL)
|
||||
}
|
||||
if dry.API[3].URL != "/open-apis/drive/v1/medias/upload_part" {
|
||||
t.Fatalf("fourth URL = %q, want upload_part", dry.API[3].URL)
|
||||
}
|
||||
if dry.API[4].URL != "/open-apis/drive/v1/medias/upload_finish" {
|
||||
t.Fatalf("fifth URL = %q, want upload_finish", dry.API[4].URL)
|
||||
}
|
||||
if dry.API[5].URL != "/open-apis/docx/v1/documents/doxcnDryRunLarge/blocks/batch_update" {
|
||||
t.Fatalf("last URL = %q, want batch_update", dry.API[5].URL)
|
||||
}
|
||||
if !strings.Contains(dry.API[2].Desc, "[3a]") {
|
||||
t.Fatalf("upload_prepare desc = %q, want [3a] step marker", dry.API[2].Desc)
|
||||
}
|
||||
if !strings.Contains(dry.API[3].Desc, "[3b]") {
|
||||
t.Fatalf("upload_part desc = %q, want [3b] step marker", dry.API[3].Desc)
|
||||
}
|
||||
if !strings.Contains(dry.API[4].Desc, "[3c]") {
|
||||
t.Fatalf("upload_finish desc = %q, want [3c] step marker", dry.API[4].Desc)
|
||||
}
|
||||
if !strings.Contains(dry.API[5].Desc, "[4]") {
|
||||
t.Fatalf("batch_update desc = %q, want [4] step marker", dry.API[5].Desc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocMediaInsertExecuteResolvesWikiBeforeFileCheck(t *testing.T) {
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-insert-exec-app"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -194,3 +284,42 @@ func TestDocMediaDownloadRejectsHTTPErrorBeforeWrite(t *testing.T) {
|
||||
t.Fatalf("download target should not be created, statErr=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
type docDryRunOutput struct {
|
||||
Description string `json:"description"`
|
||||
API []struct {
|
||||
Desc string `json:"desc"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
|
||||
func writeSizedDocTestFile(t *testing.T, name string, size int64) {
|
||||
t.Helper()
|
||||
|
||||
fh, err := os.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Create(%q) error: %v", name, err)
|
||||
}
|
||||
if err := fh.Truncate(size); err != nil {
|
||||
t.Fatalf("Truncate(%q) error: %v", name, err)
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
t.Fatalf("Close(%q) error: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeDocDryRun(t *testing.T, dryAPI *common.DryRunAPI) docDryRunOutput {
|
||||
t.Helper()
|
||||
|
||||
raw, err := json.Marshal(dryAPI)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run output: %v", err)
|
||||
}
|
||||
|
||||
var dry docDryRunOutput
|
||||
if err := json.Unmarshal(raw, &dry); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v", err)
|
||||
}
|
||||
return dry
|
||||
}
|
||||
|
||||
@@ -5,16 +5,10 @@ package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -28,7 +22,7 @@ var MediaUpload = common.Shortcut{
|
||||
Scopes: []string{"docs:document.media:upload"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Desc: "local file path (max 20MB)", Required: true},
|
||||
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
|
||||
{Name: "parent-type", Desc: "parent type: docx_image | docx_file", Required: true},
|
||||
{Name: "parent-node", Desc: "parent node ID (block_id)", Required: true},
|
||||
{Name: "doc-id", Desc: "document ID (for drive_route_token)"},
|
||||
@@ -42,13 +36,42 @@ var MediaUpload = common.Shortcut{
|
||||
"file_name": filepath.Base(filePath),
|
||||
"parent_type": parentType,
|
||||
"parent_node": parentNode,
|
||||
"file": "@" + filePath,
|
||||
}
|
||||
if docId != "" {
|
||||
body["extra"] = fmt.Sprintf(`{"drive_route_token":"%s"}`, docId)
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("multipart/form-data upload").
|
||||
dry := common.NewDryRunAPI()
|
||||
if docMediaShouldUseMultipart(filePath) {
|
||||
prepareBody := map[string]interface{}{
|
||||
"file_name": filepath.Base(filePath),
|
||||
"parent_type": parentType,
|
||||
"parent_node": parentNode,
|
||||
"size": "<file_size>",
|
||||
}
|
||||
if extra, ok := body["extra"]; ok {
|
||||
prepareBody["extra"] = extra
|
||||
}
|
||||
dry.Desc("chunked media upload (files > 20MB)").
|
||||
POST("/open-apis/drive/v1/medias/upload_prepare").
|
||||
Body(prepareBody).
|
||||
POST("/open-apis/drive/v1/medias/upload_part").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"seq": "<chunk_index>",
|
||||
"size": "<chunk_size>",
|
||||
"file": "<chunk_binary>",
|
||||
}).
|
||||
POST("/open-apis/drive/v1/medias/upload_finish").
|
||||
Body(map[string]interface{}{
|
||||
"upload_id": "<upload_id>",
|
||||
"block_num": "<block_num>",
|
||||
})
|
||||
return dry
|
||||
}
|
||||
|
||||
body["file"] = "@" + filePath
|
||||
body["size"] = "<file_size>"
|
||||
return dry.Desc("multipart/form-data upload").
|
||||
POST("/open-apis/drive/v1/medias/upload_all").
|
||||
Body(body)
|
||||
},
|
||||
@@ -62,69 +85,25 @@ var MediaUpload = common.Shortcut{
|
||||
if pathErr != nil {
|
||||
return output.ErrValidation("unsafe file path: %s", pathErr)
|
||||
}
|
||||
filePath = safeFilePath
|
||||
|
||||
// Validate file
|
||||
stat, err := vfs.Stat(filePath)
|
||||
stat, err := vfs.Stat(safeFilePath)
|
||||
if err != nil {
|
||||
return output.ErrValidation("file not found: %s", filePath)
|
||||
}
|
||||
if stat.Size() > maxFileSize {
|
||||
return output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024)
|
||||
if !stat.Mode().IsRegular() {
|
||||
return output.ErrValidation("file must be a regular file: %s", filePath)
|
||||
}
|
||||
|
||||
fileName := filepath.Base(filePath)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%d bytes)\n", fileName, stat.Size())
|
||||
if stat.Size() > common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
||||
}
|
||||
|
||||
f, err := vfs.Open(filePath)
|
||||
fileToken, err := uploadDocMediaFile(runtime, filePath, fileName, stat.Size(), parentType, parentNode, docId)
|
||||
if err != nil {
|
||||
return output.ErrValidation("cannot open file: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Build SDK Formdata
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddField("parent_type", parentType)
|
||||
fd.AddField("parent_node", parentNode)
|
||||
fd.AddField("size", fmt.Sprintf("%d", stat.Size()))
|
||||
if docId != "" {
|
||||
extra, err := buildDriveRouteExtra(docId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fd.AddField("extra", extra)
|
||||
}
|
||||
fd.AddFile("file", f)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
return output.ErrNetwork("upload failed: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
|
||||
}
|
||||
|
||||
code, _ := util.ToFloat64(result["code"])
|
||||
if code != 0 {
|
||||
msg, _ := result["msg"].(string)
|
||||
return output.ErrAPI(int(code), fmt.Sprintf("upload failed: [%d] %s", int(code), msg), result["error"])
|
||||
}
|
||||
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
fileToken, _ := data["file_token"].(string)
|
||||
if fileToken == "" {
|
||||
return output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
@@ -135,3 +114,49 @@ var MediaUpload = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func uploadDocMediaFile(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, parentType, parentNode, docID string) (string, error) {
|
||||
var extra string
|
||||
if docID != "" {
|
||||
var err error
|
||||
extra, err = buildDriveRouteExtra(docID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// Doc media uploads share the generic Drive media transport. The doc-specific
|
||||
// routing only shows up in parent_type/parent_node and optional route extra.
|
||||
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
|
||||
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: parentType,
|
||||
ParentNode: &parentNode,
|
||||
Extra: extra,
|
||||
})
|
||||
}
|
||||
return common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: parentType,
|
||||
ParentNode: parentNode,
|
||||
Extra: extra,
|
||||
})
|
||||
}
|
||||
|
||||
func docMediaShouldUseMultipart(filePath string) bool {
|
||||
// Dry-run uses local stat as a best-effort planning hint. Execute re-validates
|
||||
// the file before choosing the actual upload path.
|
||||
safeFilePath, err := validate.SafeInputPath(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
info, err := vfs.Stat(safeFilePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ var DocsCreate = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
normalizeDocsUpdateResult(result, runtime.Str("markdown"))
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -65,7 +65,6 @@ func TestValidateDriveExportSpec(t *testing.T) {
|
||||
|
||||
func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs/v1/content",
|
||||
@@ -117,7 +116,6 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
|
||||
func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
@@ -188,7 +186,6 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
|
||||
func TestDriveExportReadyDownloadFailureIncludesRecoveryHint(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
@@ -267,7 +264,6 @@ func TestDriveExportReadyDownloadFailureIncludesRecoveryHint(t *testing.T) {
|
||||
|
||||
func TestDriveExportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
@@ -333,7 +329,6 @@ func TestDriveExportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
|
||||
func TestDriveExportPollErrorsReturnLastErrorWithRecoveryHint(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
@@ -389,7 +384,6 @@ func TestDriveExportPollErrorsReturnLastErrorWithRecoveryHint(t *testing.T) {
|
||||
|
||||
func TestDriveExportDownloadUsesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_789/download",
|
||||
@@ -424,7 +418,6 @@ func TestDriveExportDownloadUsesProvidedFileName(t *testing.T) {
|
||||
|
||||
func TestDriveExportDownloadRejectsOverwriteWithoutFlag(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_dup/download",
|
||||
@@ -480,7 +473,6 @@ func TestSaveContentToOutputDirRejectsOverwriteWithoutFlag(t *testing.T) {
|
||||
|
||||
func TestDriveTaskResultExportIncludesReadyFlags(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_export",
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
@@ -147,9 +146,8 @@ func preflightDriveImportFile(spec *driveImportSpec) (int64, error) {
|
||||
if err != nil {
|
||||
return 0, output.ErrValidation("unsafe file path: %s", err)
|
||||
}
|
||||
spec.FilePath = safeFilePath
|
||||
|
||||
info, err := vfs.Stat(spec.FilePath)
|
||||
info, err := vfs.Stat(safeFilePath)
|
||||
if err != nil {
|
||||
return 0, output.ErrValidation("cannot read file: %s", err)
|
||||
}
|
||||
@@ -168,7 +166,7 @@ func appendDriveImportUploadDryRun(dry *common.DryRunAPI, spec driveImportSpec,
|
||||
extra = fmt.Sprintf(`{"obj_type":"%s","file_extension":"%s"}`, spec.DocType, spec.FileExtension())
|
||||
}
|
||||
|
||||
if fileSize > maxDriveUploadFileSize {
|
||||
if fileSize > common.MaxDriveMediaUploadSinglePartSize {
|
||||
dry.POST("/open-apis/drive/v1/medias/upload_prepare").
|
||||
Desc("[1a] Initialize multipart upload").
|
||||
Body(map[string]interface{}{
|
||||
|
||||
@@ -4,21 +4,15 @@
|
||||
package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -37,12 +31,6 @@ const (
|
||||
driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024
|
||||
)
|
||||
|
||||
type driveMultipartUploadSession struct {
|
||||
UploadID string
|
||||
BlockSize int
|
||||
BlockNum int
|
||||
}
|
||||
|
||||
// driveImportExtToDocTypes defines which source file extensions can be imported
|
||||
// into which Drive-native document types.
|
||||
var driveImportExtToDocTypes = map[string][]string{
|
||||
@@ -106,163 +94,41 @@ func uploadMediaForImport(ctx context.Context, runtime *common.RuntimeContext, f
|
||||
if err = validateDriveImportFileSize(filePath, docType, fileSize); err != nil {
|
||||
return "", err
|
||||
}
|
||||
fileSizeValue, err := driveUploadSizeValue(fileSize)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
extra, err := buildImportMediaExtra(filePath, docType)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if fileSize <= maxDriveUploadFileSize {
|
||||
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading media for import: %s (%s)\n", fileName, common.FormatSize(fileSize))
|
||||
return uploadMediaForImportAll(runtime, filePath, fileName, fileSizeValue, extra)
|
||||
// upload_all for import works without parent_node; omitting it preserves
|
||||
// the existing root-level import staging behavior.
|
||||
return common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: "ccm_import_open",
|
||||
Extra: extra,
|
||||
})
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading media for import via multipart upload: %s (%s)\n", fileName, common.FormatSize(fileSize))
|
||||
return uploadMediaForImportMultipart(runtime, filePath, fileName, fileSizeValue, extra)
|
||||
}
|
||||
|
||||
func uploadMediaForImportAll(runtime *common.RuntimeContext, filePath, fileName string, fileSize int, extra string) (string, error) {
|
||||
f, err := vfs.Open(filePath)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("cannot read file: %s", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddField("parent_type", "ccm_import_open")
|
||||
fd.AddField("size", fmt.Sprintf("%d", fileSize))
|
||||
fd.AddField("extra", extra)
|
||||
fd.AddFile("file", f)
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return "", wrapDriveUploadRequestError(err, "upload media failed")
|
||||
}
|
||||
|
||||
data, err := parseDriveUploadResponse(apiResp, "upload media failed")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return extractDriveUploadFileToken(data, "upload media failed")
|
||||
}
|
||||
|
||||
func uploadMediaForImportMultipart(runtime *common.RuntimeContext, filePath, fileName string, fileSize int, extra string) (string, error) {
|
||||
session, err := prepareMediaImportUpload(runtime, fileName, fileSize, extra)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload prepare failed: %s\n", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
totalBlocks := session.BlockNum
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", totalBlocks, common.FormatSize(int64(session.BlockSize)))
|
||||
|
||||
f, err := vfs.Open(filePath)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("cannot read file: %s", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
buffer := make([]byte, session.BlockSize)
|
||||
remaining := fileSize
|
||||
uploadedBlocks := 0
|
||||
for remaining > 0 {
|
||||
chunkSize := session.BlockSize
|
||||
if chunkSize > remaining {
|
||||
chunkSize = remaining
|
||||
}
|
||||
|
||||
n, readErr := io.ReadFull(f, buffer[:chunkSize])
|
||||
if readErr != nil {
|
||||
return "", output.ErrValidation("cannot read file: %s", readErr)
|
||||
}
|
||||
|
||||
if err = uploadMediaImportPart(runtime, session.UploadID, uploadedBlocks, buffer[:n]); err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload part failed: %s\n", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
remaining -= n
|
||||
uploadedBlocks++
|
||||
}
|
||||
|
||||
if session.BlockNum > 0 && session.BlockNum != uploadedBlocks {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "upload prepare mismatch: expected %d blocks, uploaded %d", session.BlockNum, uploadedBlocks)
|
||||
}
|
||||
|
||||
return finishMediaImportUpload(runtime, session.UploadID, uploadedBlocks)
|
||||
}
|
||||
|
||||
func prepareMediaImportUpload(runtime *common.RuntimeContext, fileName string, fileSize int, extra string) (driveMultipartUploadSession, error) {
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/medias/upload_prepare", nil, map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"parent_type": "ccm_import_open", // For media import uploads, parent_type must be ccm_import_open.
|
||||
"size": fileSize,
|
||||
"extra": extra,
|
||||
"parent_node": "", // For media import uploads, parent_node must be an explicit empty string; unlike medias/upload_all, this field cannot be omitted.
|
||||
// upload_prepare is stricter than upload_all here and expects parent_node to
|
||||
// be sent explicitly, even when import uses the implicit root staging area.
|
||||
return common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
|
||||
FilePath: filePath,
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
ParentType: "ccm_import_open",
|
||||
ParentNode: "",
|
||||
Extra: extra,
|
||||
})
|
||||
if err != nil {
|
||||
return driveMultipartUploadSession{}, err
|
||||
}
|
||||
|
||||
session := driveMultipartUploadSession{
|
||||
UploadID: common.GetString(data, "upload_id"),
|
||||
BlockSize: int(common.GetFloat(data, "block_size")),
|
||||
BlockNum: int(common.GetFloat(data, "block_num")),
|
||||
}
|
||||
if session.UploadID == "" {
|
||||
return driveMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: no upload_id returned")
|
||||
}
|
||||
if session.BlockSize <= 0 {
|
||||
return driveMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned")
|
||||
}
|
||||
if session.BlockNum <= 0 {
|
||||
return driveMultipartUploadSession{}, output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_num returned")
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func uploadMediaImportPart(runtime *common.RuntimeContext, uploadID string, seq int, chunk []byte) error {
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("upload_id", uploadID)
|
||||
fd.AddField("seq", seq)
|
||||
fd.AddField("size", len(chunk))
|
||||
fd.AddFile("file", bytes.NewReader(chunk))
|
||||
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return wrapDriveUploadRequestError(err, "upload media part failed")
|
||||
}
|
||||
|
||||
_, err = parseDriveUploadResponse(apiResp, "upload media part failed")
|
||||
return err
|
||||
}
|
||||
|
||||
func finishMediaImportUpload(runtime *common.RuntimeContext, uploadID string, blockNum int) (string, error) {
|
||||
data, err := runtime.CallAPI("POST", "/open-apis/drive/v1/medias/upload_finish", nil, map[string]interface{}{
|
||||
"upload_id": uploadID,
|
||||
"block_num": blockNum,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload finish failed: %s\n", err)
|
||||
return "", err
|
||||
}
|
||||
return extractDriveUploadFileToken(data, "upload media finish failed")
|
||||
}
|
||||
|
||||
func buildImportMediaExtra(filePath, docType string) (string, error) {
|
||||
// The import media endpoint uses extra to decide both the target native type
|
||||
// and how to interpret the uploaded source file.
|
||||
extraBytes, err := json.Marshal(map[string]string{
|
||||
"obj_type": docType,
|
||||
"file_extension": strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), "."),
|
||||
@@ -318,45 +184,6 @@ func validateDriveImportFileSize(filePath, docType string, fileSize int64) error
|
||||
)
|
||||
}
|
||||
|
||||
func driveUploadSizeValue(fileSize int64) (int, error) {
|
||||
maxInt := int64(^uint(0) >> 1)
|
||||
if fileSize > maxInt {
|
||||
return 0, output.ErrValidation("file %s is too large to upload", common.FormatSize(fileSize))
|
||||
}
|
||||
return int(fileSize), nil
|
||||
}
|
||||
|
||||
func wrapDriveUploadRequestError(err error, action string) error {
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
return output.ErrNetwork("%s: %v", action, err)
|
||||
}
|
||||
|
||||
func parseDriveUploadResponse(apiResp *larkcore.ApiResp, action string) (map[string]interface{}, error) {
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
|
||||
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: invalid response JSON: %v", action, err)
|
||||
}
|
||||
|
||||
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
|
||||
msg, _ := result["msg"].(string)
|
||||
return nil, output.ErrAPI(larkCode, fmt.Sprintf("%s: [%d] %s", action, larkCode, msg), result["error"])
|
||||
}
|
||||
|
||||
data, _ := result["data"].(map[string]interface{})
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func extractDriveUploadFileToken(data map[string]interface{}, action string) (string, error) {
|
||||
fileToken := common.GetString(data, "file_token")
|
||||
if fileToken == "" {
|
||||
return "", output.Errorf(output.ExitAPI, "api_error", "%s: no file_token returned", action)
|
||||
}
|
||||
return fileToken, nil
|
||||
}
|
||||
|
||||
// validateDriveImportSpec enforces the CLI-level compatibility rules before any
|
||||
// upload or import request is sent to the backend.
|
||||
func validateDriveImportSpec(spec driveImportSpec) error {
|
||||
|
||||
@@ -5,20 +5,12 @@ package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestValidateDriveImportSpecRejectsMismatchedType(t *testing.T) {
|
||||
@@ -144,7 +136,6 @@ func TestDriveImportStatusPendingWithoutToken(t *testing.T) {
|
||||
|
||||
func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
@@ -207,295 +198,6 @@ func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportUsesMultipartUploadForLargeFile(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
|
||||
prepareStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": 4 * 1024 * 1024,
|
||||
"block_num": 6,
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(prepareStub)
|
||||
|
||||
partStubs := make([]*httpmock.Stub, 0, 6)
|
||||
for i := 0; i < 6; i++ {
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
}
|
||||
partStubs = append(partStubs, stub)
|
||||
reg.Register(stub)
|
||||
}
|
||||
|
||||
finishStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"file_token": "file_123",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(finishStub)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/import_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"ticket": "tk_import"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/import_tasks/tk_import",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"type": "sheet",
|
||||
"job_status": 0,
|
||||
"token": "sheet_123",
|
||||
"url": "https://example.com/sheets/sheet_123",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1)
|
||||
|
||||
err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import",
|
||||
"--file", "large.xlsx",
|
||||
"--type", "sheet",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"token": "sheet_123"`)) {
|
||||
t.Fatalf("stdout missing imported token: %s", stdout.String())
|
||||
}
|
||||
|
||||
prepareBody := decodeCapturedJSONBody(t, prepareStub)
|
||||
if got, _ := prepareBody["parent_type"].(string); got != "ccm_import_open" {
|
||||
t.Fatalf("prepare parent_type = %q, want %q", got, "ccm_import_open")
|
||||
}
|
||||
if got, _ := prepareBody["file_name"].(string); got != "large.xlsx" {
|
||||
t.Fatalf("prepare file_name = %q, want %q", got, "large.xlsx")
|
||||
}
|
||||
if got, _ := prepareBody["size"].(float64); got != float64(maxDriveUploadFileSize+1) {
|
||||
t.Fatalf("prepare size = %v, want %d", got, maxDriveUploadFileSize+1)
|
||||
}
|
||||
|
||||
firstPart := decodeCapturedMultipartBody(t, partStubs[0])
|
||||
if got := firstPart.Fields["upload_id"]; got != "upload_123" {
|
||||
t.Fatalf("first part upload_id = %q, want %q", got, "upload_123")
|
||||
}
|
||||
if got := firstPart.Fields["seq"]; got != "0" {
|
||||
t.Fatalf("first part seq = %q, want %q", got, "0")
|
||||
}
|
||||
if got := firstPart.Fields["size"]; got != "4194304" {
|
||||
t.Fatalf("first part size = %q, want %q", got, "4194304")
|
||||
}
|
||||
if got := len(firstPart.Files["file"]); got != 4*1024*1024 {
|
||||
t.Fatalf("first part file size = %d, want %d", got, 4*1024*1024)
|
||||
}
|
||||
|
||||
lastPart := decodeCapturedMultipartBody(t, partStubs[len(partStubs)-1])
|
||||
if got := lastPart.Fields["seq"]; got != "5" {
|
||||
t.Fatalf("last part seq = %q, want %q", got, "5")
|
||||
}
|
||||
if got := lastPart.Fields["size"]; got != "1" {
|
||||
t.Fatalf("last part size = %q, want %q", got, "1")
|
||||
}
|
||||
if got := len(lastPart.Files["file"]); got != 1 {
|
||||
t.Fatalf("last part file size = %d, want %d", got, 1)
|
||||
}
|
||||
|
||||
finishBody := decodeCapturedJSONBody(t, finishStub)
|
||||
if got, _ := finishBody["upload_id"].(string); got != "upload_123" {
|
||||
t.Fatalf("finish upload_id = %q, want %q", got, "upload_123")
|
||||
}
|
||||
if got, _ := finishBody["block_num"].(float64); got != 6 {
|
||||
t.Fatalf("finish block_num = %v, want %d", got, 6)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportMultipartPrepareValidatesResponseFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data map[string]interface{}
|
||||
wantText string
|
||||
}{
|
||||
{
|
||||
name: "missing upload id",
|
||||
data: map[string]interface{}{
|
||||
"block_size": 4 * 1024 * 1024,
|
||||
"block_num": 6,
|
||||
},
|
||||
wantText: "upload prepare failed: no upload_id returned",
|
||||
},
|
||||
{
|
||||
name: "missing block size",
|
||||
data: map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_num": 6,
|
||||
},
|
||||
wantText: "upload prepare failed: invalid block_size returned",
|
||||
},
|
||||
{
|
||||
name: "missing block num",
|
||||
data: map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": 4 * 1024 * 1024,
|
||||
},
|
||||
wantText: "upload prepare failed: invalid block_num returned",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": tt.data,
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1)
|
||||
|
||||
err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import",
|
||||
"--file", "large.xlsx",
|
||||
"--type", "sheet",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantText) {
|
||||
t.Fatalf("error = %v, want substring %q", err, tt.wantText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportMultipartUploadPartAPIFailure(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": 4 * 1024 * 1024,
|
||||
"block_num": 6,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: map[string]interface{}{
|
||||
"code": 999,
|
||||
"msg": "chunk rejected",
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1)
|
||||
|
||||
err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import",
|
||||
"--file", "large.xlsx",
|
||||
"--type", "sheet",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "upload media part failed: [999] chunk rejected") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportMultipartFinishRequiresFileToken(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "upload_123",
|
||||
"block_size": 4 * 1024 * 1024,
|
||||
"block_num": 6,
|
||||
},
|
||||
},
|
||||
})
|
||||
for i := 0; i < 6; i++ {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_part",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_finish",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
writeSizedDriveImportFile(t, "large.xlsx", int64(maxDriveUploadFileSize)+1)
|
||||
|
||||
err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import",
|
||||
"--file", "large.xlsx",
|
||||
"--type", "sheet",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "upload media finish failed: no file_token returned") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportRejectsOversizedFileByImportLimit(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
@@ -517,73 +219,6 @@ func TestDriveImportRejectsOversizedFileByImportLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDriveUploadResponseErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("invalid json", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseDriveUploadResponse(&larkcore.ApiResp{RawBody: []byte("{")}, "upload media failed")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid response JSON") {
|
||||
t.Fatalf("expected invalid JSON error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("api code error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseDriveUploadResponse(&larkcore.ApiResp{RawBody: []byte(`{"code":999,"msg":"boom","error":{"detail":"x"}}`)}, "upload media failed")
|
||||
if err == nil || !strings.Contains(err.Error(), "upload media failed: [999] boom") {
|
||||
t.Fatalf("expected API error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWrapDriveUploadRequestError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("preserves exit error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
original := output.ErrValidation("bad input")
|
||||
got := wrapDriveUploadRequestError(original, "upload media failed")
|
||||
if got != original {
|
||||
t.Fatalf("expected same exit error pointer, got %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wraps generic error as network", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := wrapDriveUploadRequestError(io.EOF, "upload media failed")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", got)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitNetwork)
|
||||
}
|
||||
if !strings.Contains(got.Error(), "upload media failed") {
|
||||
t.Fatalf("unexpected error: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type capturedMultipartBody struct {
|
||||
Fields map[string]string
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode captured JSON body: %v", err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func writeSizedDriveImportFile(t *testing.T, name string, size int64) {
|
||||
t.Helper()
|
||||
|
||||
@@ -598,42 +233,3 @@ func writeSizedDriveImportFile(t *testing.T, name string, size int64) {
|
||||
t.Fatalf("Close(%q) error: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeCapturedMultipartBody(t *testing.T, stub *httpmock.Stub) capturedMultipartBody {
|
||||
t.Helper()
|
||||
|
||||
contentType := stub.CapturedHeaders.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
t.Fatalf("parse multipart content type: %v", err)
|
||||
}
|
||||
if mediaType != "multipart/form-data" {
|
||||
t.Fatalf("content type = %q, want multipart/form-data", mediaType)
|
||||
}
|
||||
|
||||
reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
body := capturedMultipartBody{
|
||||
Fields: map[string]string{},
|
||||
Files: map[string][]byte{},
|
||||
}
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read multipart part: %v", err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(part)
|
||||
if err != nil {
|
||||
t.Fatalf("read multipart data: %v", err)
|
||||
}
|
||||
if part.FileName() != "" {
|
||||
body.Files[part.FormName()] = data
|
||||
continue
|
||||
}
|
||||
body.Fields[part.FormName()] = string(data)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ func TestDriveImportDryRunShowsMultipartUploadForLargeFile(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error: %v", err)
|
||||
}
|
||||
if err := fh.Truncate(int64(maxDriveUploadFileSize) + 1); err != nil {
|
||||
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
|
||||
t.Fatalf("Truncate() error: %v", err)
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
|
||||
@@ -18,9 +18,6 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// registerDriveBotTokenStub is a no-op. TAT is now managed by CredentialProvider, not SDK.
|
||||
func registerDriveBotTokenStub(_ *httpmock.Registry) {}
|
||||
|
||||
func driveTestConfig() *core.CliConfig {
|
||||
return &core.CliConfig{
|
||||
AppID: "drive-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -62,7 +59,6 @@ func TestDriveUploadLargeFileUsesMultipart(t *testing.T) {
|
||||
AppID: "drive-upload-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
registerDriveBotTokenStub(reg)
|
||||
|
||||
// Step 1: upload_prepare
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -72,7 +68,7 @@ func TestDriveUploadLargeFileUsesMultipart(t *testing.T) {
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "test-upload-id",
|
||||
"block_size": float64(maxDriveUploadFileSize),
|
||||
"block_size": float64(common.MaxDriveMediaUploadSinglePartSize),
|
||||
"block_num": float64(2),
|
||||
},
|
||||
},
|
||||
@@ -116,7 +112,7 @@ func TestDriveUploadLargeFileUsesMultipart(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error: %v", err)
|
||||
}
|
||||
if err := fh.Truncate(maxDriveUploadFileSize + 1); err != nil {
|
||||
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
|
||||
t.Fatalf("Truncate() error: %v", err)
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
@@ -141,7 +137,6 @@ func TestDriveUploadSmallFile(t *testing.T) {
|
||||
AppID: "drive-upload-small-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
registerDriveBotTokenStub(reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -181,7 +176,6 @@ func TestDriveUploadSmallFileAPIError(t *testing.T) {
|
||||
AppID: "drive-upload-small-err", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
registerDriveBotTokenStub(reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -218,7 +212,6 @@ func TestDriveUploadSmallFileNoToken(t *testing.T) {
|
||||
AppID: "drive-upload-small-notoken", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
registerDriveBotTokenStub(reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -256,7 +249,6 @@ func TestDriveUploadSmallFileInvalidJSON(t *testing.T) {
|
||||
AppID: "drive-upload-small-json", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
registerDriveBotTokenStub(reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -291,7 +283,6 @@ func TestDriveUploadPrepareInvalidResponse(t *testing.T) {
|
||||
AppID: "drive-upload-prepare-bad", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
registerDriveBotTokenStub(reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -317,7 +308,7 @@ func TestDriveUploadPrepareInvalidResponse(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error: %v", err)
|
||||
}
|
||||
if err := fh.Truncate(maxDriveUploadFileSize + 1); err != nil {
|
||||
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
|
||||
t.Fatalf("Truncate() error: %v", err)
|
||||
}
|
||||
fh.Close()
|
||||
@@ -338,7 +329,6 @@ func TestDriveUploadPartAPIError(t *testing.T) {
|
||||
AppID: "drive-upload-part-err", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
registerDriveBotTokenStub(reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -347,7 +337,7 @@ func TestDriveUploadPartAPIError(t *testing.T) {
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "test-upload-id",
|
||||
"block_size": float64(maxDriveUploadFileSize),
|
||||
"block_size": float64(common.MaxDriveMediaUploadSinglePartSize),
|
||||
"block_num": float64(2),
|
||||
},
|
||||
},
|
||||
@@ -380,7 +370,7 @@ func TestDriveUploadPartAPIError(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error: %v", err)
|
||||
}
|
||||
if err := fh.Truncate(maxDriveUploadFileSize + 1); err != nil {
|
||||
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
|
||||
t.Fatalf("Truncate() error: %v", err)
|
||||
}
|
||||
fh.Close()
|
||||
@@ -401,7 +391,6 @@ func TestDriveUploadPartInvalidJSON(t *testing.T) {
|
||||
AppID: "drive-upload-part-json", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
registerDriveBotTokenStub(reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -410,7 +399,7 @@ func TestDriveUploadPartInvalidJSON(t *testing.T) {
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "test-upload-id",
|
||||
"block_size": float64(maxDriveUploadFileSize + 1),
|
||||
"block_size": float64(common.MaxDriveMediaUploadSinglePartSize + 1),
|
||||
"block_num": float64(1),
|
||||
},
|
||||
},
|
||||
@@ -433,7 +422,7 @@ func TestDriveUploadPartInvalidJSON(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error: %v", err)
|
||||
}
|
||||
if err := fh.Truncate(maxDriveUploadFileSize + 1); err != nil {
|
||||
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
|
||||
t.Fatalf("Truncate() error: %v", err)
|
||||
}
|
||||
fh.Close()
|
||||
@@ -454,7 +443,6 @@ func TestDriveUploadFinishNoToken(t *testing.T) {
|
||||
AppID: "drive-upload-finish-notoken", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
registerDriveBotTokenStub(reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -463,7 +451,7 @@ func TestDriveUploadFinishNoToken(t *testing.T) {
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"upload_id": "test-upload-id",
|
||||
"block_size": float64(maxDriveUploadFileSize + 1),
|
||||
"block_size": float64(common.MaxDriveMediaUploadSinglePartSize + 1),
|
||||
"block_num": float64(1),
|
||||
},
|
||||
},
|
||||
@@ -495,7 +483,7 @@ func TestDriveUploadFinishNoToken(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error: %v", err)
|
||||
}
|
||||
if err := fh.Truncate(maxDriveUploadFileSize + 1); err != nil {
|
||||
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
|
||||
t.Fatalf("Truncate() error: %v", err)
|
||||
}
|
||||
fh.Close()
|
||||
@@ -516,7 +504,6 @@ func TestDriveUploadWithCustomName(t *testing.T) {
|
||||
AppID: "drive-upload-name-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
registerDriveBotTokenStub(reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
|
||||
@@ -104,7 +104,6 @@ func TestDriveMoveDryRunFolderIncludesTaskCheckParams(t *testing.T) {
|
||||
|
||||
func TestDriveMoveFolderSuccessUsesTaskCheckHelper(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/fld_src/move",
|
||||
@@ -148,7 +147,6 @@ func TestDriveMoveFolderSuccessUsesTaskCheckHelper(t *testing.T) {
|
||||
|
||||
func TestDriveMoveFolderTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/fld_src/move",
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
|
||||
func TestDriveMoveUsesRootFolderWhenFolderTokenMissing(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/explorer/v2/root_folder/meta",
|
||||
@@ -52,7 +51,6 @@ func TestDriveMoveUsesRootFolderWhenFolderTokenMissing(t *testing.T) {
|
||||
|
||||
func TestDriveMoveRootFolderLookupRequiresToken(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/explorer/v2/root_folder/meta",
|
||||
|
||||
@@ -127,7 +127,6 @@ func TestDriveTaskResultDryRunExportIncludesTokenParam(t *testing.T) {
|
||||
|
||||
func TestDriveTaskResultImportIncludesReadyFlags(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/import_tasks/tk_import",
|
||||
@@ -161,7 +160,6 @@ func TestDriveTaskResultImportIncludesReadyFlags(t *testing.T) {
|
||||
|
||||
func TestDriveTaskResultTaskCheckIncludesReadyFlags(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
registerDriveBotTokenStub(reg)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/task_check",
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
@@ -21,8 +20,6 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const maxDriveUploadFileSize = 20 * 1024 * 1024 // 20MB
|
||||
|
||||
var DriveUpload = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+upload",
|
||||
@@ -78,7 +75,7 @@ var DriveUpload = common.Shortcut{
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s)\n", fileName, common.FormatSize(fileSize))
|
||||
|
||||
var fileToken string
|
||||
if fileSize > maxDriveUploadFileSize {
|
||||
if fileSize > common.MaxDriveMediaUploadSinglePartSize {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
||||
fileToken, err = uploadFileMultipart(ctx, runtime, filePath, fileName, folderToken, fileSize)
|
||||
} else {
|
||||
@@ -183,7 +180,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
|
||||
partSize = remaining
|
||||
}
|
||||
|
||||
partFile, err := os.Open(filePath)
|
||||
partFile, err := vfs.Open(filePath)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("cannot open file: %v", err)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
func TestSanitizeURLForDisplay(t *testing.T) {
|
||||
@@ -404,39 +406,36 @@ func TestBuildSearchChatBodyAdditionalBranches(t *testing.T) {
|
||||
|
||||
func TestParseMediaDurationSuccess(t *testing.T) {
|
||||
t.Run("mp4", func(t *testing.T) {
|
||||
f, err := os.CreateTemp("", "im-duration-*.mp4")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp() error = %v", err)
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
fname := "im-duration-test.mp4"
|
||||
if err := os.WriteFile(fname, wrapInMoov(buildMvhdBox(0, 1000, 5000)), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
defer f.Close()
|
||||
|
||||
if _, err := f.Write(wrapInMoov(buildMvhdBox(0, 1000, 5000))); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
if got := parseMediaDuration(f.Name(), "mp4"); got != "5000" {
|
||||
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("unexpected")
|
||||
}))
|
||||
if got := parseMediaDuration(rt, fname, "mp4"); got != "5000" {
|
||||
t.Fatalf("parseMediaDuration(mp4) = %q, want %q", got, "5000")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("opus", func(t *testing.T) {
|
||||
f, err := os.CreateTemp("", "im-duration-*.ogg")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp() error = %v", err)
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
defer f.Close()
|
||||
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
page := make([]byte, 27)
|
||||
copy(page[0:4], "OggS")
|
||||
page[5] = 4
|
||||
page[6] = 0x00
|
||||
page[7] = 0x53
|
||||
page[8] = 0x07
|
||||
if _, err := f.Write(page); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
|
||||
fname := "im-duration-test.ogg"
|
||||
if err := os.WriteFile(fname, page, 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
if got := parseMediaDuration(f.Name(), "opus"); got != "10000" {
|
||||
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("unexpected")
|
||||
}))
|
||||
if got := parseMediaDuration(rt, fname, "opus"); got != "10000" {
|
||||
t.Fatalf("parseMediaDuration(opus) = %q, want %q", got, "10000")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13,16 +13,15 @@ import (
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -327,21 +326,16 @@ func resolveURLMedia(ctx context.Context, runtime *common.RuntimeContext, s medi
|
||||
func resolveLocalMedia(ctx context.Context, runtime *common.RuntimeContext, s mediaSpec) (string, error) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "uploading %s: %s\n", s.mediaType, filepath.Base(s.value))
|
||||
|
||||
safePath, err := validate.SafeInputPath(s.value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if s.kind == mediaKindImage {
|
||||
return uploadImageToIM(ctx, runtime, safePath, "message")
|
||||
return uploadImageToIM(ctx, runtime, s.value, "message")
|
||||
}
|
||||
|
||||
ft := detectIMFileType(safePath)
|
||||
ft := detectIMFileType(s.value)
|
||||
dur := ""
|
||||
if s.withDuration {
|
||||
dur = parseMediaDuration(safePath, ft)
|
||||
dur = parseMediaDuration(runtime, s.value, ft)
|
||||
}
|
||||
return uploadFileToIM(ctx, runtime, safePath, ft, dur)
|
||||
return uploadFileToIM(ctx, runtime, s.value, ft, dur)
|
||||
}
|
||||
|
||||
// resolveVideoContent handles the video case which needs both a file_key and
|
||||
@@ -556,18 +550,16 @@ func findMP4Box(data []byte, start, end int, boxType string) (int, int) {
|
||||
// for audio/video uploads. Only reads the minimal portion of the file needed
|
||||
// for parsing (tail for OGG, box headers + moov for MP4).
|
||||
// Returns "" if parsing fails or the file type is not audio/video.
|
||||
func parseMediaDuration(filePath, fileType string) string {
|
||||
func parseMediaDuration(runtime *common.RuntimeContext, filePath, fileType string) string {
|
||||
if fileType != "opus" && fileType != "mp4" {
|
||||
return ""
|
||||
}
|
||||
f, err := vfs.Open(filePath)
|
||||
if err != nil {
|
||||
info, err := runtime.FileIO().Stat(filePath)
|
||||
if err != nil || info.Size() == 0 {
|
||||
return ""
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil || info.Size() == 0 {
|
||||
f, err := runtime.FileIO().Open(filePath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -698,7 +690,7 @@ func readMp4DurationBytes(data []byte) int64 {
|
||||
}
|
||||
|
||||
// readOggDuration reads the tail of an OGG file (up to 64 KB) and parses duration.
|
||||
func readOggDuration(f *os.File, fileSize int64) int64 {
|
||||
func readOggDuration(f fileio.File, fileSize int64) int64 {
|
||||
const maxTail = 65536
|
||||
readSize := fileSize
|
||||
if readSize > maxTail {
|
||||
@@ -713,7 +705,7 @@ func readOggDuration(f *os.File, fileSize int64) int64 {
|
||||
|
||||
// readMp4Duration walks top-level MP4 boxes via file seeks to find moov,
|
||||
// then reads only the moov content to locate mvhd and extract the duration.
|
||||
func readMp4Duration(f *os.File, fileSize int64) int64 {
|
||||
func readMp4Duration(f fileio.File, fileSize int64) int64 {
|
||||
hdr := make([]byte, 16)
|
||||
var offset int64
|
||||
for offset+8 <= fileSize {
|
||||
@@ -1005,14 +997,11 @@ const maxImageUploadSize = 5 * 1024 * 1024 // 5MB — Lark API limit for images
|
||||
const maxFileUploadSize = 100 * 1024 * 1024 // 100MB — Lark API limit for files
|
||||
|
||||
func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, imageType string) (string, error) {
|
||||
// filePath is already validated by the caller (resolveLocalMedia).
|
||||
safePath := filePath
|
||||
|
||||
if info, err := vfs.Stat(safePath); err == nil && info.Size() > maxImageUploadSize {
|
||||
if info, err := runtime.FileIO().Stat(filePath); err == nil && info.Size() > maxImageUploadSize {
|
||||
return "", fmt.Errorf("image size %s exceeds limit (max 5MB)", common.FormatSize(info.Size()))
|
||||
}
|
||||
|
||||
f, err := vfs.Open(safePath)
|
||||
f, err := runtime.FileIO().Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -1045,14 +1034,11 @@ func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePa
|
||||
}
|
||||
|
||||
func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, fileType, duration string) (string, error) {
|
||||
// filePath is already validated by the caller (resolveLocalMedia).
|
||||
safePath := filePath
|
||||
|
||||
if info, err := vfs.Stat(safePath); err == nil && info.Size() > maxFileUploadSize {
|
||||
if info, err := runtime.FileIO().Stat(filePath); err == nil && info.Size() > maxFileUploadSize {
|
||||
return "", fmt.Errorf("file size %s exceeds limit (max 100MB)", common.FormatSize(info.Size()))
|
||||
}
|
||||
|
||||
f, err := vfs.Open(safePath)
|
||||
f, err := runtime.FileIO().Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -1060,7 +1046,7 @@ func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePat
|
||||
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_type", fileType)
|
||||
fd.AddField("file_name", filepath.Base(safePath))
|
||||
fd.AddField("file_name", filepath.Base(filePath))
|
||||
if duration != "" {
|
||||
fd.AddField("duration", duration)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
@@ -52,9 +53,10 @@ func shortcutRawResponse(status int, body []byte, headers http.Header) *http.Res
|
||||
headers = make(http.Header)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: headers,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
StatusCode: status,
|
||||
Header: headers,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
ContentLength: int64(len(body)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,10 +90,11 @@ func newBotShortcutRuntime(t *testing.T, rt http.RoundTripper) *common.RuntimeCo
|
||||
runtime := &common.RuntimeContext{
|
||||
Config: cfg,
|
||||
Factory: &cmdutil.Factory{
|
||||
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
||||
HttpClient: func() (*http.Client, error) { return httpClient, nil },
|
||||
LarkClient: func() (*lark.Client, error) { return sdk, nil },
|
||||
Credential: testCred,
|
||||
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
||||
HttpClient: func() (*http.Client, error) { return httpClient, nil },
|
||||
LarkClient: func() (*lark.Client, error) { return sdk, nil },
|
||||
Credential: testCred,
|
||||
FileIOProvider: fileio.GetProvider(),
|
||||
IOStreams: &cmdutil.IOStreams{
|
||||
Out: &bytes.Buffer{},
|
||||
ErrOut: &bytes.Buffer{},
|
||||
@@ -241,7 +244,9 @@ func TestDownloadIMResourceToPathSuccess(t *testing.T) {
|
||||
}
|
||||
}))
|
||||
|
||||
target := filepath.Join(t.TempDir(), "nested", "resource.bin")
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
target := filepath.Join("nested", "resource.bin")
|
||||
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_123", "file_123", "file", target)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
@@ -280,7 +285,9 @@ func TestDownloadIMResourceToPathHTTPErrorBody(t *testing.T) {
|
||||
}
|
||||
}))
|
||||
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_403", "file_403", "file", filepath.Join(t.TempDir(), "out.bin"))
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_403", "file_403", "file", "out.bin")
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTP 403: denied") {
|
||||
t.Fatalf("downloadIMResourceToPath() error = %v", err)
|
||||
}
|
||||
@@ -305,28 +312,14 @@ func TestUploadImageToIMSuccess(t *testing.T) {
|
||||
}
|
||||
}))
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd() error = %v", err)
|
||||
}
|
||||
tmpDir := t.TempDir()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("Chdir() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(wd)
|
||||
})
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
path := "demo.png"
|
||||
if err := os.WriteFile(path, []byte("png"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Abs() error = %v", err)
|
||||
}
|
||||
got, err := uploadImageToIM(context.Background(), runtime, absPath, "message")
|
||||
got, err := uploadImageToIM(context.Background(), runtime, path, "message")
|
||||
if err != nil {
|
||||
t.Fatalf("uploadImageToIM() error = %v", err)
|
||||
}
|
||||
@@ -357,28 +350,14 @@ func TestUploadFileToIMSuccess(t *testing.T) {
|
||||
}
|
||||
}))
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd() error = %v", err)
|
||||
}
|
||||
tmpDir := t.TempDir()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("Chdir() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(wd)
|
||||
})
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
path := "demo.txt"
|
||||
if err := os.WriteFile(path, []byte("demo"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Abs() error = %v", err)
|
||||
}
|
||||
got, err := uploadFileToIM(context.Background(), runtime, absPath, "stream", "1200")
|
||||
got, err := uploadFileToIM(context.Background(), runtime, path, "stream", "1200")
|
||||
if err != nil {
|
||||
t.Fatalf("uploadFileToIM() error = %v", err)
|
||||
}
|
||||
@@ -394,7 +373,8 @@ func TestUploadFileToIMSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUploadImageToIMSizeLimit(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "too-large.png")
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
path := "too-large.png"
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
@@ -404,14 +384,18 @@ func TestUploadImageToIMSizeLimit(t *testing.T) {
|
||||
}
|
||||
f.Close()
|
||||
|
||||
_, err = uploadImageToIM(context.Background(), nil, path, "message")
|
||||
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("unexpected")
|
||||
}))
|
||||
_, err = uploadImageToIM(context.Background(), rt, path, "message")
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds limit") {
|
||||
t.Fatalf("uploadImageToIM() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadFileToIMSizeLimit(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "too-large.bin")
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
path := "too-large.bin"
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
@@ -421,7 +405,10 @@ func TestUploadFileToIMSizeLimit(t *testing.T) {
|
||||
}
|
||||
f.Close()
|
||||
|
||||
_, err = uploadFileToIM(context.Background(), nil, path, "stream", "")
|
||||
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("unexpected")
|
||||
}))
|
||||
_, err = uploadFileToIM(context.Background(), rt, path, "stream", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds limit") {
|
||||
t.Fatalf("uploadFileToIM() error = %v", err)
|
||||
}
|
||||
@@ -430,6 +417,7 @@ func TestUploadFileToIMSizeLimit(t *testing.T) {
|
||||
func TestResolveMediaContentWrapsUploadError(t *testing.T) {
|
||||
runtime := &common.RuntimeContext{
|
||||
Factory: &cmdutil.Factory{
|
||||
FileIOProvider: fileio.GetProvider(),
|
||||
IOStreams: &cmdutil.IOStreams{
|
||||
Out: &bytes.Buffer{},
|
||||
ErrOut: &bytes.Buffer{},
|
||||
@@ -437,7 +425,9 @@ func TestResolveMediaContentWrapsUploadError(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
missing := filepath.Join(t.TempDir(), "missing.png")
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
missing := "missing.png"
|
||||
_, _, err := resolveMediaContent(context.Background(), runtime, "", missing, "", "", "", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "image upload failed") {
|
||||
t.Fatalf("resolveMediaContent() error = %v", err)
|
||||
@@ -457,15 +447,7 @@ func TestResolveLocalMediaImage(t *testing.T) {
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}))
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd() error = %v", err)
|
||||
}
|
||||
tmpDir := t.TempDir()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("Chdir() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(wd) })
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
if err := os.WriteFile("test.png", []byte("png-data"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
@@ -496,15 +478,7 @@ func TestResolveLocalMediaFile(t *testing.T) {
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}))
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd() error = %v", err)
|
||||
}
|
||||
tmpDir := t.TempDir()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("Chdir() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(wd) })
|
||||
cmdutil.TestChdir(t, t.TempDir())
|
||||
|
||||
if err := os.WriteFile("test.txt", []byte("file-data"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -263,10 +264,13 @@ func TestParseMp4Duration(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseMediaDuration(t *testing.T) {
|
||||
if got := parseMediaDuration("test.pdf", "pdf"); got != "" {
|
||||
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("unexpected")
|
||||
}))
|
||||
if got := parseMediaDuration(rt, "test.pdf", "pdf"); got != "" {
|
||||
t.Fatalf("parseMediaDuration(pdf) = %q, want empty", got)
|
||||
}
|
||||
if got := parseMediaDuration("nonexistent.opus", "opus"); got != "" {
|
||||
if got := parseMediaDuration(rt, "nonexistent.opus", "opus"); got != "" {
|
||||
t.Fatalf("parseMediaDuration(missing) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,29 +91,13 @@ var ImMessagesReply = common.Shortcut{
|
||||
videoCoverKey := runtime.Str("video-cover")
|
||||
audioKey := runtime.Str("audio")
|
||||
|
||||
if !isMediaKey(imageKey) {
|
||||
if _, err := validate.SafeLocalFlagPath("--image", imageKey); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
}
|
||||
if !isMediaKey(fileKey) {
|
||||
if _, err := validate.SafeLocalFlagPath("--file", fileKey); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
}
|
||||
if !isMediaKey(videoKey) {
|
||||
if _, err := validate.SafeLocalFlagPath("--video", videoKey); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
}
|
||||
if !isMediaKey(videoCoverKey) {
|
||||
if _, err := validate.SafeLocalFlagPath("--video-cover", videoCoverKey); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
}
|
||||
if !isMediaKey(audioKey) {
|
||||
if _, err := validate.SafeLocalFlagPath("--audio", audioKey); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
fio := runtime.FileIO()
|
||||
for _, mf := range []struct{ flag, val string }{
|
||||
{"--image", imageKey}, {"--file", fileKey}, {"--video", videoKey},
|
||||
{"--video-cover", videoCoverKey}, {"--audio", audioKey},
|
||||
} {
|
||||
if err := validateMediaFlagPath(fio, mf.flag, mf.val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,29 +133,13 @@ var ImMessagesReply = common.Shortcut{
|
||||
audioVal := runtime.Str("audio")
|
||||
replyInThread := runtime.Bool("reply-in-thread")
|
||||
idempotencyKey := runtime.Str("idempotency-key")
|
||||
if !isMediaKey(imageVal) {
|
||||
if _, err := validate.SafeLocalFlagPath("--image", imageVal); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
}
|
||||
if !isMediaKey(fileVal) {
|
||||
if _, err := validate.SafeLocalFlagPath("--file", fileVal); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
}
|
||||
if !isMediaKey(videoVal) {
|
||||
if _, err := validate.SafeLocalFlagPath("--video", videoVal); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
}
|
||||
if !isMediaKey(videoCoverVal) {
|
||||
if _, err := validate.SafeLocalFlagPath("--video-cover", videoCoverVal); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
}
|
||||
if !isMediaKey(audioVal) {
|
||||
if _, err := validate.SafeLocalFlagPath("--audio", audioVal); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
fio := runtime.FileIO()
|
||||
for _, mf := range []struct{ flag, val string }{
|
||||
{"--image", imageVal}, {"--file", fileVal}, {"--video", videoVal},
|
||||
{"--video-cover", videoCoverVal}, {"--audio", audioVal},
|
||||
} {
|
||||
if err := validateMediaFlagPath(fio, mf.flag, mf.val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,15 +6,15 @@ package im
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
@@ -54,7 +54,7 @@ var ImMessagesResourcesDownload = common.Shortcut{
|
||||
if err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if _, err := validate.SafeOutputPath(relPath); err != nil {
|
||||
if _, err := runtime.ResolveSavePath(relPath); err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
return nil
|
||||
@@ -67,12 +67,8 @@ var ImMessagesResourcesDownload = common.Shortcut{
|
||||
if err != nil {
|
||||
return output.ErrValidation("invalid output path: %s", err)
|
||||
}
|
||||
safePath, err := validate.SafeOutputPath(relPath)
|
||||
if err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
|
||||
finalPath, sizeBytes, err := downloadIMResourceToPath(ctx, runtime, messageId, fileKey, fileType, safePath)
|
||||
finalPath, sizeBytes, err := downloadIMResourceToPath(ctx, runtime, messageId, fileKey, fileType, relPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -109,33 +105,33 @@ func normalizeDownloadOutputPath(fileKey, outputPath string) (string, error) {
|
||||
const defaultIMResourceDownloadTimeout = 120 * time.Second
|
||||
|
||||
var imMimeToExt = map[string]string{
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
"application/pdf": ".pdf",
|
||||
"video/mp4": ".mp4",
|
||||
"video/3gpp": ".3gp",
|
||||
"video/x-msvideo": ".avi",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/ogg": ".ogg",
|
||||
"audio/wav": ".wav",
|
||||
"text/plain": ".txt",
|
||||
"text/html": ".html",
|
||||
"text/css": ".css",
|
||||
"text/csv": ".csv",
|
||||
"application/zip": ".zip",
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
"application/pdf": ".pdf",
|
||||
"video/mp4": ".mp4",
|
||||
"video/3gpp": ".3gp",
|
||||
"video/x-msvideo": ".avi",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/ogg": ".ogg",
|
||||
"audio/wav": ".wav",
|
||||
"text/plain": ".txt",
|
||||
"text/html": ".html",
|
||||
"text/css": ".css",
|
||||
"text/csv": ".csv",
|
||||
"application/zip": ".zip",
|
||||
"application/x-zip-compressed": ".zip",
|
||||
"application/x-rar-compressed": ".rar",
|
||||
"application/json": ".json",
|
||||
"application/xml": ".xml",
|
||||
"application/octet-stream": ".bin",
|
||||
"application/msword": ".doc",
|
||||
"application/json": ".json",
|
||||
"application/xml": ".xml",
|
||||
"application/octet-stream": ".bin",
|
||||
"application/msword": ".doc",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
||||
"application/vnd.ms-excel": ".xls",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
||||
"application/vnd.ms-powerpoint": ".ppt",
|
||||
"application/vnd.ms-excel": ".xls",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
||||
"application/vnd.ms-powerpoint": ".ppt",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
|
||||
}
|
||||
|
||||
@@ -156,8 +152,12 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
|
||||
}
|
||||
defer downloadResp.Body.Close()
|
||||
|
||||
if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
|
||||
return "", 0, output.Errorf(output.ExitInternal, "api_error", "cannot create parent directory: %s", err)
|
||||
if downloadResp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(downloadResp.Body, 4096))
|
||||
if len(body) > 0 {
|
||||
return "", 0, output.ErrNetwork("download failed: HTTP %d: %s", downloadResp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return "", 0, output.ErrNetwork("download failed: HTTP %d", downloadResp.StatusCode)
|
||||
}
|
||||
|
||||
// Auto-detect extension from Content-Type if missing
|
||||
@@ -171,9 +171,19 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
|
||||
}
|
||||
}
|
||||
|
||||
sizeBytes, err := validate.AtomicWriteFromReader(finalPath, downloadResp.Body, 0600)
|
||||
result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
|
||||
ContentType: downloadResp.Header.Get("Content-Type"),
|
||||
ContentLength: downloadResp.ContentLength,
|
||||
}, downloadResp.Body)
|
||||
if err != nil {
|
||||
return "", 0, output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err)
|
||||
return "", 0, output.Errorf(output.ExitInternal, "api_error", "%s",
|
||||
common.WrapSaveError(err, "unsafe output path", "cannot create parent directory", "cannot create file"))
|
||||
}
|
||||
return finalPath, sizeBytes, nil
|
||||
savedPath, resolveErr := runtime.ResolveSavePath(finalPath)
|
||||
if resolveErr != nil {
|
||||
// Save succeeded — file is on disk. Fall back to the relative path
|
||||
// rather than returning an error for a successfully written file.
|
||||
savedPath = finalPath
|
||||
}
|
||||
return savedPath, result.Size(), nil
|
||||
}
|
||||
|
||||
@@ -7,10 +7,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
@@ -98,29 +99,13 @@ var ImMessagesSend = common.Shortcut{
|
||||
videoCoverKey := runtime.Str("video-cover")
|
||||
audioKey := runtime.Str("audio")
|
||||
|
||||
if !isMediaKey(imageKey) {
|
||||
if _, err := validate.SafeLocalFlagPath("--image", imageKey); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
}
|
||||
if !isMediaKey(fileKey) {
|
||||
if _, err := validate.SafeLocalFlagPath("--file", fileKey); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
}
|
||||
if !isMediaKey(videoKey) {
|
||||
if _, err := validate.SafeLocalFlagPath("--video", videoKey); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
}
|
||||
if !isMediaKey(videoCoverKey) {
|
||||
if _, err := validate.SafeLocalFlagPath("--video-cover", videoCoverKey); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
}
|
||||
if !isMediaKey(audioKey) {
|
||||
if _, err := validate.SafeLocalFlagPath("--audio", audioKey); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
fio := runtime.FileIO()
|
||||
for _, mf := range []struct{ flag, val string }{
|
||||
{"--image", imageKey}, {"--file", fileKey}, {"--video", videoKey},
|
||||
{"--video-cover", videoCoverKey}, {"--audio", audioKey},
|
||||
} {
|
||||
if err := validateMediaFlagPath(fio, mf.flag, mf.val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,29 +150,13 @@ var ImMessagesSend = common.Shortcut{
|
||||
videoVal := runtime.Str("video")
|
||||
videoCoverVal := runtime.Str("video-cover")
|
||||
audioVal := runtime.Str("audio")
|
||||
if !isMediaKey(imageVal) {
|
||||
if _, err := validate.SafeLocalFlagPath("--image", imageVal); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
}
|
||||
if !isMediaKey(fileVal) {
|
||||
if _, err := validate.SafeLocalFlagPath("--file", fileVal); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
}
|
||||
if !isMediaKey(videoVal) {
|
||||
if _, err := validate.SafeLocalFlagPath("--video", videoVal); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
}
|
||||
if !isMediaKey(videoCoverVal) {
|
||||
if _, err := validate.SafeLocalFlagPath("--video-cover", videoCoverVal); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
}
|
||||
}
|
||||
if !isMediaKey(audioVal) {
|
||||
if _, err := validate.SafeLocalFlagPath("--audio", audioVal); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
fio := runtime.FileIO()
|
||||
for _, mf := range []struct{ flag, val string }{
|
||||
{"--image", imageVal}, {"--file", fileVal}, {"--video", videoVal},
|
||||
{"--video-cover", videoCoverVal}, {"--audio", audioVal},
|
||||
} {
|
||||
if err := validateMediaFlagPath(fio, mf.flag, mf.val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Resolve content type
|
||||
@@ -239,3 +208,15 @@ var ImMessagesSend = common.Shortcut{
|
||||
func isMediaKey(value string) bool {
|
||||
return strings.HasPrefix(value, "img_") || strings.HasPrefix(value, "file_")
|
||||
}
|
||||
|
||||
// validateMediaFlagPath validates a media flag value as a local file path via FileIO.
|
||||
// Empty values, URLs, and media keys are skipped (not local files).
|
||||
func validateMediaFlagPath(fio fileio.FileIO, flagName, value string) error {
|
||||
if value == "" || strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") || isMediaKey(value) {
|
||||
return nil
|
||||
}
|
||||
if _, err := fio.Stat(value); err != nil && !os.IsNotExist(err) {
|
||||
return output.ErrValidation("%s: %v", flagName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
51
shortcuts/im/validate_media_test.go
Normal file
51
shortcuts/im/validate_media_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
|
||||
func TestValidateMediaFlagPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
os.Chdir(dir)
|
||||
os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("img"), 0644)
|
||||
|
||||
fio := &localfileio.LocalFileIO{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flag string
|
||||
value string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty value skipped", "--image", "", false},
|
||||
{"http URL skipped", "--image", "http://example.com/a.jpg", false},
|
||||
{"https URL skipped", "--file", "https://example.com/b.mp4", false},
|
||||
{"media key skipped", "--image", "img_abc123", false},
|
||||
{"file key skipped", "--file", "file_abc123", false},
|
||||
{"valid local file", "--image", "photo.jpg", false},
|
||||
{"nonexistent file allowed", "--file", "missing.txt", false},
|
||||
{"path traversal rejected", "--image", "../../etc/passwd", true},
|
||||
{"absolute path rejected", "--file", "/etc/passwd", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateMediaFlagPath(fio, tt.flag, tt.value)
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatalf("expected error for %s=%q, got nil", tt.flag, tt.value)
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error for %s=%q: %v", tt.flag, tt.value, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,19 @@ import (
|
||||
"fmt"
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/shortcuts/mail/filecheck"
|
||||
)
|
||||
|
||||
// imgSrcRegexp matches <img ... src="value" ...> and captures the src value.
|
||||
// It handles both single and double quotes.
|
||||
var imgSrcRegexp = regexp.MustCompile(`(?i)<img\s(?:[^>]*?\s)?src\s*=\s*["']([^"']+)["']`)
|
||||
|
||||
var protectedHeaders = map[string]bool{
|
||||
"message-id": true,
|
||||
"mime-version": true,
|
||||
@@ -24,22 +30,32 @@ var protectedHeaders = map[string]bool{
|
||||
"reply-to": true,
|
||||
}
|
||||
|
||||
// bodyChangingOps lists patch operations that modify the HTML body content,
|
||||
// which is the trigger for running local image path resolution.
|
||||
var bodyChangingOps = map[string]bool{
|
||||
"set_body": true,
|
||||
"set_reply_body": true,
|
||||
"replace_body": true,
|
||||
"append_body": true,
|
||||
}
|
||||
|
||||
func Apply(snapshot *DraftSnapshot, patch Patch) error {
|
||||
if err := patch.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
hasBodyChange := false
|
||||
for _, op := range patch.Ops {
|
||||
if err := applyOp(snapshot, op, patch.Options); err != nil {
|
||||
return err
|
||||
}
|
||||
if bodyChangingOps[op.Op] {
|
||||
hasBodyChange = true
|
||||
}
|
||||
}
|
||||
if err := refreshSnapshot(snapshot); err != nil {
|
||||
if err := postProcessInlineImages(snapshot, hasBodyChange); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateInlineCIDAfterApply(snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateOrphanedInlineCIDAfterApply(snapshot)
|
||||
return refreshSnapshot(snapshot)
|
||||
}
|
||||
|
||||
func applyOp(snapshot *DraftSnapshot, op PatchOp, options PatchOptions) error {
|
||||
@@ -523,21 +539,25 @@ func addAttachment(snapshot *DraftSnapshot, path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func addInline(snapshot *DraftSnapshot, path, cid, fileName, contentType string) error {
|
||||
// loadAndAttachInline reads a local image file, validates its format,
|
||||
// creates a MIME inline part, and attaches it to the snapshot's
|
||||
// multipart/related container. If container is non-nil it is reused;
|
||||
// otherwise the container is resolved from the snapshot.
|
||||
func loadAndAttachInline(snapshot *DraftSnapshot, path, cid, fileName string, container *Part) (*Part, error) {
|
||||
safePath, err := validate.SafeInputPath(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inline image %q: %w", path, err)
|
||||
return nil, fmt.Errorf("inline image %q: %w", path, err)
|
||||
}
|
||||
info, err := vfs.Stat(safePath)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, fmt.Errorf("inline image %q: %w", path, err)
|
||||
}
|
||||
if err := checkSnapshotAttachmentLimit(snapshot, info.Size(), nil); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
content, err := vfs.ReadFile(safePath)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, fmt.Errorf("inline image %q: %w", path, err)
|
||||
}
|
||||
name := fileName
|
||||
if strings.TrimSpace(name) == "" {
|
||||
@@ -545,23 +565,30 @@ func addInline(snapshot *DraftSnapshot, path, cid, fileName, contentType string)
|
||||
}
|
||||
detectedCT, err := filecheck.CheckInlineImageFormat(name, content)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, fmt.Errorf("inline image %q: %w", path, err)
|
||||
}
|
||||
inline, err := newInlinePart(path, content, cid, fileName, detectedCT)
|
||||
inline, err := newInlinePart(safePath, content, cid, name, detectedCT)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, fmt.Errorf("inline image %q: %w", path, err)
|
||||
}
|
||||
containerRef := primaryBodyRootRef(&snapshot.Body)
|
||||
if containerRef == nil || *containerRef == nil {
|
||||
return fmt.Errorf("draft has no primary body container")
|
||||
}
|
||||
container, err := ensureInlineContainerRef(containerRef)
|
||||
if err != nil {
|
||||
return err
|
||||
if container == nil {
|
||||
containerRef := primaryBodyRootRef(&snapshot.Body)
|
||||
if containerRef == nil || *containerRef == nil {
|
||||
return nil, fmt.Errorf("draft has no primary body container")
|
||||
}
|
||||
container, err = ensureInlineContainerRef(containerRef)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inline image %q: %w", path, err)
|
||||
}
|
||||
}
|
||||
container.Children = append(container.Children, inline)
|
||||
container.Dirty = true
|
||||
return nil
|
||||
return container, nil
|
||||
}
|
||||
|
||||
func addInline(snapshot *DraftSnapshot, path, cid, fileName, contentType string) error {
|
||||
_, err := loadAndAttachInline(snapshot, path, cid, fileName, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func replaceInline(snapshot *DraftSnapshot, partID, path, cid, fileName, contentType string) error {
|
||||
@@ -605,13 +632,10 @@ func replaceInline(snapshot *DraftSnapshot, partID, path, cid, fileName, content
|
||||
}
|
||||
contentType = detectedCT
|
||||
contentType, mediaParams := normalizedDetectedMediaType(contentType)
|
||||
finalCID := strings.Trim(strings.TrimSpace(cid), "<>")
|
||||
if err := validate.RejectCRLF(finalCID, "inline cid"); err != nil {
|
||||
finalCID := normalizeCID(cid)
|
||||
if err := validateCID(finalCID); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.ContainsAny(finalCID, " \t<>()") {
|
||||
return fmt.Errorf("inline cid %q contains invalid characters (spaces, tabs, angle brackets, or parentheses are not allowed)", finalCID)
|
||||
}
|
||||
if err := validate.RejectCRLF(fileName, "inline filename"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -734,6 +758,33 @@ func findPart(root *Part, partID string) *Part {
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeCID strips a single RFC 2392 angle-bracket wrapper (<...>) from the
|
||||
// CID if present, and trims surrounding whitespace. Unlike strings.Trim, it
|
||||
// only removes a matched pair so that stray brackets like "test<>" are preserved
|
||||
// for validation to reject.
|
||||
func normalizeCID(cid string) string {
|
||||
cid = strings.TrimSpace(cid)
|
||||
if strings.HasPrefix(cid, "<") && strings.HasSuffix(cid, ">") {
|
||||
cid = cid[1 : len(cid)-1]
|
||||
}
|
||||
return cid
|
||||
}
|
||||
|
||||
// validateCID checks that a Content-ID value is non-empty and free of
|
||||
// characters that would break MIME headers or cause ambiguous references.
|
||||
func validateCID(cid string) error {
|
||||
if cid == "" {
|
||||
return fmt.Errorf("inline cid is empty")
|
||||
}
|
||||
if err := validate.RejectCRLF(cid, "inline cid"); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.ContainsAny(cid, " \t<>()") {
|
||||
return fmt.Errorf("inline cid %q contains invalid characters (spaces, tabs, angle brackets, or parentheses are not allowed)", cid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureInlineContainerRef(partRef **Part) (*Part, error) {
|
||||
if partRef == nil || *partRef == nil {
|
||||
return nil, fmt.Errorf("body container is nil")
|
||||
@@ -758,16 +809,10 @@ func newInlinePart(path string, content []byte, cid, fileName, contentType strin
|
||||
}
|
||||
contentType, mediaParams := normalizedDetectedMediaType(contentType)
|
||||
mediaParams["name"] = fileName
|
||||
cid = strings.Trim(strings.TrimSpace(cid), "<>")
|
||||
if cid == "" {
|
||||
return nil, fmt.Errorf("inline cid is empty")
|
||||
}
|
||||
if err := validate.RejectCRLF(cid, "inline cid"); err != nil {
|
||||
cid = normalizeCID(cid)
|
||||
if err := validateCID(cid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.ContainsAny(cid, " \t<>()") {
|
||||
return nil, fmt.Errorf("inline cid %q contains invalid characters (spaces, tabs, angle brackets, or parentheses are not allowed)", cid)
|
||||
}
|
||||
if err := validate.RejectCRLF(fileName, "inline filename"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -863,59 +908,227 @@ func removeHeader(headers *[]Header, name string) {
|
||||
*headers = next
|
||||
}
|
||||
|
||||
// validateInlineCIDAfterApply checks that all CID references in the HTML body
|
||||
// resolve to actual inline MIME parts. This is called after Apply (editing) to
|
||||
// prevent broken CID references, but NOT during Parse (where broken CIDs
|
||||
// should not block opening the draft).
|
||||
func validateInlineCIDAfterApply(snapshot *DraftSnapshot) error {
|
||||
htmlPart := findPrimaryBodyPart(snapshot.Body, "text/html")
|
||||
if htmlPart == nil {
|
||||
return nil
|
||||
// uriSchemeRegexp matches a URI scheme (RFC 3986: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":").
|
||||
var uriSchemeRegexp = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.\-]*:`)
|
||||
|
||||
// isLocalFileSrc returns true if src is a local file path.
|
||||
// Any URI with a scheme (http:, cid:, data:, ftp:, blob:, file:, etc.)
|
||||
// or protocol-relative URL (//host/...) is rejected.
|
||||
func isLocalFileSrc(src string) bool {
|
||||
trimmed := strings.TrimSpace(src)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
refs := extractCIDRefs(string(htmlPart.Body))
|
||||
if strings.HasPrefix(trimmed, "//") {
|
||||
return false
|
||||
}
|
||||
return !uriSchemeRegexp.MatchString(trimmed)
|
||||
}
|
||||
|
||||
// generateCID returns a random UUID string suitable for use as a Content-ID.
|
||||
// UUIDs contain only [0-9a-f-], which is inherently RFC-safe and unique,
|
||||
// avoiding all filename-derived encoding/collision issues.
|
||||
func generateCID() (string, error) {
|
||||
id, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate CID: %w", err)
|
||||
}
|
||||
return id.String(), nil
|
||||
}
|
||||
|
||||
// LocalImageRef represents a local image found in an HTML body that needs
|
||||
// to be embedded as an inline MIME part.
|
||||
type LocalImageRef struct {
|
||||
FilePath string // original src value from the HTML
|
||||
CID string // generated Content-ID
|
||||
}
|
||||
|
||||
// ResolveLocalImagePaths scans HTML for <img src="local/path"> references,
|
||||
// validates each path, generates CIDs, and returns the modified HTML with
|
||||
// cid: URIs plus the list of local image references to embed as inline parts.
|
||||
// This function handles only the HTML transformation; callers are responsible
|
||||
// for embedding the actual file data (e.g., via emlbuilder.AddFileInline).
|
||||
func ResolveLocalImagePaths(html string) (string, []LocalImageRef, error) {
|
||||
matches := imgSrcRegexp.FindAllStringSubmatchIndex(html, -1)
|
||||
if len(matches) == 0 {
|
||||
return html, nil, nil
|
||||
}
|
||||
|
||||
// Cache resolved paths so the same file is only attached once.
|
||||
pathToCID := make(map[string]string)
|
||||
var refs []LocalImageRef
|
||||
|
||||
// Iterate in reverse so that index offsets remain valid after replacement.
|
||||
for i := len(matches) - 1; i >= 0; i-- {
|
||||
srcStart, srcEnd := matches[i][2], matches[i][3]
|
||||
src := html[srcStart:srcEnd]
|
||||
if !isLocalFileSrc(src) {
|
||||
continue
|
||||
}
|
||||
|
||||
resolvedPath, err := validate.SafeInputPath(src)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("inline image %q: %w", src, err)
|
||||
}
|
||||
|
||||
cid, ok := pathToCID[resolvedPath]
|
||||
if !ok {
|
||||
cid, err = generateCID()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
pathToCID[resolvedPath] = cid
|
||||
refs = append(refs, LocalImageRef{FilePath: src, CID: cid})
|
||||
}
|
||||
|
||||
html = html[:srcStart] + "cid:" + cid + html[srcEnd:]
|
||||
}
|
||||
|
||||
return html, refs, nil
|
||||
}
|
||||
|
||||
// resolveLocalImgSrc scans HTML for <img src="local/path"> references,
|
||||
// creates MIME inline parts for each local file, and returns the HTML
|
||||
// with those src attributes replaced by cid: URIs.
|
||||
func resolveLocalImgSrc(snapshot *DraftSnapshot, html string) (string, error) {
|
||||
resolved, refs, err := ResolveLocalImagePaths(html)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var container *Part
|
||||
for _, ref := range refs {
|
||||
fileName := filepath.Base(ref.FilePath)
|
||||
container, err = loadAndAttachInline(snapshot, ref.FilePath, ref.CID, fileName, container)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// removeOrphanedInlineParts removes inline MIME parts whose ContentID
|
||||
// is not in the referencedCIDs set. It searches multipart/related and
|
||||
// multipart/mixed containers, because some servers flatten the MIME tree
|
||||
// and place inline parts directly under multipart/mixed.
|
||||
func removeOrphanedInlineParts(root *Part, referencedCIDs map[string]bool) {
|
||||
if root == nil {
|
||||
return
|
||||
}
|
||||
isRelated := strings.EqualFold(root.MediaType, "multipart/related")
|
||||
isMixed := strings.EqualFold(root.MediaType, "multipart/mixed")
|
||||
if !isRelated && !isMixed {
|
||||
for _, child := range root.Children {
|
||||
removeOrphanedInlineParts(child, referencedCIDs)
|
||||
}
|
||||
return
|
||||
}
|
||||
kept := make([]*Part, 0, len(root.Children))
|
||||
for _, child := range root.Children {
|
||||
if child == nil {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(child.ContentDisposition, "inline") && child.ContentID != "" {
|
||||
if !referencedCIDs[strings.ToLower(child.ContentID)] {
|
||||
root.Dirty = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
kept = append(kept, child)
|
||||
}
|
||||
root.Children = kept
|
||||
for _, child := range root.Children {
|
||||
removeOrphanedInlineParts(child, referencedCIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateCIDReferences checks that every cid: reference in the HTML body has
|
||||
// a matching entry in availableCIDs. Returns an error for the first missing CID.
|
||||
// Both sides are compared case-insensitively.
|
||||
func ValidateCIDReferences(html string, availableCIDs []string) error {
|
||||
refs := extractCIDRefs(html)
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
cids := make(map[string]bool)
|
||||
for _, part := range flattenParts(snapshot.Body) {
|
||||
if part == nil || part.ContentID == "" {
|
||||
continue
|
||||
}
|
||||
cids[strings.ToLower(part.ContentID)] = true
|
||||
cidSet := make(map[string]bool, len(availableCIDs))
|
||||
for _, cid := range availableCIDs {
|
||||
cidSet[strings.ToLower(cid)] = true
|
||||
}
|
||||
for _, ref := range refs {
|
||||
if !cids[strings.ToLower(ref)] {
|
||||
if !cidSet[strings.ToLower(ref)] {
|
||||
return fmt.Errorf("html body references missing inline cid %q", ref)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateOrphanedInlineCIDAfterApply checks the reverse direction: every
|
||||
// inline MIME part with a ContentID must be referenced by the HTML body.
|
||||
// An orphaned inline part (CID exists but HTML has no <img src="cid:...">) will
|
||||
// be displayed as an unexpected attachment by most mail clients.
|
||||
func validateOrphanedInlineCIDAfterApply(snapshot *DraftSnapshot) error {
|
||||
htmlPart := findPrimaryBodyPart(snapshot.Body, "text/html")
|
||||
if htmlPart == nil {
|
||||
return nil
|
||||
}
|
||||
refs := extractCIDRefs(string(htmlPart.Body))
|
||||
// FindOrphanedCIDs returns CIDs from addedCIDs that are not referenced in the
|
||||
// HTML body via <img src="cid:...">. These would appear as unexpected
|
||||
// attachments when the email is sent.
|
||||
func FindOrphanedCIDs(html string, addedCIDs []string) []string {
|
||||
refs := extractCIDRefs(html)
|
||||
refSet := make(map[string]bool, len(refs))
|
||||
for _, ref := range refs {
|
||||
refSet[strings.ToLower(ref)] = true
|
||||
}
|
||||
var orphaned []string
|
||||
for _, cid := range addedCIDs {
|
||||
if !refSet[strings.ToLower(cid)] {
|
||||
orphaned = append(orphaned, cid)
|
||||
}
|
||||
}
|
||||
return orphaned
|
||||
}
|
||||
|
||||
// postProcessInlineImages is the unified post-processing step that:
|
||||
// 1. Resolves local <img src="./path"> to inline CID parts (only when resolveLocal is true).
|
||||
// 2. Validates all CID references in HTML resolve to MIME parts.
|
||||
// 3. Removes orphaned inline MIME parts no longer referenced by HTML.
|
||||
//
|
||||
// resolveLocal should be true only when a body-changing op was applied;
|
||||
// metadata-only edits skip local path resolution to avoid disk I/O side effects.
|
||||
//
|
||||
// NOTE: The EML builder path has an equivalent function processInlineImagesForEML
|
||||
// in shortcuts/mail/helpers.go. When adding new validation or processing logic here,
|
||||
// update processInlineImagesForEML as well (or extract a shared function).
|
||||
func postProcessInlineImages(snapshot *DraftSnapshot, resolveLocal bool) error {
|
||||
htmlPart := findPrimaryBodyPart(snapshot.Body, "text/html")
|
||||
if htmlPart == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
origHTML := string(htmlPart.Body)
|
||||
html := origHTML
|
||||
if resolveLocal {
|
||||
var err error
|
||||
html, err = resolveLocalImgSrc(snapshot, origHTML)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if html != origHTML {
|
||||
htmlPart.Body = []byte(html)
|
||||
htmlPart.Dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all CIDs present as MIME parts.
|
||||
var cidParts []string
|
||||
for _, part := range flattenParts(snapshot.Body) {
|
||||
if part == nil || part.ContentID == "" {
|
||||
continue
|
||||
}
|
||||
if !refSet[strings.ToLower(part.ContentID)] {
|
||||
orphaned = append(orphaned, part.ContentID)
|
||||
if part != nil && part.ContentID != "" {
|
||||
cidParts = append(cidParts, part.ContentID)
|
||||
}
|
||||
}
|
||||
if len(orphaned) > 0 {
|
||||
return fmt.Errorf("inline MIME parts have no <img> reference in the HTML body and will appear as unexpected attachments: orphaned cids %v; if you used set_body, make sure the new body preserves all existing cid:... references", orphaned)
|
||||
|
||||
if err := ValidateCIDReferences(html, cidParts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
refs := extractCIDRefs(html)
|
||||
refSet := make(map[string]bool, len(refs))
|
||||
for _, ref := range refs {
|
||||
refSet[strings.ToLower(ref)] = true
|
||||
}
|
||||
removeOrphanedInlineParts(snapshot.Body, refSet)
|
||||
return nil
|
||||
}
|
||||
|
||||
979
shortcuts/mail/draft/patch_inline_resolve_test.go
Normal file
979
shortcuts/mail/draft/patch_inline_resolve_test.go
Normal file
@@ -0,0 +1,979 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package draft
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveLocalImgSrc — basic auto-resolve
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestResolveLocalImgSrcBasic(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
os.WriteFile("logo.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
|
||||
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Test
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<div>Hello<img src="./logo.png" /></div>
|
||||
`)
|
||||
err := Apply(snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "set_body", Value: `<div>Hello<img src="./logo.png" /></div>`}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply() error = %v", err)
|
||||
}
|
||||
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
|
||||
if htmlPart == nil {
|
||||
t.Fatal("HTML part not found")
|
||||
}
|
||||
body := string(htmlPart.Body)
|
||||
if strings.Contains(body, "./logo.png") {
|
||||
t.Fatal("local path should have been replaced")
|
||||
}
|
||||
// Extract the generated CID from the HTML body.
|
||||
cidRe := regexp.MustCompile(`src="cid:([^"]+)"`)
|
||||
m := cidRe.FindStringSubmatch(body)
|
||||
if m == nil {
|
||||
t.Fatalf("expected src to contain a cid: reference, got: %s", body)
|
||||
}
|
||||
cid := m[1]
|
||||
// Verify MIME inline part was created with the matching CID.
|
||||
found := false
|
||||
for _, part := range flattenParts(snapshot.Body) {
|
||||
if part != nil && part.ContentID == cid {
|
||||
found = true
|
||||
if part.MediaType != "image/png" {
|
||||
t.Fatalf("expected image/png, got %q", part.MediaType)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected inline MIME part with CID %q to be created", cid)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveLocalImgSrc — multiple images
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestResolveLocalImgSrcMultipleImages(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
os.WriteFile("a.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
|
||||
os.WriteFile("b.jpg", []byte{0xFF, 0xD8, 0xFF, 0xE0}, 0o644)
|
||||
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Test
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<div>empty</div>
|
||||
`)
|
||||
err := Apply(snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "set_body", Value: `<div><img src="./a.png" /><img src="./b.jpg" /></div>`}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply() error = %v", err)
|
||||
}
|
||||
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
|
||||
body := string(htmlPart.Body)
|
||||
cidRe := regexp.MustCompile(`src="cid:([^"]+)"`)
|
||||
matches := cidRe.FindAllStringSubmatch(body, -1)
|
||||
if len(matches) != 2 {
|
||||
t.Fatalf("expected 2 cid: references, got %d in: %s", len(matches), body)
|
||||
}
|
||||
if matches[0][1] == matches[1][1] {
|
||||
t.Fatalf("expected different CIDs for different files, both got: %s", matches[0][1])
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveLocalImgSrc — skips cid/http/data URIs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestResolveLocalImgSrcSkipsNonLocalSrc(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Test
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/related; boundary="rel"
|
||||
|
||||
--rel
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<div><img src="cid:existing" /><img src="https://example.com/img.png" /><img src="data:image/png;base64,abc" /></div>
|
||||
--rel
|
||||
Content-Type: image/png; name=existing.png
|
||||
Content-Disposition: inline; filename=existing.png
|
||||
Content-ID: <existing>
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
cG5n
|
||||
--rel--
|
||||
`)
|
||||
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
|
||||
originalBody := string(htmlPart.Body)
|
||||
|
||||
err := Apply(snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "set_body", Value: originalBody}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply() error = %v", err)
|
||||
}
|
||||
htmlPart = findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
|
||||
if string(htmlPart.Body) != originalBody {
|
||||
t.Fatalf("body should be unchanged, got: %s", string(htmlPart.Body))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveLocalImgSrc — duplicate file names get unique CIDs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestResolveLocalImgSrcDuplicateCID(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
os.MkdirAll("sub", 0o755)
|
||||
os.WriteFile("logo.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
|
||||
os.WriteFile("sub/logo.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
|
||||
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Test
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<div>empty</div>
|
||||
`)
|
||||
err := Apply(snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "set_body", Value: `<div><img src="./logo.png" /><img src="./sub/logo.png" /></div>`}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply() error = %v", err)
|
||||
}
|
||||
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
|
||||
body := string(htmlPart.Body)
|
||||
cidRe := regexp.MustCompile(`src="cid:([^"]+)"`)
|
||||
matches := cidRe.FindAllStringSubmatch(body, -1)
|
||||
if len(matches) != 2 {
|
||||
t.Fatalf("expected 2 cid: references, got %d in: %s", len(matches), body)
|
||||
}
|
||||
if matches[0][1] == matches[1][1] {
|
||||
t.Fatalf("expected different CIDs for different files, both got: %s", matches[0][1])
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveLocalImgSrc — same file referenced multiple times reuses one CID
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestResolveLocalImgSrcSameFileReused(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
os.WriteFile("logo.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
|
||||
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Test
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<div>empty</div>
|
||||
`)
|
||||
err := Apply(snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "set_body", Value: `<div><img src="./logo.png" /><p>text</p><img src="./logo.png" /></div>`}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply() error = %v", err)
|
||||
}
|
||||
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
|
||||
body := string(htmlPart.Body)
|
||||
// Both references should resolve to the same CID.
|
||||
cidRe := regexp.MustCompile(`src="cid:([^"]+)"`)
|
||||
matches := cidRe.FindAllStringSubmatch(body, -1)
|
||||
if len(matches) != 2 {
|
||||
t.Fatalf("expected 2 cid: references, got %d in: %s", len(matches), body)
|
||||
}
|
||||
if matches[0][1] != matches[1][1] {
|
||||
t.Fatalf("expected same CID reused, got %q and %q", matches[0][1], matches[1][1])
|
||||
}
|
||||
// Count inline MIME parts — should be exactly 1.
|
||||
var count int
|
||||
for _, part := range flattenParts(snapshot.Body) {
|
||||
if part != nil && strings.EqualFold(part.ContentDisposition, "inline") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected 1 inline part (reused), got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveLocalImgSrc — non-image format rejected
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestResolveLocalImgSrcRejectsNonImage(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
os.WriteFile("doc.txt", []byte("not an image"), 0o644)
|
||||
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Test
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<div>empty</div>
|
||||
`)
|
||||
err := Apply(snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "set_body", Value: `<div><img src="./doc.txt" /></div>`}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-image file")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// orphan cleanup — delete inline image by removing <img> from body
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestOrphanCleanupOnImgRemoval(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Inline
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/related; boundary="rel"
|
||||
|
||||
--rel
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<div>hello<img src="cid:logo" /></div>
|
||||
--rel
|
||||
Content-Type: image/png; name=logo.png
|
||||
Content-Disposition: inline; filename=logo.png
|
||||
Content-ID: <logo>
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
cG5n
|
||||
--rel--
|
||||
`)
|
||||
// Remove the <img> tag from body.
|
||||
err := Apply(snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "set_body", Value: "<div>hello</div>"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply() error = %v", err)
|
||||
}
|
||||
for _, part := range flattenParts(snapshot.Body) {
|
||||
if part != nil && part.ContentID == "logo" {
|
||||
t.Fatal("expected orphaned inline part 'logo' to be removed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// orphan cleanup — replace inline image
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestOrphanCleanupOnImgReplace(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
os.WriteFile("new.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
|
||||
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Inline
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/related; boundary="rel"
|
||||
|
||||
--rel
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<div><img src="cid:old" /></div>
|
||||
--rel
|
||||
Content-Type: image/png; name=old.png
|
||||
Content-Disposition: inline; filename=old.png
|
||||
Content-ID: <old>
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
cG5n
|
||||
--rel--
|
||||
`)
|
||||
// Replace old image reference with a new local file.
|
||||
err := Apply(snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "set_body", Value: `<div><img src="./new.png" /></div>`}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply() error = %v", err)
|
||||
}
|
||||
var foundOld bool
|
||||
var newInlineCount int
|
||||
for _, part := range flattenParts(snapshot.Body) {
|
||||
if part == nil {
|
||||
continue
|
||||
}
|
||||
if part.ContentID == "old" {
|
||||
foundOld = true
|
||||
}
|
||||
if strings.EqualFold(part.ContentDisposition, "inline") && part.ContentID != "" && part.ContentID != "old" {
|
||||
newInlineCount++
|
||||
}
|
||||
}
|
||||
if foundOld {
|
||||
t.Fatal("expected old inline part to be removed")
|
||||
}
|
||||
if newInlineCount != 1 {
|
||||
t.Fatalf("expected 1 new inline part, got %d", newInlineCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set_reply_body — local path resolved, quote block preserved
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSetReplyBodyResolvesLocalImgSrc(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
os.WriteFile("photo.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
|
||||
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Re: Hello
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<div>original reply</div><div class="history-quote-wrapper"><div>quoted text</div></div>
|
||||
`)
|
||||
err := Apply(snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "set_reply_body", Value: `<div>new reply<img src="./photo.png" /></div>`}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply() error = %v", err)
|
||||
}
|
||||
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
|
||||
if htmlPart == nil {
|
||||
t.Fatal("HTML part not found")
|
||||
}
|
||||
body := string(htmlPart.Body)
|
||||
if strings.Contains(body, "./photo.png") {
|
||||
t.Fatal("local path should have been replaced")
|
||||
}
|
||||
cidRe := regexp.MustCompile(`src="cid:([^"]+)"`)
|
||||
m := cidRe.FindStringSubmatch(body)
|
||||
if m == nil {
|
||||
t.Fatalf("expected cid: reference in body, got: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "history-quote-wrapper") {
|
||||
t.Fatalf("expected quote block preserved, got: %s", body)
|
||||
}
|
||||
found := false
|
||||
for _, part := range flattenParts(snapshot.Body) {
|
||||
if part != nil && part.ContentID == m[1] {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected inline MIME part with CID %q to be created", m[1])
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mixed usage — add_inline + local path in body
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMixedAddInlineAndLocalPath(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
os.WriteFile("a.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
|
||||
os.WriteFile("b.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
|
||||
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Test
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<div>empty</div>
|
||||
`)
|
||||
err := Apply(snapshot, Patch{
|
||||
Ops: []PatchOp{
|
||||
{Op: "add_inline", Path: "a.png", CID: "a"},
|
||||
{Op: "set_body", Value: `<div><img src="cid:a" /><img src="./b.png" /></div>`},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply() error = %v", err)
|
||||
}
|
||||
var foundA bool
|
||||
var autoResolvedCount int
|
||||
for _, part := range flattenParts(snapshot.Body) {
|
||||
if part == nil {
|
||||
continue
|
||||
}
|
||||
if part.ContentID == "a" {
|
||||
foundA = true
|
||||
} else if strings.EqualFold(part.ContentDisposition, "inline") && part.ContentID != "" {
|
||||
autoResolvedCount++
|
||||
}
|
||||
}
|
||||
if !foundA {
|
||||
t.Fatal("expected inline part 'a' from add_inline")
|
||||
}
|
||||
if autoResolvedCount != 1 {
|
||||
t.Fatalf("expected 1 auto-resolved inline part for b.png, got %d", autoResolvedCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// conflict: add_inline same file + body local path → redundant part cleaned
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestAddInlineSameFileAsLocalPath(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
os.WriteFile("logo.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
|
||||
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Test
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<div>empty</div>
|
||||
`)
|
||||
// add_inline creates CID "logo", but body uses local path instead of cid:logo.
|
||||
// resolve generates a UUID CID, orphan cleanup removes the unused "logo".
|
||||
err := Apply(snapshot, Patch{
|
||||
Ops: []PatchOp{
|
||||
{Op: "add_inline", Path: "logo.png", CID: "logo"},
|
||||
{Op: "set_body", Value: `<div><img src="./logo.png" /></div>`},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply() error = %v", err)
|
||||
}
|
||||
// The explicitly added "logo" CID is orphaned (not referenced in HTML)
|
||||
// and should be auto-removed. Only the auto-generated CID remains.
|
||||
var foundLogo bool
|
||||
var count int
|
||||
for _, part := range flattenParts(snapshot.Body) {
|
||||
if part != nil && strings.EqualFold(part.ContentDisposition, "inline") {
|
||||
count++
|
||||
if part.ContentID == "logo" {
|
||||
foundLogo = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if foundLogo {
|
||||
t.Fatal("expected orphaned 'logo' inline part to be removed")
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected 1 inline part after orphan cleanup, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// conflict: remove_inline but body still references its CID → error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRemoveInlineButBodyStillReferencesCID(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Inline
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/related; boundary="rel"
|
||||
|
||||
--rel
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<div><img src="cid:logo" /></div>
|
||||
--rel
|
||||
Content-Type: image/png; name=logo.png
|
||||
Content-Disposition: inline; filename=logo.png
|
||||
Content-ID: <logo>
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
cG5n
|
||||
--rel--
|
||||
`)
|
||||
// remove_inline removes the MIME part, but set_body still references cid:logo.
|
||||
err := Apply(snapshot, Patch{
|
||||
Ops: []PatchOp{
|
||||
{Op: "remove_inline", Target: AttachmentTarget{CID: "logo"}},
|
||||
{Op: "set_body", Value: `<div><img src="cid:logo" /></div>`},
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "missing inline cid") {
|
||||
t.Fatalf("expected missing cid error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// conflict: remove_inline + body replaces with local path → works
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRemoveInlineAndReplaceWithLocalPath(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
os.WriteFile("new.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
|
||||
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Inline
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/related; boundary="rel"
|
||||
|
||||
--rel
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<div><img src="cid:old" /></div>
|
||||
--rel
|
||||
Content-Type: image/png; name=old.png
|
||||
Content-Disposition: inline; filename=old.png
|
||||
Content-ID: <old>
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
cG5n
|
||||
--rel--
|
||||
`)
|
||||
err := Apply(snapshot, Patch{
|
||||
Ops: []PatchOp{
|
||||
{Op: "remove_inline", Target: AttachmentTarget{CID: "old"}},
|
||||
{Op: "set_body", Value: `<div><img src="./new.png" /></div>`},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply() error = %v", err)
|
||||
}
|
||||
var foundOld bool
|
||||
var newInlineCount int
|
||||
for _, part := range flattenParts(snapshot.Body) {
|
||||
if part == nil {
|
||||
continue
|
||||
}
|
||||
if part.ContentID == "old" {
|
||||
foundOld = true
|
||||
}
|
||||
if strings.EqualFold(part.ContentDisposition, "inline") && part.ContentID != "" && part.ContentID != "old" {
|
||||
newInlineCount++
|
||||
}
|
||||
}
|
||||
if foundOld {
|
||||
t.Fatal("expected old inline part to be removed")
|
||||
}
|
||||
if newInlineCount != 1 {
|
||||
t.Fatalf("expected 1 new inline part from local path resolve, got %d", newInlineCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// no HTML body — text/plain only draft
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestResolveLocalImgSrcNoHTMLBody(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Plain
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
|
||||
Just plain text.
|
||||
`)
|
||||
err := Apply(snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "set_body", Value: "Updated plain text."}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply() error = %v", err)
|
||||
}
|
||||
textPart := findPrimaryBodyPart(snapshot.Body, "text/plain")
|
||||
if textPart == nil {
|
||||
t.Fatal("text/plain part not found")
|
||||
}
|
||||
if got := string(textPart.Body); got != "Updated plain text." {
|
||||
t.Fatalf("text/plain body = %q, want %q", got, "Updated plain text.")
|
||||
}
|
||||
for _, part := range flattenParts(snapshot.Body) {
|
||||
if part != nil && strings.EqualFold(part.ContentDisposition, "inline") && part.ContentID != "" {
|
||||
t.Fatalf("unexpected inline part with CID %q in text-only draft", part.ContentID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// regression: HTML body with Content-ID must not be removed by orphan cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestOrphanCleanupPreservesHTMLBodyWithContentID(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Test
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/related; boundary="rel"
|
||||
|
||||
--rel
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-ID: <body-part>
|
||||
|
||||
<div>hello world</div>
|
||||
--rel
|
||||
Content-Type: image/png; name=logo.png
|
||||
Content-Disposition: inline; filename=logo.png
|
||||
Content-ID: <logo>
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
cG5n
|
||||
--rel--
|
||||
`)
|
||||
// A metadata-only edit should not destroy the HTML body part even though
|
||||
// its Content-ID is not referenced by any <img src="cid:...">.
|
||||
err := Apply(snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "set_subject", Value: "Updated subject"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply() error = %v", err)
|
||||
}
|
||||
htmlPart := findPrimaryBodyPart(snapshot.Body, "text/html")
|
||||
if htmlPart == nil {
|
||||
t.Fatal("HTML body part was deleted by orphan cleanup")
|
||||
}
|
||||
if !strings.Contains(string(htmlPart.Body), "hello world") {
|
||||
t.Fatalf("HTML body content changed unexpectedly: %s", string(htmlPart.Body))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helper unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIsLocalFileSrc(t *testing.T) {
|
||||
tests := []struct {
|
||||
src string
|
||||
want bool
|
||||
}{
|
||||
{"./logo.png", true},
|
||||
{"../images/logo.png", true},
|
||||
{"logo.png", true},
|
||||
{"/absolute/path/logo.png", true},
|
||||
{`C:\images\logo.png`, false},
|
||||
{"C:/images/logo.png", false},
|
||||
{`c:\path\file.png`, false},
|
||||
{"cid:logo", false},
|
||||
{"CID:logo", false},
|
||||
{"http://example.com/img.png", false},
|
||||
{"https://example.com/img.png", false},
|
||||
{"data:image/png;base64,abc", false},
|
||||
{"//cdn.example.com/a.png", false},
|
||||
{"blob:https://example.com/uuid", false},
|
||||
{"ftp://example.com/file.png", false},
|
||||
{"file:///local/file.png", false},
|
||||
{"mailto:test@example.com", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := isLocalFileSrc(tt.src); got != tt.want {
|
||||
t.Errorf("isLocalFileSrc(%q) = %v, want %v", tt.src, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCID(t *testing.T) {
|
||||
seen := make(map[string]bool)
|
||||
for i := 0; i < 100; i++ {
|
||||
cid, err := generateCID()
|
||||
if err != nil {
|
||||
t.Fatalf("generateCID() error = %v", err)
|
||||
}
|
||||
if cid == "" {
|
||||
t.Fatal("generateCID() returned empty string")
|
||||
}
|
||||
if strings.ContainsAny(cid, " \t\r\n<>()") {
|
||||
t.Fatalf("generateCID() returned CID with invalid characters: %q", cid)
|
||||
}
|
||||
if seen[cid] {
|
||||
t.Fatalf("generateCID() returned duplicate CID: %q", cid)
|
||||
}
|
||||
seen[cid] = true
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// imgSrcRegexp — must not match data-src or similar attribute names
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestImgSrcRegexpSkipsDataSrc(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
html string
|
||||
want string // expected captured src value, empty if no match
|
||||
}{
|
||||
{
|
||||
name: "plain src",
|
||||
html: `<img src="./logo.png" />`,
|
||||
want: "./logo.png",
|
||||
},
|
||||
{
|
||||
name: "src with alt before",
|
||||
html: `<img alt="pic" src="./logo.png" />`,
|
||||
want: "./logo.png",
|
||||
},
|
||||
{
|
||||
name: "data-src before real src",
|
||||
html: `<img data-src="lazy.png" src="./logo.png" />`,
|
||||
want: "./logo.png",
|
||||
},
|
||||
{
|
||||
name: "only data-src, no src",
|
||||
html: `<img data-src="lazy.png" />`,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "x-src before real src",
|
||||
html: `<img x-src="other.png" src="./real.png" />`,
|
||||
want: "./real.png",
|
||||
},
|
||||
{
|
||||
name: "single-quoted src",
|
||||
html: `<img src='./logo.png' />`,
|
||||
want: "./logo.png",
|
||||
},
|
||||
{
|
||||
name: "multiple spaces before src",
|
||||
html: `<img src="./logo.png" />`,
|
||||
want: "./logo.png",
|
||||
},
|
||||
{
|
||||
name: "newline before src",
|
||||
html: "<img\nsrc=\"./logo.png\" />",
|
||||
want: "./logo.png",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
matches := imgSrcRegexp.FindStringSubmatch(tt.html)
|
||||
got := ""
|
||||
if len(matches) > 1 {
|
||||
got = matches[1]
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("imgSrcRegexp on %q: got %q, want %q", tt.html, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ResolveLocalImagePaths — exported function for EML build paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestResolveLocalImagePathsBasic(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
os.WriteFile("photo.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
|
||||
|
||||
html := `<div>Hello<img src="./photo.png" /></div>`
|
||||
resolved, refs, err := ResolveLocalImagePaths(html)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveLocalImagePaths() error = %v", err)
|
||||
}
|
||||
if strings.Contains(resolved, "./photo.png") {
|
||||
t.Fatal("local path should have been replaced")
|
||||
}
|
||||
if len(refs) != 1 {
|
||||
t.Fatalf("expected 1 ref, got %d", len(refs))
|
||||
}
|
||||
if refs[0].FilePath != "./photo.png" {
|
||||
t.Errorf("expected FilePath ./photo.png, got %q", refs[0].FilePath)
|
||||
}
|
||||
if !strings.Contains(resolved, "cid:"+refs[0].CID) {
|
||||
t.Fatalf("expected resolved HTML to contain cid:%s", refs[0].CID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLocalImagePathsSkipsRemoteURLs(t *testing.T) {
|
||||
html := `<div><img src="https://example.com/img.png" /></div>`
|
||||
resolved, refs, err := ResolveLocalImagePaths(html)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveLocalImagePaths() error = %v", err)
|
||||
}
|
||||
if resolved != html {
|
||||
t.Fatal("expected unchanged HTML for remote URLs")
|
||||
}
|
||||
if len(refs) != 0 {
|
||||
t.Fatalf("expected 0 refs, got %d", len(refs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLocalImagePathsDeduplicatesSameFile(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
os.WriteFile("icon.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
|
||||
|
||||
html := `<img src="./icon.png" /><img src="./icon.png" />`
|
||||
_, refs, err := ResolveLocalImagePaths(html)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveLocalImagePaths() error = %v", err)
|
||||
}
|
||||
if len(refs) != 1 {
|
||||
t.Fatalf("same file should produce 1 ref, got %d", len(refs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLocalImagePathsNoImages(t *testing.T) {
|
||||
html := "no html images at all"
|
||||
resolved, refs, err := ResolveLocalImagePaths(html)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resolved != html {
|
||||
t.Fatal("expected unchanged text")
|
||||
}
|
||||
if len(refs) != 0 {
|
||||
t.Fatalf("expected 0 refs, got %d", len(refs))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// newInlinePart — rejects CIDs with spaces or other invalid characters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestNewInlinePartRejectsInvalidCIDChars(t *testing.T) {
|
||||
content := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
|
||||
for _, bad := range []string{"my logo", "a\tb", "cid<x>", "cid(x)", "cid\r\nx", "test<>", "<>bad"} {
|
||||
_, err := newInlinePart("test.png", content, bad, "test.png", "image/png")
|
||||
if err == nil {
|
||||
t.Errorf("expected error for CID %q, got nil", bad)
|
||||
}
|
||||
}
|
||||
// Valid CIDs should pass (including RFC <...> wrapper which gets unwrapped).
|
||||
for _, good := range []string{"logo", "my-logo", "img_01", "photo.2", "<wrapped>"} {
|
||||
_, err := newInlinePart("test.png", content, good, "test.png", "image/png")
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error for CID %q: %v", good, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regression: orphaned inline under multipart/mixed (not multipart/related)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestSetBodyReplacesOrphanedInlineUnderMixed reproduces the bug where the
|
||||
// server returns a draft with an inline part as a direct child of
|
||||
// multipart/mixed (not wrapped in multipart/related). When set_body replaces
|
||||
// the HTML with a local <img src>, postProcessInlineImages must remove the
|
||||
// old inline part even though it lives under multipart/mixed.
|
||||
func TestSetBodyReplacesOrphanedInlineUnderMixed(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
os.WriteFile("Peter1.jpeg", []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'}, 0o644)
|
||||
|
||||
// Simulate a server-returned draft where the inline part is a direct
|
||||
// child of multipart/mixed (no multipart/related wrapper).
|
||||
snapshot := mustParseFixtureDraft(t, "Subject: Test\r\n"+
|
||||
"From: alice@example.com\r\n"+
|
||||
"MIME-Version: 1.0\r\n"+
|
||||
"Content-Type: multipart/mixed; boundary=outer\r\n"+
|
||||
"\r\n"+
|
||||
"--outer\r\n"+
|
||||
"Content-Type: text/html; charset=UTF-8\r\n"+
|
||||
"\r\n"+
|
||||
"<p>111<img src=\"cid:peter1-inline\"></p><p>222</p>\r\n"+
|
||||
"--outer\r\n"+
|
||||
"Content-Type: image/jpeg; name=\"Peter1.jpeg\"\r\n"+
|
||||
"Content-Disposition: inline; filename=\"Peter1.jpeg\"\r\n"+
|
||||
"Content-ID: <peter1-inline>\r\n"+
|
||||
"Content-Transfer-Encoding: base64\r\n"+
|
||||
"\r\n"+
|
||||
"/9j/4AAQ\r\n"+
|
||||
"--outer--\r\n")
|
||||
|
||||
// Verify the old inline part exists before patching.
|
||||
oldInlineFound := false
|
||||
for _, part := range flattenParts(snapshot.Body) {
|
||||
if part != nil && part.ContentID == "peter1-inline" {
|
||||
oldInlineFound = true
|
||||
}
|
||||
}
|
||||
if !oldInlineFound {
|
||||
t.Fatal("expected old inline part with CID 'peter1-inline' in parsed draft")
|
||||
}
|
||||
|
||||
// Apply set_body with a local image path (triggers auto-resolve).
|
||||
err := Apply(snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "set_body", Value: `<p>111<img src="./Peter1.jpeg" /></p><p>222</p>`}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply() error = %v", err)
|
||||
}
|
||||
|
||||
// After apply, the HTML should reference a UUID CID, not peter1-inline.
|
||||
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
|
||||
if htmlPart == nil {
|
||||
t.Fatal("HTML part not found after apply")
|
||||
}
|
||||
body := string(htmlPart.Body)
|
||||
if strings.Contains(body, "peter1-inline") {
|
||||
t.Fatalf("HTML should not reference old CID 'peter1-inline', got: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "./Peter1.jpeg") {
|
||||
t.Fatal("local path should have been replaced with cid: reference")
|
||||
}
|
||||
|
||||
// Extract the new CID from HTML.
|
||||
cidRe := regexp.MustCompile(`src="cid:([^"]+)"`)
|
||||
m := cidRe.FindStringSubmatch(body)
|
||||
if m == nil {
|
||||
t.Fatalf("expected cid: reference in HTML, got: %s", body)
|
||||
}
|
||||
newCID := m[1]
|
||||
|
||||
// Verify: the old inline part must be gone, and a new one with the UUID CID must exist.
|
||||
oldFound := false
|
||||
newFound := false
|
||||
for _, part := range flattenParts(snapshot.Body) {
|
||||
if part == nil {
|
||||
continue
|
||||
}
|
||||
if part.ContentID == "peter1-inline" {
|
||||
oldFound = true
|
||||
}
|
||||
if part.ContentID == newCID {
|
||||
newFound = true
|
||||
}
|
||||
}
|
||||
if oldFound {
|
||||
t.Error("old inline part with CID 'peter1-inline' should have been removed")
|
||||
}
|
||||
if !newFound {
|
||||
t.Errorf("new inline part with CID %q should exist", newCID)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata-only edit must NOT trigger local path resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestMetadataEditSkipsLocalPathResolve ensures that a pure metadata edit
|
||||
// (set_subject) does not attempt to resolve <img src="./..."> paths from
|
||||
// disk. If the HTML happens to contain a local path (e.g. from an external
|
||||
// client), the edit should still succeed without file I/O.
|
||||
func TestMetadataEditSkipsLocalPathResolve(t *testing.T) {
|
||||
// Draft HTML contains a local path that does NOT exist on disk.
|
||||
// A body-changing op would fail trying to read this file.
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Original
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
|
||||
<div>Hello<img src="./nonexistent-image.png" /></div>
|
||||
`)
|
||||
err := Apply(snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "set_subject", Value: "Updated subject"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("metadata-only edit should not trigger local path resolution, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -460,7 +460,7 @@ func TestRemoveInlineFailsWhenHTMLStillReferencesCID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySetBodyOrphanedInlineCIDIsRejected(t *testing.T) {
|
||||
func TestApplySetBodyOrphanedInlineCIDIsAutoRemoved(t *testing.T) {
|
||||
snapshot := mustParseFixtureDraft(t, `Subject: Inline
|
||||
From: Alice <alice@example.com>
|
||||
To: Bob <bob@example.com>
|
||||
@@ -480,12 +480,18 @@ Content-Transfer-Encoding: base64
|
||||
cG5n
|
||||
--rel--
|
||||
`)
|
||||
// set_body that drops the existing cid:logo reference → logo becomes orphaned
|
||||
// set_body that drops the existing cid:logo reference → logo is auto-removed
|
||||
err := Apply(snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "set_body", Value: "<div>replaced body without cid reference</div>"}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "orphaned cids") {
|
||||
t.Fatalf("expected orphaned cid error, got: %v", err)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
// The orphaned inline part should be removed from the MIME tree.
|
||||
for _, part := range flattenParts(snapshot.Body) {
|
||||
if part != nil && part.ContentID == "logo" {
|
||||
t.Fatal("expected orphaned inline part 'logo' to be removed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -641,12 +647,18 @@ Content-Type: text/html; charset=UTF-8
|
||||
t.Fatalf("Apply(set_body) error = %v", err)
|
||||
}
|
||||
|
||||
// Step 3: set_body again dropping the CID reference — should fail validation
|
||||
// Step 3: set_body again dropping the CID reference — orphaned inline part
|
||||
// should be auto-removed (not error), matching the auto-cleanup behavior.
|
||||
err = Apply(snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "set_body", Value: `<div>no image here</div>`}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "orphaned cids") {
|
||||
t.Fatalf("expected orphaned cid error, got: %v", err)
|
||||
if err != nil {
|
||||
t.Fatalf("Apply(set_body drop CID) error = %v", err)
|
||||
}
|
||||
for _, part := range flattenParts(snapshot.Body) {
|
||||
if part != nil && part.ContentID == "logo" {
|
||||
t.Fatal("expected orphaned inline part 'logo' to be auto-removed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -732,6 +744,23 @@ func TestReplaceInlineRejectsCRLFInCID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceInlineRejectsInvalidCIDChars(t *testing.T) {
|
||||
fixtureData := mustReadFixture(t, "testdata/html_inline_draft.eml")
|
||||
chdirTemp(t)
|
||||
if err := os.WriteFile("updated.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
snapshot := mustParseFixtureDraft(t, fixtureData)
|
||||
for _, bad := range []string{"my logo", "a\tb", "cid<x>", "cid(x)"} {
|
||||
err := Apply(snapshot, Patch{
|
||||
Ops: []PatchOp{{Op: "replace_inline", Target: AttachmentTarget{PartID: "1.2"}, Path: "updated.png", CID: bad}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Errorf("expected error for CID %q, got nil", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceInlineRejectsCRLFInFileName(t *testing.T) {
|
||||
fixtureData := mustReadFixture(t, "testdata/html_inline_draft.eml")
|
||||
chdirTemp(t)
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
|
||||
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
|
||||
)
|
||||
|
||||
@@ -1770,11 +1771,33 @@ func normalizeInlineCID(cid string) string {
|
||||
return strings.TrimSpace(trimmed)
|
||||
}
|
||||
|
||||
func addInlineImagesToBuilder(runtime *common.RuntimeContext, bld emlbuilder.Builder, images []inlineSourcePart) (emlbuilder.Builder, error) {
|
||||
// validateInlineCIDs checks bidirectional CID consistency between HTML body and
|
||||
// inline MIME parts — the same checks as postProcessInlineImages in draft-edit.
|
||||
// 1. Every cid: reference in HTML must have a corresponding inline part (checked
|
||||
// against userCIDs + extraCIDs combined).
|
||||
// 2. Every user-provided inline part must be referenced in HTML (orphan check
|
||||
// against userCIDs only — extraCIDs such as source-message images in
|
||||
// reply/forward are excluded because quoting may drop some references).
|
||||
func validateInlineCIDs(html string, userCIDs, extraCIDs []string) error {
|
||||
allCIDs := append(append([]string{}, userCIDs...), extraCIDs...)
|
||||
if err := draftpkg.ValidateCIDReferences(html, allCIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(userCIDs) > 0 {
|
||||
orphaned := draftpkg.FindOrphanedCIDs(html, userCIDs)
|
||||
if len(orphaned) > 0 {
|
||||
return fmt.Errorf("inline images with cids %v are not referenced by any <img src=\"cid:...\"> in the HTML body and will appear as unexpected attachments; remove unused --inline entries or add matching <img> tags", orphaned)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addInlineImagesToBuilder(runtime *common.RuntimeContext, bld emlbuilder.Builder, images []inlineSourcePart) (emlbuilder.Builder, []string, error) {
|
||||
var cids []string
|
||||
for _, img := range images {
|
||||
content, err := downloadAttachmentContent(runtime, img.DownloadURL)
|
||||
if err != nil {
|
||||
return bld, fmt.Errorf("failed to download inline resource %s: %w", img.Filename, err)
|
||||
return bld, nil, fmt.Errorf("failed to download inline resource %s: %w", img.Filename, err)
|
||||
}
|
||||
cid := normalizeInlineCID(img.CID)
|
||||
if cid == "" {
|
||||
@@ -1785,8 +1808,9 @@ func addInlineImagesToBuilder(runtime *common.RuntimeContext, bld emlbuilder.Bui
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
bld = bld.AddInline(content, contentType, img.Filename, cid)
|
||||
cids = append(cids, cid)
|
||||
}
|
||||
return bld, nil
|
||||
return bld, cids, nil
|
||||
}
|
||||
|
||||
// InlineSpec represents one inline image entry from the --inline JSON array.
|
||||
@@ -1961,13 +1985,14 @@ func validateComposeInlineAndAttachments(attachFlag, inlineFlag string, plainTex
|
||||
return fmt.Errorf("--inline requires an HTML body (the provided body appears to be plain text; add HTML tags or remove --inline)")
|
||||
}
|
||||
}
|
||||
// Validate explicitly provided files (--attach + --inline) early so that
|
||||
// dry-run and reply/forward can catch local errors before Execute.
|
||||
// Auto-resolved local images are only known at Execute time, so Execute
|
||||
// performs a second, complete size check that includes them.
|
||||
inlineSpecs, err := parseInlineSpecs(inlineFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
allFiles := append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...)
|
||||
if err := checkAttachmentSizeLimit(allFiles, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return checkAttachmentSizeLimit(allFiles, 0)
|
||||
}
|
||||
|
||||
@@ -614,6 +614,67 @@ func TestCheckAttachmentSizeLimit_WithFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateInlineCIDs — bidirectional CID consistency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestValidateInlineCIDs_UserOrphanError(t *testing.T) {
|
||||
// User-provided CID not referenced in body → error.
|
||||
err := validateInlineCIDs(`<p>no image</p>`, []string{"orphan-cid"}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected orphaned CID error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "orphan-cid") {
|
||||
t.Fatalf("expected error mentioning orphan-cid, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInlineCIDs_SourceOrphanAllowed(t *testing.T) {
|
||||
// Source-message CID not referenced in body → allowed (quoting may drop references).
|
||||
err := validateInlineCIDs(`<p>no image</p>`, nil, []string{"source-unused"})
|
||||
if err != nil {
|
||||
t.Fatalf("source CID orphan should not error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInlineCIDs_SourceAndUserMixed(t *testing.T) {
|
||||
// Body references both a source CID and a user CID.
|
||||
// Source has an extra unreferenced CID — should not error.
|
||||
html := `<p><img src="cid:src-used" /><img src="cid:user-img" /></p>`
|
||||
err := validateInlineCIDs(html, []string{"user-img"}, []string{"src-used", "src-unused"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInlineCIDs_MissingRefError(t *testing.T) {
|
||||
// Body references a CID that nobody provided → error.
|
||||
html := `<p><img src="cid:exists" /><img src="cid:missing" /></p>`
|
||||
err := validateInlineCIDs(html, []string{"exists"}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing CID error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing") {
|
||||
t.Fatalf("expected error mentioning missing, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInlineCIDs_MissingRefSatisfiedBySource(t *testing.T) {
|
||||
// Body references a CID that only exists in source (extraCIDs) → ok.
|
||||
html := `<p><img src="cid:from-source" /></p>`
|
||||
err := validateInlineCIDs(html, nil, []string{"from-source"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInlineCIDs_NoCIDsNoError(t *testing.T) {
|
||||
err := validateInlineCIDs(`<p>plain text</p>`, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// downloadAttachmentContent — size limit enforcement
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -678,7 +739,7 @@ func TestAddInlineImagesToBuilder_EmptyCIDSkipped(t *testing.T) {
|
||||
images := []inlineSourcePart{
|
||||
{ID: "img1", Filename: "logo.png", ContentType: "image/png", CID: "", DownloadURL: srv.URL + "/img1"},
|
||||
}
|
||||
_, err := addInlineImagesToBuilder(rt, bld, images)
|
||||
_, _, err := addInlineImagesToBuilder(rt, bld, images)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -699,7 +760,7 @@ func TestAddInlineImagesToBuilder_Success(t *testing.T) {
|
||||
images := []inlineSourcePart{
|
||||
{ID: "img1", Filename: "banner.png", ContentType: "image/png", CID: "cid:banner", DownloadURL: srv.URL + "/img1"},
|
||||
}
|
||||
result, err := addInlineImagesToBuilder(rt, bld, images)
|
||||
result, _, err := addInlineImagesToBuilder(rt, bld, images)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
@@ -148,23 +148,42 @@ func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreate
|
||||
if input.BCC != "" {
|
||||
bld = bld.BCCAddrs(parseNetAddrs(input.BCC))
|
||||
}
|
||||
if input.PlainText {
|
||||
bld = bld.TextBody([]byte(input.Body))
|
||||
} else if bodyIsHTML(input.Body) {
|
||||
bld = bld.HTMLBody([]byte(input.Body))
|
||||
} else {
|
||||
bld = bld.TextBody([]byte(input.Body))
|
||||
}
|
||||
inlineSpecs, err := parseInlineSpecs(input.Inline)
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("%v", err)
|
||||
}
|
||||
var autoResolvedPaths []string
|
||||
if input.PlainText {
|
||||
bld = bld.TextBody([]byte(input.Body))
|
||||
} else if bodyIsHTML(input.Body) {
|
||||
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(input.Body)
|
||||
if resolveErr != nil {
|
||||
return "", resolveErr
|
||||
}
|
||||
bld = bld.HTMLBody([]byte(resolved))
|
||||
var allCIDs []string
|
||||
for _, ref := range refs {
|
||||
bld = bld.AddFileInline(ref.FilePath, ref.CID)
|
||||
autoResolvedPaths = append(autoResolvedPaths, ref.FilePath)
|
||||
allCIDs = append(allCIDs, ref.CID)
|
||||
}
|
||||
for _, spec := range inlineSpecs {
|
||||
bld = bld.AddFileInline(spec.FilePath, spec.CID)
|
||||
allCIDs = append(allCIDs, spec.CID)
|
||||
}
|
||||
if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
bld = bld.TextBody([]byte(input.Body))
|
||||
}
|
||||
allFilePaths := append(append(splitByComma(input.Attach), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
|
||||
if err := checkAttachmentSizeLimit(allFilePaths, 0); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, path := range splitByComma(input.Attach) {
|
||||
bld = bld.AddFileAttachment(path)
|
||||
}
|
||||
for _, spec := range inlineSpecs {
|
||||
bld = bld.AddFileInline(spec.FilePath, spec.CID)
|
||||
}
|
||||
rawEML, err := bld.BuildBase64URL()
|
||||
if err != nil {
|
||||
return "", output.ErrValidation("build EML failed: %v", err)
|
||||
|
||||
152
shortcuts/mail/mail_draft_create_test.go
Normal file
152
shortcuts/mail/mail_draft_create_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildRawEMLForDraftCreate_ResolvesLocalImages(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
os.WriteFile("test_image.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
|
||||
|
||||
input := draftCreateInput{
|
||||
From: "sender@example.com",
|
||||
Subject: "local image test",
|
||||
Body: `<p>Hello</p><p><img src="./test_image.png" /></p>`,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(nil, input)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
|
||||
eml := decodeBase64URL(rawEML)
|
||||
|
||||
if strings.Contains(eml, `src="./test_image.png"`) {
|
||||
t.Fatal("local image path should have been replaced with cid: reference")
|
||||
}
|
||||
if !strings.Contains(eml, "cid:") {
|
||||
t.Fatal("expected cid: reference in resolved HTML body")
|
||||
}
|
||||
if !strings.Contains(eml, "Content-Disposition: inline") {
|
||||
t.Fatal("expected inline MIME part for the resolved image")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRawEMLForDraftCreate_NoLocalImages(t *testing.T) {
|
||||
input := draftCreateInput{
|
||||
From: "sender@example.com",
|
||||
Subject: "plain html",
|
||||
Body: `<p>Hello <b>world</b></p>`,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(nil, input)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
|
||||
eml := decodeBase64URL(rawEML)
|
||||
|
||||
if !strings.Contains(eml, "Hello") {
|
||||
t.Fatal("expected body content in EML")
|
||||
}
|
||||
if strings.Contains(eml, "Content-Disposition: inline") {
|
||||
t.Fatal("no inline parts expected without local images")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRawEMLForDraftCreate_AutoResolveCountedInSizeLimit(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
// Create a 1KB PNG file — small, but enough to push over the limit
|
||||
// when combined with a near-limit --attach file.
|
||||
pngHeader := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
|
||||
imgData := make([]byte, 1024)
|
||||
copy(imgData, pngHeader)
|
||||
os.WriteFile("photo.png", imgData, 0o644)
|
||||
|
||||
// Create an attach file that's just under the 25MB limit (use .txt — allowed extension).
|
||||
bigFile := make([]byte, MaxAttachmentBytes-500)
|
||||
os.WriteFile("big.txt", bigFile, 0o644)
|
||||
|
||||
input := draftCreateInput{
|
||||
From: "sender@example.com",
|
||||
Subject: "size limit test",
|
||||
Body: `<p><img src="./photo.png" /></p>`,
|
||||
Attach: "./big.txt",
|
||||
}
|
||||
|
||||
_, err := buildRawEMLForDraftCreate(nil, input)
|
||||
if err == nil {
|
||||
t.Fatal("expected size limit error when auto-resolved image + attachment exceed 25MB")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "25 MB") {
|
||||
t.Fatalf("expected 25 MB limit error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRawEMLForDraftCreate_OrphanedInlineSpecError(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
os.WriteFile("unused.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
|
||||
|
||||
input := draftCreateInput{
|
||||
From: "sender@example.com",
|
||||
Subject: "orphan test",
|
||||
Body: `<p>No image reference here</p>`,
|
||||
Inline: `[{"cid":"orphan","file_path":"./unused.png"}]`,
|
||||
}
|
||||
|
||||
_, err := buildRawEMLForDraftCreate(nil, input)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for orphaned --inline CID not referenced in body")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "orphan") {
|
||||
t.Fatalf("expected error mentioning orphan, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
os.WriteFile("present.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
|
||||
|
||||
input := draftCreateInput{
|
||||
From: "sender@example.com",
|
||||
Subject: "missing cid test",
|
||||
Body: `<p><img src="cid:present" /><img src="cid:missing" /></p>`,
|
||||
Inline: `[{"cid":"present","file_path":"./present.png"}]`,
|
||||
}
|
||||
|
||||
_, err := buildRawEMLForDraftCreate(nil, input)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing CID reference")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "missing") {
|
||||
t.Fatalf("expected error mentioning missing, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
os.WriteFile("img.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
|
||||
|
||||
input := draftCreateInput{
|
||||
From: "sender@example.com",
|
||||
Subject: "plain text",
|
||||
Body: `check <img src="./img.png" /> text`,
|
||||
PlainText: true,
|
||||
}
|
||||
|
||||
rawEML, err := buildRawEMLForDraftCreate(nil, input)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRawEMLForDraftCreate() error = %v", err)
|
||||
}
|
||||
|
||||
eml := decodeBase64URL(rawEML)
|
||||
|
||||
if strings.Contains(eml, "cid:") {
|
||||
t.Fatal("plain-text mode should not resolve local images")
|
||||
}
|
||||
}
|
||||
@@ -303,13 +303,13 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
|
||||
{"op": "set_recipients", "shape": map[string]interface{}{"field": "to|cc|bcc", "addresses": []map[string]interface{}{{"address": "string", "name": "string(optional)"}}}},
|
||||
{"op": "add_recipient", "shape": map[string]interface{}{"field": "to|cc|bcc", "address": "string", "name": "string(optional)"}},
|
||||
{"op": "remove_recipient", "shape": map[string]interface{}{"field": "to|cc|bcc", "address": "string"}},
|
||||
{"op": "set_body", "shape": map[string]interface{}{"value": "string"}},
|
||||
{"op": "set_reply_body", "shape": map[string]interface{}{"value": "string (user-authored content only, WITHOUT the quote block; the quote block is re-appended automatically)"}},
|
||||
{"op": "set_body", "shape": map[string]interface{}{"value": "string (supports <img src=\"./local/path.png\" /> — local paths auto-resolved to inline MIME parts)"}},
|
||||
{"op": "set_reply_body", "shape": map[string]interface{}{"value": "string (user-authored content only, WITHOUT the quote block; the quote block is re-appended automatically; supports <img src=\"./local/path.png\" /> — local paths auto-resolved to inline MIME parts)"}},
|
||||
{"op": "set_header", "shape": map[string]interface{}{"name": "string", "value": "string"}},
|
||||
{"op": "remove_header", "shape": map[string]interface{}{"name": "string"}},
|
||||
{"op": "add_attachment", "shape": map[string]interface{}{"path": "string(relative path)"}},
|
||||
{"op": "remove_attachment", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
|
||||
{"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}},
|
||||
{"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}, "note": "advanced: prefer <img src=\"./path\"> in set_body/set_reply_body instead"},
|
||||
{"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string(relative path)", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}},
|
||||
{"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
|
||||
},
|
||||
@@ -318,8 +318,8 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
|
||||
"group": "subject_and_body",
|
||||
"ops": []map[string]interface{}{
|
||||
{"op": "set_subject", "shape": map[string]interface{}{"value": "string"}},
|
||||
{"op": "set_body", "shape": map[string]interface{}{"value": "string"}},
|
||||
{"op": "set_reply_body", "shape": map[string]interface{}{"value": "string (user-authored content only, WITHOUT the quote block; the quote block is re-appended automatically)"}},
|
||||
{"op": "set_body", "shape": map[string]interface{}{"value": "string (supports <img src=\"./local/path.png\" /> — local paths auto-resolved to inline MIME parts)"}},
|
||||
{"op": "set_reply_body", "shape": map[string]interface{}{"value": "string (user-authored content only, WITHOUT the quote block; the quote block is re-appended automatically; supports <img src=\"./local/path.png\" /> — local paths auto-resolved to inline MIME parts)"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -342,7 +342,7 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
|
||||
"ops": []map[string]interface{}{
|
||||
{"op": "add_attachment", "shape": map[string]interface{}{"path": "string(relative path)"}},
|
||||
{"op": "remove_attachment", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
|
||||
{"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}},
|
||||
{"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}, "note": "advanced: prefer <img src=\"./path\"> in set_body/set_reply_body instead"},
|
||||
{"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string(relative path)", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}},
|
||||
{"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}},
|
||||
},
|
||||
@@ -359,12 +359,13 @@ func buildDraftEditPatchTemplate() map[string]interface{} {
|
||||
{"situation": "draft created by +reply or +forward (has_quoted_content=true)", "recommended_op": "set_reply_body — replaces only the user-authored portion and automatically preserves the quoted original message; if user explicitly wants to remove the quote, use set_body instead"},
|
||||
},
|
||||
"notes": []string{
|
||||
"`set_body`/`set_reply_body` support inline images via local file paths: use <img src=\"./local/file.png\" /> in the HTML value — the local path is automatically resolved into an inline MIME part with a generated CID; removing or replacing an <img> tag automatically cleans up or replaces the corresponding MIME part; do NOT use `add_inline` for this; example: {\"op\":\"set_body\",\"value\":\"<div>Hello<img src=\\\"./logo.png\\\" /></div>\"}",
|
||||
"`add_inline` is an advanced op for precise CID control only — in most cases, use <img src=\"./path\"> in `set_body`/`set_reply_body` instead",
|
||||
"`ops` is executed in order",
|
||||
"all file paths (--patch-file and `path` fields in ops) must be relative — no absolute paths or .. traversal",
|
||||
"all body edits MUST go through --patch-file; there is no --set-body flag",
|
||||
"`set_body` replaces the ENTIRE body including any reply/forward quote block; when the draft has both text/plain and text/html, it updates the HTML body and regenerates the plain-text summary, so the input should be HTML",
|
||||
"`set_reply_body` replaces only the user-authored portion of the body and automatically re-appends the trailing reply/forward quote block (generated by +reply or +forward); the value you pass should contain ONLY the new user-authored content WITHOUT the quote block — the quote block will be re-inserted automatically; if the user wants to modify content INSIDE the quote block, use `set_body` instead for full replacement; if the draft has no quote block, it behaves identically to `set_body`",
|
||||
"`add_inline` only adds the MIME binary part; it does NOT insert an <img> tag into the HTML body; to display the image in the body, you must ALSO use set_body/set_reply_body to insert <img src=\"cid:...\"> into the body content; forgetting this causes the inline part to become an orphaned attachment when sent",
|
||||
"`body_kind` only supports text/plain and text/html",
|
||||
"`selector` currently only supports primary",
|
||||
"`remove_attachment` target supports part_id or cid; priority: part_id > cid",
|
||||
|
||||
@@ -121,16 +121,41 @@ var MailForward = common.Shortcut{
|
||||
if strings.TrimSpace(inlineFlag) != "" && !useHTML {
|
||||
return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML")
|
||||
}
|
||||
inlineSpecs, err := parseInlineSpecs(inlineFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var autoResolvedPaths []string
|
||||
if useHTML {
|
||||
if err := validateInlineImageURLs(sourceMsg); err != nil {
|
||||
return fmt.Errorf("forward blocked: %w", err)
|
||||
}
|
||||
processedBody := buildBodyDiv(body, bodyIsHTML(body))
|
||||
bld = bld.HTMLBody([]byte(processedBody + buildForwardQuoteHTML(&orig)))
|
||||
bld, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
|
||||
forwardQuote := buildForwardQuoteHTML(&orig)
|
||||
var srcCIDs []string
|
||||
bld, srcCIDs, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(processedBody)
|
||||
if resolveErr != nil {
|
||||
return resolveErr
|
||||
}
|
||||
fullHTML := resolved + forwardQuote
|
||||
bld = bld.HTMLBody([]byte(fullHTML))
|
||||
var userCIDs []string
|
||||
for _, ref := range refs {
|
||||
bld = bld.AddFileInline(ref.FilePath, ref.CID)
|
||||
autoResolvedPaths = append(autoResolvedPaths, ref.FilePath)
|
||||
userCIDs = append(userCIDs, ref.CID)
|
||||
}
|
||||
for _, spec := range inlineSpecs {
|
||||
bld = bld.AddFileInline(spec.FilePath, spec.CID)
|
||||
userCIDs = append(userCIDs, spec.CID)
|
||||
}
|
||||
if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
bld = bld.TextBody([]byte(buildForwardedMessage(&orig, body)))
|
||||
}
|
||||
@@ -169,11 +194,8 @@ var MailForward = common.Shortcut{
|
||||
}
|
||||
bld = bld.Header("X-Lms-Large-Attachment-Ids", base64.StdEncoding.EncodeToString(idsJSON))
|
||||
}
|
||||
inlineSpecs, err := parseInlineSpecs(inlineFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkAttachmentSizeLimit(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), origAttBytes, len(origAtts)); err != nil {
|
||||
allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
|
||||
if err := checkAttachmentSizeLimit(allFilePaths, origAttBytes, len(origAtts)); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, att := range origAtts {
|
||||
@@ -182,9 +204,6 @@ var MailForward = common.Shortcut{
|
||||
for _, path := range splitByComma(attachFlag) {
|
||||
bld = bld.AddFileAttachment(path)
|
||||
}
|
||||
for _, spec := range inlineSpecs {
|
||||
bld = bld.AddFileInline(spec.FilePath, spec.CID)
|
||||
}
|
||||
rawEML, err := bld.BuildBase64URL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build EML: %w", err)
|
||||
|
||||
@@ -128,24 +128,45 @@ var MailReply = common.Shortcut{
|
||||
if messageId != "" {
|
||||
bld = bld.LMSReplyToMessageID(messageId)
|
||||
}
|
||||
var autoResolvedPaths []string
|
||||
if useHTML {
|
||||
if err := validateInlineImageURLs(sourceMsg); err != nil {
|
||||
return fmt.Errorf("HTML reply blocked: %w", err)
|
||||
}
|
||||
bld = bld.HTMLBody([]byte(bodyStr + quoted))
|
||||
bld, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
|
||||
var srcCIDs []string
|
||||
bld, srcCIDs, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(bodyStr)
|
||||
if resolveErr != nil {
|
||||
return resolveErr
|
||||
}
|
||||
fullHTML := resolved + quoted
|
||||
bld = bld.HTMLBody([]byte(fullHTML))
|
||||
var userCIDs []string
|
||||
for _, ref := range refs {
|
||||
bld = bld.AddFileInline(ref.FilePath, ref.CID)
|
||||
autoResolvedPaths = append(autoResolvedPaths, ref.FilePath)
|
||||
userCIDs = append(userCIDs, ref.CID)
|
||||
}
|
||||
for _, spec := range inlineSpecs {
|
||||
bld = bld.AddFileInline(spec.FilePath, spec.CID)
|
||||
userCIDs = append(userCIDs, spec.CID)
|
||||
}
|
||||
if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
bld = bld.TextBody([]byte(bodyStr + quoted))
|
||||
}
|
||||
allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
|
||||
if err := checkAttachmentSizeLimit(allFilePaths, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, path := range splitByComma(attachFlag) {
|
||||
bld = bld.AddFileAttachment(path)
|
||||
}
|
||||
for _, spec := range inlineSpecs {
|
||||
bld = bld.AddFileInline(spec.FilePath, spec.CID)
|
||||
}
|
||||
rawEML, err := bld.BuildBase64URL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build EML: %w", err)
|
||||
|
||||
@@ -142,24 +142,45 @@ var MailReplyAll = common.Shortcut{
|
||||
if messageId != "" {
|
||||
bld = bld.LMSReplyToMessageID(messageId)
|
||||
}
|
||||
var autoResolvedPaths []string
|
||||
if useHTML {
|
||||
if err := validateInlineImageURLs(sourceMsg); err != nil {
|
||||
return fmt.Errorf("HTML reply-all blocked: %w", err)
|
||||
}
|
||||
bld = bld.HTMLBody([]byte(bodyStr + quoted))
|
||||
bld, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
|
||||
var srcCIDs []string
|
||||
bld, srcCIDs, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(bodyStr)
|
||||
if resolveErr != nil {
|
||||
return resolveErr
|
||||
}
|
||||
fullHTML := resolved + quoted
|
||||
bld = bld.HTMLBody([]byte(fullHTML))
|
||||
var userCIDs []string
|
||||
for _, ref := range refs {
|
||||
bld = bld.AddFileInline(ref.FilePath, ref.CID)
|
||||
autoResolvedPaths = append(autoResolvedPaths, ref.FilePath)
|
||||
userCIDs = append(userCIDs, ref.CID)
|
||||
}
|
||||
for _, spec := range inlineSpecs {
|
||||
bld = bld.AddFileInline(spec.FilePath, spec.CID)
|
||||
userCIDs = append(userCIDs, spec.CID)
|
||||
}
|
||||
if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
bld = bld.TextBody([]byte(bodyStr + quoted))
|
||||
}
|
||||
allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
|
||||
if err := checkAttachmentSizeLimit(allFilePaths, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, path := range splitByComma(attachFlag) {
|
||||
bld = bld.AddFileAttachment(path)
|
||||
}
|
||||
for _, spec := range inlineSpecs {
|
||||
bld = bld.AddFileInline(spec.FilePath, spec.CID)
|
||||
}
|
||||
rawEML, err := bld.BuildBase64URL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build EML: %w", err)
|
||||
|
||||
275
shortcuts/mail/mail_reply_forward_inline_test.go
Normal file
275
shortcuts/mail/mail_reply_forward_inline_test.go
Normal file
@@ -0,0 +1,275 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// stubSourceMessageWithInlineImages registers HTTP stubs for a source message.
|
||||
func stubSourceMessageWithInlineImages(reg *httpmock.Registry, bodyHTML string, allImages []map[string]interface{}) {
|
||||
// Profile
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/user_mailboxes/me/profile",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"primary_email_address": "me@example.com",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Message get
|
||||
atts := allImages
|
||||
if atts == nil {
|
||||
atts = []map[string]interface{}{}
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/user_mailboxes/me/messages/msg_001",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"message": map[string]interface{}{
|
||||
"message_id": "msg_001",
|
||||
"thread_id": "thread_001",
|
||||
"smtp_message_id": "<msg_001@example.com>",
|
||||
"subject": "Original Subject",
|
||||
"head_from": map[string]interface{}{"mail_address": "sender@example.com", "name": "Sender"},
|
||||
"to": []map[string]interface{}{{"mail_address": "me@example.com", "name": "Me"}},
|
||||
"cc": []interface{}{},
|
||||
"bcc": []interface{}{},
|
||||
"body_html": base64.URLEncoding.EncodeToString([]byte(bodyHTML)),
|
||||
"body_plain_text": base64.URLEncoding.EncodeToString([]byte("plain")),
|
||||
"internal_date": "1704067200000",
|
||||
"attachments": atts,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Download URLs
|
||||
if len(allImages) > 0 {
|
||||
downloadURLs := make([]map[string]interface{}, 0, len(allImages))
|
||||
for _, img := range allImages {
|
||||
id, _ := img["id"].(string)
|
||||
downloadURLs = append(downloadURLs, map[string]interface{}{
|
||||
"attachment_id": id,
|
||||
"download_url": "https://storage.example.com/" + id,
|
||||
})
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/attachments/download_url",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"download_urls": downloadURLs,
|
||||
"failed_ids": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Image downloads
|
||||
pngBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
|
||||
for _, img := range allImages {
|
||||
id, _ := img["id"].(string)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "https://storage.example.com/" + id,
|
||||
RawBody: pngBytes,
|
||||
})
|
||||
}
|
||||
|
||||
// Draft create
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/user_mailboxes/me/drafts",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"draft_id": "draft_001",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// +reply with source inline images
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestReply_SourceInlineImagesPreserved(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
|
||||
stubSourceMessageWithInlineImages(reg,
|
||||
`<p>Hello <img src="cid:banner_001" /></p>`,
|
||||
[]map[string]interface{}{
|
||||
{"id": "img_001", "filename": "banner.png", "is_inline": true, "cid": "banner_001", "content_type": "image/png"},
|
||||
},
|
||||
)
|
||||
|
||||
err := runMountedMailShortcut(t, MailReply, []string{
|
||||
"+reply", "--message-id", "msg_001", "--body", "<p>Thanks!</p>",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("reply failed: %v", err)
|
||||
}
|
||||
|
||||
data := decodeShortcutEnvelopeData(t, stdout)
|
||||
if data["draft_id"] == nil || data["draft_id"] == "" {
|
||||
t.Fatal("expected draft_id in output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReply_SourceOrphanCIDNotBlocked(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
|
||||
// Source has TWO inline images, but body HTML only references one.
|
||||
// The unreferenced image should NOT be downloaded or cause an error.
|
||||
stubSourceMessageWithInlineImages(reg,
|
||||
`<p>Hello <img src="cid:used_001" /></p>`,
|
||||
[]map[string]interface{}{
|
||||
{"id": "img_001", "filename": "used.png", "is_inline": true, "cid": "used_001", "content_type": "image/png"},
|
||||
{"id": "img_002", "filename": "unused.png", "is_inline": true, "cid": "unused_002", "content_type": "image/png"},
|
||||
},
|
||||
)
|
||||
|
||||
err := runMountedMailShortcut(t, MailReply, []string{
|
||||
"+reply", "--message-id", "msg_001", "--body", "<p>Reply</p>",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("reply should succeed even with unreferenced source CID, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReply_WithAutoResolveLocalImage(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
os.WriteFile("local.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
|
||||
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
|
||||
stubSourceMessageWithInlineImages(reg,
|
||||
`<p>Hello</p>`,
|
||||
nil,
|
||||
)
|
||||
|
||||
err := runMountedMailShortcut(t, MailReply, []string{
|
||||
"+reply", "--message-id", "msg_001",
|
||||
"--body", `<p>See image: <img src="./local.png" /></p>`,
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("reply with auto-resolved local image failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// +reply-all with source inline images
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestReplyAll_SourceOrphanCIDNotBlocked(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
|
||||
stubSourceMessageWithInlineImages(reg,
|
||||
`<p>Hello <img src="cid:used_001" /></p>`,
|
||||
[]map[string]interface{}{
|
||||
{"id": "img_001", "filename": "used.png", "is_inline": true, "cid": "used_001", "content_type": "image/png"},
|
||||
{"id": "img_002", "filename": "orphan.png", "is_inline": true, "cid": "orphan_002", "content_type": "image/png"},
|
||||
},
|
||||
)
|
||||
|
||||
// reply-all also needs self-exclusion profile lookup
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/user_mailboxes/me/profile",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"primary_email_address": "me@example.com",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runMountedMailShortcut(t, MailReplyAll, []string{
|
||||
"+reply-all", "--message-id", "msg_001", "--body", "<p>Reply all</p>",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("reply-all should succeed with unreferenced source CID, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// +forward with source inline images
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestForward_SourceOrphanCIDNotBlocked(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
|
||||
stubSourceMessageWithInlineImages(reg,
|
||||
`<p>Hello <img src="cid:used_001" /></p>`,
|
||||
[]map[string]interface{}{
|
||||
{"id": "img_001", "filename": "used.png", "is_inline": true, "cid": "used_001", "content_type": "image/png"},
|
||||
{"id": "img_002", "filename": "orphan.png", "is_inline": true, "cid": "orphan_002", "content_type": "image/png"},
|
||||
},
|
||||
)
|
||||
|
||||
err := runMountedMailShortcut(t, MailForward, []string{
|
||||
"+forward", "--message-id", "msg_001",
|
||||
"--to", "alice@example.com",
|
||||
"--body", "<p>FYI</p>",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("forward should succeed with unreferenced source CID, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForward_WithAutoResolveLocalImage(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
os.WriteFile("chart.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
|
||||
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
|
||||
stubSourceMessageWithInlineImages(reg,
|
||||
`<p>Original content</p>`,
|
||||
nil,
|
||||
)
|
||||
|
||||
err := runMountedMailShortcut(t, MailForward, []string{
|
||||
"+forward", "--message-id", "msg_001",
|
||||
"--to", "alice@example.com",
|
||||
"--body", `<p>See chart: <img src="./chart.png" /></p>`,
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("forward with auto-resolved local image failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// +reply body auto-resolve does NOT scan quoted content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestReply_QuotedContentNotAutoResolved(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
|
||||
// Source message body has a relative <img src> — this should NOT be
|
||||
// auto-resolved because it's in the quoted portion, not the user body.
|
||||
stubSourceMessageWithInlineImages(reg,
|
||||
`<p>See <img src="./should-not-resolve.png" /></p>`,
|
||||
nil,
|
||||
)
|
||||
|
||||
err := runMountedMailShortcut(t, MailReply, []string{
|
||||
"+reply", "--message-id", "msg_001",
|
||||
"--body", "<p>Got it</p>",
|
||||
}, f, stdout)
|
||||
// Should succeed — the ./should-not-resolve.png in quoted content is
|
||||
// NOT auto-resolved (file doesn't exist, would fail if scanned).
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "should-not-resolve") {
|
||||
t.Fatalf("auto-resolve incorrectly scanned quoted content: %v", err)
|
||||
}
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -92,16 +92,37 @@ var MailSend = common.Shortcut{
|
||||
if bccFlag != "" {
|
||||
bld = bld.BCCAddrs(parseNetAddrs(bccFlag))
|
||||
}
|
||||
inlineSpecs, err := parseInlineSpecs(inlineFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var autoResolvedPaths []string
|
||||
if plainText {
|
||||
bld = bld.TextBody([]byte(body))
|
||||
} else if bodyIsHTML(body) {
|
||||
bld = bld.HTMLBody([]byte(body))
|
||||
resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(body)
|
||||
if resolveErr != nil {
|
||||
return resolveErr
|
||||
}
|
||||
bld = bld.HTMLBody([]byte(resolved))
|
||||
var allCIDs []string
|
||||
for _, ref := range refs {
|
||||
bld = bld.AddFileInline(ref.FilePath, ref.CID)
|
||||
autoResolvedPaths = append(autoResolvedPaths, ref.FilePath)
|
||||
allCIDs = append(allCIDs, ref.CID)
|
||||
}
|
||||
for _, spec := range inlineSpecs {
|
||||
bld = bld.AddFileInline(spec.FilePath, spec.CID)
|
||||
allCIDs = append(allCIDs, spec.CID)
|
||||
}
|
||||
if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
bld = bld.TextBody([]byte(body))
|
||||
}
|
||||
|
||||
inlineSpecs, err := parseInlineSpecs(inlineFlag)
|
||||
if err != nil {
|
||||
allFilePaths := append(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), autoResolvedPaths...)
|
||||
if err := checkAttachmentSizeLimit(allFilePaths, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -109,10 +130,6 @@ var MailSend = common.Shortcut{
|
||||
bld = bld.AddFileAttachment(path)
|
||||
}
|
||||
|
||||
for _, spec := range inlineSpecs {
|
||||
bld = bld.AddFileInline(spec.FilePath, spec.CID)
|
||||
}
|
||||
|
||||
rawEML, err := bld.BuildBase64URL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build EML: %w", err)
|
||||
|
||||
@@ -14,8 +14,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -79,7 +78,7 @@ var MinutesDownload = common.Shortcut{
|
||||
|
||||
// Batch mode: --output must be a directory, not an existing file.
|
||||
if !single && outputPath != "" {
|
||||
if fi, err := vfs.Stat(outputPath); err == nil && !fi.IsDir() {
|
||||
if fi, err := runtime.FileIO().Stat(outputPath); err == nil && !fi.IsDir() {
|
||||
return output.ErrValidation("--output %q is a file; batch mode expects a directory path", outputPath)
|
||||
}
|
||||
}
|
||||
@@ -162,7 +161,7 @@ var MinutesDownload = common.Shortcut{
|
||||
fmt.Fprintf(errOut, "Downloading media: %s\n", common.MaskToken(token))
|
||||
|
||||
// single token: --output is a file path; batch: --output is a directory
|
||||
opts := downloadOpts{overwrite: overwrite, usedNames: usedNames}
|
||||
opts := downloadOpts{fio: runtime.FileIO(), overwrite: overwrite, usedNames: usedNames}
|
||||
if single {
|
||||
opts.outputPath = outputPath
|
||||
} else {
|
||||
@@ -229,8 +228,9 @@ type downloadResult struct {
|
||||
}
|
||||
|
||||
type downloadOpts struct {
|
||||
outputPath string // explicit output file path (single mode only)
|
||||
outputDir string // output directory (batch mode)
|
||||
fio fileio.FileIO // file I/O abstraction
|
||||
outputPath string // explicit output file path (single mode only)
|
||||
outputDir string // output directory (batch mode)
|
||||
overwrite bool
|
||||
usedNames map[string]bool // tracks used filenames to deduplicate in batch mode
|
||||
}
|
||||
@@ -275,22 +275,24 @@ func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, mi
|
||||
outputPath = filepath.Join(opts.outputDir, filename)
|
||||
}
|
||||
|
||||
safePath, err := validate.SafeOutputPath(outputPath)
|
||||
if err != nil {
|
||||
return nil, output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
if err := common.EnsureWritableFile(safePath, opts.overwrite); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := vfs.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "api_error", "cannot create parent directory: %s", err)
|
||||
if !opts.overwrite {
|
||||
if _, statErr := opts.fio.Stat(outputPath); statErr == nil {
|
||||
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
sizeBytes, err := validate.AtomicWriteFromReader(safePath, resp.Body, 0600)
|
||||
result, err := opts.fio.Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err)
|
||||
return nil, common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
return &downloadResult{savedPath: safePath, sizeBytes: sizeBytes}, nil
|
||||
resolvedPath, err := opts.fio.ResolvePath(outputPath)
|
||||
if err != nil || resolvedPath == "" {
|
||||
resolvedPath = outputPath
|
||||
}
|
||||
return &downloadResult{savedPath: resolvedPath, sizeBytes: result.Size()}, nil
|
||||
}
|
||||
|
||||
// resolveFilenameFromResponse derives the filename from HTTP response headers.
|
||||
|
||||
@@ -29,12 +29,13 @@ var GetMyTasks = common.Shortcut{
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search for tasks by summary (exact match first, then partial match)"},
|
||||
{Name: "complete", Type: "bool", Desc: "if true, query completed tasks; default is false"},
|
||||
{Name: "complete", Type: "bool", Desc: "if true, query completed tasks;if false, query incompleted tasks; if not provided, both completed and incompleted tasks are queried."},
|
||||
{Name: "created_at", Desc: "query tasks created after this time (date/relative/ms)"},
|
||||
{Name: "due-start", Desc: "query tasks with due date after this time (date/relative/ms)"},
|
||||
{Name: "due-end", Desc: "query tasks with due date before this time (date/relative/ms)"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40 with --page-all)"},
|
||||
{Name: "page-token", Desc: "start from the specified page token"},
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -48,6 +49,9 @@ var GetMyTasks = common.Shortcut{
|
||||
if runtime.Cmd.Flags().Changed("complete") {
|
||||
params["completed"] = runtime.Bool("complete")
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
|
||||
return d.GET("/open-apis/task/v2/tasks").Params(params)
|
||||
},
|
||||
@@ -66,6 +70,9 @@ var GetMyTasks = common.Shortcut{
|
||||
queryParams.Set("completed", "false")
|
||||
}
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
queryParams.Set("page_token", pageToken)
|
||||
}
|
||||
|
||||
// parse time flags to ms timestamp if provided
|
||||
var createdAfterMs, dueStartMs, dueEndMs int64
|
||||
|
||||
@@ -22,11 +22,14 @@ func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
formatFlag string
|
||||
pageToken string
|
||||
stubURL string
|
||||
expectedOutput []string
|
||||
}{
|
||||
{
|
||||
name: "pretty format",
|
||||
formatFlag: "pretty",
|
||||
stubURL: "/open-apis/task/v2/tasks",
|
||||
expectedOutput: []string{
|
||||
"Due: " + expectedDueTimeStr,
|
||||
"Created: " + expectedCreatedDateStr,
|
||||
@@ -35,11 +38,21 @@ func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
|
||||
{
|
||||
name: "json format",
|
||||
formatFlag: "json",
|
||||
stubURL: "/open-apis/task/v2/tasks",
|
||||
expectedOutput: []string{
|
||||
`"due_at": "` + expectedRFC3339 + `"`,
|
||||
`"created_at": "` + expectedRFC3339 + `"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "start from page token",
|
||||
formatFlag: "json",
|
||||
pageToken: "pt_001",
|
||||
stubURL: "page_token=pt_001",
|
||||
expectedOutput: []string{
|
||||
`"guid": "task-123"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -49,7 +62,7 @@ func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasks",
|
||||
URL: tt.stubURL,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
@@ -73,7 +86,11 @@ func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
|
||||
s := GetMyTasks
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
err := runMountedTaskShortcut(t, s, []string{"+get-my-tasks", "--format", tt.formatFlag, "--as", "bot"}, f, stdout)
|
||||
args := []string{"+get-my-tasks", "--format", tt.formatFlag, "--as", "bot"}
|
||||
if tt.pageToken != "" {
|
||||
args = append(args, "--page-token", tt.pageToken)
|
||||
}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
@@ -10,5 +10,6 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
VCSearch,
|
||||
VCNotes,
|
||||
VCRecording,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
package vc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -22,11 +24,11 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -90,44 +92,34 @@ func getPrimaryCalendarID(runtime *common.RuntimeContext) (string, error) {
|
||||
return calID, nil
|
||||
}
|
||||
|
||||
// fetchNoteByCalendarEventID queries notes via calendar event instance ID.
|
||||
// Chain: primary calendar → mget_instance_relation_info → meeting_id → meeting.get → note_id
|
||||
func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeContext, instanceID string, calendarID string) map[string]any {
|
||||
errOut := runtime.IO().ErrOut
|
||||
|
||||
// call mget_instance_relation_info to get meeting_id
|
||||
// resolveMeetingIDsFromCalendarEvent resolves a calendar event instance to its
|
||||
// associated meeting IDs via the mget_instance_relation_info API.
|
||||
// Shared by +notes and +recording for the --calendar-event-ids path.
|
||||
func resolveMeetingIDsFromCalendarEvent(runtime *common.RuntimeContext, instanceID string, calendarID string) ([]string, error) {
|
||||
data, err := runtime.DoAPIJSON(http.MethodPost,
|
||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", validate.EncodePathSegment(calendarID)),
|
||||
nil,
|
||||
map[string]any{
|
||||
"instance_ids": []string{instanceID},
|
||||
"need_meeting_instance_ids": true,
|
||||
"need_meeting_notes": true,
|
||||
"need_ai_meeting_notes": true,
|
||||
})
|
||||
if err != nil {
|
||||
return map[string]any{"calendar_event_id": instanceID, "error": fmt.Sprintf("failed to query event relation info: %v", err)}
|
||||
return nil, fmt.Errorf("failed to query event relation info: %w", err)
|
||||
}
|
||||
|
||||
// parse instance_relation_infos
|
||||
infos, _ := data["instance_relation_infos"].([]any)
|
||||
if len(infos) == 0 {
|
||||
return map[string]any{"calendar_event_id": instanceID, "error": "no event relation info found"}
|
||||
return nil, fmt.Errorf("no event relation info found")
|
||||
}
|
||||
info, _ := infos[0].(map[string]any)
|
||||
|
||||
// get meeting_instance_ids
|
||||
meetingIDs, _ := info["meeting_instance_ids"].([]any)
|
||||
if len(meetingIDs) == 0 {
|
||||
return map[string]any{"calendar_event_id": instanceID, "error": "no associated video meeting for this event"}
|
||||
rawIDs, _ := info["meeting_instance_ids"].([]any)
|
||||
if len(rawIDs) == 0 {
|
||||
return nil, fmt.Errorf("no associated video meeting for this event")
|
||||
}
|
||||
|
||||
if len(meetingIDs) > 1 {
|
||||
fmt.Fprintf(errOut, "%s event %s has %d meetings, trying each\n", logPrefix, sanitizeLogValue(instanceID), len(meetingIDs))
|
||||
}
|
||||
|
||||
// try each meeting_instance_id until one has notes
|
||||
for _, mid := range meetingIDs {
|
||||
var ids []string
|
||||
for _, mid := range rawIDs {
|
||||
if mid == nil {
|
||||
continue
|
||||
}
|
||||
@@ -140,12 +132,32 @@ func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeCont
|
||||
default:
|
||||
meetingID = fmt.Sprintf("%v", v)
|
||||
}
|
||||
ids = append(ids, meetingID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// fetchNoteByCalendarEventID queries notes via calendar event instance ID.
|
||||
// Chain: primary calendar → mget_instance_relation_info → meeting_id → meeting.get → note_id
|
||||
func fetchNoteByCalendarEventID(ctx context.Context, runtime *common.RuntimeContext, instanceID string, calendarID string) map[string]any {
|
||||
errOut := runtime.IO().ErrOut
|
||||
|
||||
meetingIDs, err := resolveMeetingIDsFromCalendarEvent(runtime, instanceID, calendarID)
|
||||
if err != nil {
|
||||
return map[string]any{"calendar_event_id": instanceID, "error": err.Error()}
|
||||
}
|
||||
|
||||
if len(meetingIDs) > 1 {
|
||||
fmt.Fprintf(errOut, "%s event %s has %d meetings, trying each\n", logPrefix, sanitizeLogValue(instanceID), len(meetingIDs))
|
||||
}
|
||||
|
||||
// try each associated meeting until one has notes
|
||||
for _, meetingID := range meetingIDs {
|
||||
fmt.Fprintf(errOut, "%s event %s → meeting_id=%s\n", logPrefix, sanitizeLogValue(instanceID), sanitizeLogValue(meetingID))
|
||||
result := fetchNoteByMeetingID(ctx, runtime, meetingID)
|
||||
if result["error"] == nil {
|
||||
return result
|
||||
}
|
||||
// if this meeting has no notes, try next
|
||||
fmt.Fprintf(errOut, "%s meeting_id=%s: %s, trying next\n", logPrefix, sanitizeLogValue(meetingID), result["error"])
|
||||
}
|
||||
return map[string]any{"calendar_event_id": instanceID, "error": "no notes found in any associated meeting"}
|
||||
@@ -252,25 +264,16 @@ func downloadTranscriptFile(runtime *common.RuntimeContext, minuteToken string,
|
||||
base = outDir
|
||||
}
|
||||
dirName := filepath.Join(base, sanitizeDirName(title, minuteToken))
|
||||
transcriptPath := filepath.Join(dirName, "transcript.txt")
|
||||
|
||||
// Overwrite check via FileIO.Stat
|
||||
if !runtime.Bool("overwrite") {
|
||||
transcriptPath := filepath.Join(dirName, "transcript.txt")
|
||||
if _, statErr := vfs.Stat(transcriptPath); statErr == nil {
|
||||
if _, statErr := runtime.FileIO().Stat(transcriptPath); statErr == nil {
|
||||
fmt.Fprintf(errOut, "%s transcript already exists: %s (use --overwrite to replace)\n", logPrefix, transcriptPath)
|
||||
return transcriptPath
|
||||
}
|
||||
}
|
||||
|
||||
transcriptPath := filepath.Join(dirName, "transcript.txt")
|
||||
safePath, err := validate.SafeOutputPath(transcriptPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "%s invalid transcript path: %v\n", logPrefix, err)
|
||||
return ""
|
||||
}
|
||||
if err := vfs.MkdirAll(filepath.Dir(safePath), 0755); err != nil {
|
||||
fmt.Fprintf(errOut, "%s failed to create directory: %v\n", logPrefix, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
fmt.Fprintf(errOut, "%s downloading transcript: %s\n", logPrefix, transcriptPath)
|
||||
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
@@ -293,8 +296,16 @@ func downloadTranscriptFile(runtime *common.RuntimeContext, minuteToken string,
|
||||
fmt.Fprintf(errOut, "%s transcript is empty (not available for this minute)\n", logPrefix)
|
||||
return ""
|
||||
}
|
||||
if err := validate.AtomicWrite(safePath, apiResp.RawBody, 0644); err != nil {
|
||||
fmt.Fprintf(errOut, "%s failed to write transcript: %v\n", logPrefix, err)
|
||||
if _, err := runtime.FileIO().Save(transcriptPath, fileio.SaveOptions{}, bytes.NewReader(apiResp.RawBody)); err != nil {
|
||||
var me *fileio.MkdirError
|
||||
switch {
|
||||
case errors.Is(err, fileio.ErrPathValidation):
|
||||
fmt.Fprintf(errOut, "%s invalid transcript path: %v\n", logPrefix, err)
|
||||
case errors.As(err, &me):
|
||||
fmt.Fprintf(errOut, "%s failed to create directory: %v\n", logPrefix, err)
|
||||
default:
|
||||
fmt.Fprintf(errOut, "%s failed to write transcript: %v\n", logPrefix, err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return transcriptPath
|
||||
|
||||
252
shortcuts/vc/vc_recording.go
Normal file
252
shortcuts/vc/vc_recording.go
Normal file
@@ -0,0 +1,252 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// vc +recording — query minute_token from meeting-ids or calendar-event-ids
|
||||
//
|
||||
// Two mutually exclusive input modes:
|
||||
// meeting-ids: recording API → extract minute_token from URL
|
||||
// calendar-event-ids: primary calendar → mget_instance_relation_info → meeting_id → recording API
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const recordingLogPrefix = "[vc +recording]"
|
||||
|
||||
var (
|
||||
scopesRecordingMeetingIDs = []string{
|
||||
"vc:record:readonly",
|
||||
}
|
||||
scopesRecordingCalendarEventIDs = []string{
|
||||
"vc:record:readonly",
|
||||
"calendar:calendar:read",
|
||||
"calendar:calendar.event:read",
|
||||
}
|
||||
)
|
||||
|
||||
// extractMinuteToken parses minute_token from a recording URL.
|
||||
// URL format: https://meetings.feishu.cn/minutes/{minute_token}
|
||||
func extractMinuteToken(recordingURL string) string {
|
||||
u, err := url.Parse(recordingURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(strings.TrimRight(u.Path, "/"), "/")
|
||||
for i, p := range parts {
|
||||
if p == "minutes" && i+1 < len(parts) {
|
||||
return parts[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// fetchRecordingByMeetingID queries recording info for a single meeting.
|
||||
func fetchRecordingByMeetingID(_ context.Context, runtime *common.RuntimeContext, meetingID string) map[string]any {
|
||||
data, err := runtime.DoAPIJSON(http.MethodGet,
|
||||
fmt.Sprintf("/open-apis/vc/v1/meetings/%s/recording", validate.EncodePathSegment(meetingID)),
|
||||
nil, nil)
|
||||
if err != nil {
|
||||
return map[string]any{"meeting_id": meetingID, "error": fmt.Sprintf("failed to query recording: %v", err)}
|
||||
}
|
||||
|
||||
recording, _ := data["recording"].(map[string]any)
|
||||
if recording == nil {
|
||||
return map[string]any{"meeting_id": meetingID, "error": "no recording available for this meeting"}
|
||||
}
|
||||
|
||||
recordingURL, _ := recording["url"].(string)
|
||||
duration, _ := recording["duration"].(string)
|
||||
|
||||
result := map[string]any{"meeting_id": meetingID}
|
||||
if recordingURL != "" {
|
||||
result["recording_url"] = recordingURL
|
||||
}
|
||||
if duration != "" {
|
||||
result["duration"] = duration
|
||||
}
|
||||
if token := extractMinuteToken(recordingURL); token != "" {
|
||||
result["minute_token"] = token
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// VCRecording gets meeting recording info and extracts minute_token.
|
||||
var VCRecording = common.Shortcut{
|
||||
Service: "vc",
|
||||
Command: "+recording",
|
||||
Description: "Query minute_token from meeting-ids or calendar-event-ids",
|
||||
Risk: "read",
|
||||
Scopes: []string{"vc:record:readonly"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "meeting-ids", Desc: "meeting IDs, comma-separated for batch"},
|
||||
{Name: "calendar-event-ids", Desc: "calendar event instance IDs, comma-separated for batch"},
|
||||
},
|
||||
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := common.ExactlyOne(runtime, "meeting-ids", "calendar-event-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
const maxBatchSize = 50
|
||||
for _, flag := range []string{"meeting-ids", "calendar-event-ids"} {
|
||||
if v := runtime.Str(flag); v != "" {
|
||||
if ids := common.SplitCSV(v); len(ids) > maxBatchSize {
|
||||
return output.ErrValidation("--%s: too many IDs (%d), maximum is %d", flag, len(ids), maxBatchSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
var required []string
|
||||
switch {
|
||||
case runtime.Str("meeting-ids") != "":
|
||||
required = scopesRecordingMeetingIDs
|
||||
case runtime.Str("calendar-event-ids") != "":
|
||||
required = scopesRecordingCalendarEventIDs
|
||||
}
|
||||
appID := runtime.Config.AppID
|
||||
userOpenID := runtime.UserOpenId()
|
||||
if appID != "" && userOpenID != "" {
|
||||
stored := auth.GetStoredToken(appID, userOpenID)
|
||||
if stored != nil {
|
||||
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if ids := runtime.Str("meeting-ids"); ids != "" {
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/vc/v1/meetings/{meeting_id}/recording").
|
||||
Set("meeting_ids", common.SplitCSV(ids)).
|
||||
Set("steps", "meeting recording API → extract minute_token from URL")
|
||||
}
|
||||
ids := runtime.Str("calendar-event-ids")
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/calendar/v4/calendars/primary").
|
||||
POST("/open-apis/calendar/v4/calendars/{calendar_id}/events/mget_instance_relation_info").
|
||||
GET("/open-apis/vc/v1/meetings/{meeting_id}/recording").
|
||||
Set("calendar_event_ids", common.SplitCSV(ids)).
|
||||
Set("steps", "primary calendar → mget_instance_relation_info → meeting_id → recording API → extract minute_token")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
errOut := runtime.IO().ErrOut
|
||||
var results []any
|
||||
|
||||
const batchDelay = 100 * time.Millisecond
|
||||
|
||||
if ids := runtime.Str("meeting-ids"); ids != "" {
|
||||
meetingIDs := common.SplitCSV(ids)
|
||||
fmt.Fprintf(errOut, "%s querying %d meeting_id(s)\n", recordingLogPrefix, len(meetingIDs))
|
||||
for i, id := range meetingIDs {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if i > 0 {
|
||||
time.Sleep(batchDelay)
|
||||
}
|
||||
fmt.Fprintf(errOut, "%s querying meeting_id=%s ...\n", recordingLogPrefix, sanitizeLogValue(id))
|
||||
results = append(results, fetchRecordingByMeetingID(ctx, runtime, id))
|
||||
}
|
||||
} else {
|
||||
instanceIDs := common.SplitCSV(runtime.Str("calendar-event-ids"))
|
||||
fmt.Fprintf(errOut, "%s querying %d calendar_event_id(s)\n", recordingLogPrefix, len(instanceIDs))
|
||||
calendarID, err := getPrimaryCalendarID(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(errOut, "%s primary calendar: %s\n", recordingLogPrefix, calendarID)
|
||||
for i, instanceID := range instanceIDs {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if i > 0 {
|
||||
time.Sleep(batchDelay)
|
||||
}
|
||||
fmt.Fprintf(errOut, "%s resolving calendar_event_id=%s ...\n", recordingLogPrefix, sanitizeLogValue(instanceID))
|
||||
meetingIDs, resolveErr := resolveMeetingIDsFromCalendarEvent(runtime, instanceID, calendarID)
|
||||
if resolveErr != nil {
|
||||
results = append(results, map[string]any{"calendar_event_id": instanceID, "error": resolveErr.Error()})
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, meetingID := range meetingIDs {
|
||||
fmt.Fprintf(errOut, "%s event %s → meeting_id=%s\n", recordingLogPrefix, sanitizeLogValue(instanceID), sanitizeLogValue(meetingID))
|
||||
result := fetchRecordingByMeetingID(ctx, runtime, meetingID)
|
||||
if result["error"] == nil {
|
||||
result["calendar_event_id"] = instanceID
|
||||
results = append(results, result)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(errOut, "%s meeting_id=%s: %s, trying next\n", recordingLogPrefix, sanitizeLogValue(meetingID), result["error"])
|
||||
}
|
||||
if !found {
|
||||
results = append(results, map[string]any{"calendar_event_id": instanceID, "error": "no recording found in any associated meeting"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
for _, r := range results {
|
||||
m, _ := r.(map[string]any)
|
||||
if m["error"] == nil {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", recordingLogPrefix, len(results), successCount, len(results)-successCount)
|
||||
|
||||
if successCount == 0 && len(results) > 0 {
|
||||
outData := map[string]any{"recordings": results}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, nil)
|
||||
return output.ErrAPI(0, fmt.Sprintf("all %d queries failed", len(results)), nil)
|
||||
}
|
||||
|
||||
outData := map[string]any{"recordings": results}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, func(w io.Writer) {
|
||||
var rows []map[string]interface{}
|
||||
for _, r := range results {
|
||||
m, _ := r.(map[string]any)
|
||||
meetingID, _ := m["meeting_id"].(string)
|
||||
row := map[string]interface{}{}
|
||||
if meetingID != "" {
|
||||
row["meeting_id"] = meetingID
|
||||
}
|
||||
if calEventID, _ := m["calendar_event_id"].(string); calEventID != "" {
|
||||
row["calendar_event_id"] = calEventID
|
||||
}
|
||||
if errMsg, _ := m["error"].(string); errMsg != "" {
|
||||
row["status"] = "FAIL"
|
||||
row["error"] = errMsg
|
||||
} else {
|
||||
row["status"] = "OK"
|
||||
if v, _ := m["minute_token"].(string); v != "" {
|
||||
row["minute_token"] = v
|
||||
}
|
||||
if v, _ := m["duration"].(string); v != "" {
|
||||
row["duration"] = v
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
fmt.Fprintf(w, "\n%d recording(s), %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
774
shortcuts/vc/vc_recording_test.go
Normal file
774
shortcuts/vc/vc_recording_test.go
Normal file
@@ -0,0 +1,774 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests: extractMinuteToken
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestExtractMinuteToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{"standard feishu URL", "https://meetings.feishu.cn/minutes/obcn37dxcftoc3656rgyejm7", "obcn37dxcftoc3656rgyejm7"},
|
||||
{"larksuite URL", "https://meetings.larksuite.com/minutes/obcn12345678", "obcn12345678"},
|
||||
{"trailing slash", "https://meetings.feishu.cn/minutes/obcntoken123/", "obcntoken123"},
|
||||
{"with query params", "https://meetings.feishu.cn/minutes/obcntoken123?from=share", "obcntoken123"},
|
||||
{"with fragment", "https://meetings.feishu.cn/minutes/obcntoken123#section", "obcntoken123"},
|
||||
{"empty URL", "", ""},
|
||||
{"no minutes path", "https://meetings.feishu.cn/other/path", ""},
|
||||
{"only domain", "https://meetings.feishu.cn", ""},
|
||||
{"minutes at end with no token", "https://meetings.feishu.cn/minutes", ""},
|
||||
{"minutes trailing slash only", "https://meetings.feishu.cn/minutes/", ""},
|
||||
{"invalid URL", "://invalid", ""},
|
||||
{"nested path after token", "https://meetings.feishu.cn/minutes/obcntoken123/extra/path", "obcntoken123"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractMinuteToken(tt.url)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractMinuteToken(%q) = %q, want %q", tt.url, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRecording_Validation_ExactlyOne(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
// 没传任何 flag
|
||||
err := mountAndRun(t, VCRecording, []string{"+recording", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for no flags")
|
||||
}
|
||||
|
||||
// 两个 flag 都传了
|
||||
err = mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", "m1", "--calendar-event-ids", "e1", "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for two flags")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecording_BatchLimit_MeetingIDs(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
ids := make([]string, 51)
|
||||
for i := range ids {
|
||||
ids[i] = fmt.Sprintf("m%d", i)
|
||||
}
|
||||
err := mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", strings.Join(ids, ","), "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected batch limit error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "too many IDs") {
|
||||
t.Errorf("expected 'too many IDs' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecording_BatchLimit_CalendarEventIDs(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
ids := make([]string, 51)
|
||||
for i := range ids {
|
||||
ids[i] = fmt.Sprintf("e%d", i)
|
||||
}
|
||||
err := mountAndRun(t, VCRecording, []string{"+recording", "--calendar-event-ids", strings.Join(ids, ","), "--as", "user"}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected batch limit error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "too many IDs") {
|
||||
t.Errorf("expected 'too many IDs' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DryRun tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRecording_DryRun_MeetingIDs(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", "m001", "--dry-run", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "recording") {
|
||||
t.Errorf("dry-run should show recording API, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "minute_token") {
|
||||
t.Errorf("dry-run should mention minute_token, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecording_DryRun_CalendarEventIDs(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCRecording, []string{"+recording", "--calendar-event-ids", "evt001", "--dry-run", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "mget_instance_relation_info") {
|
||||
t.Errorf("dry-run should show mget step, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "recording") {
|
||||
t.Errorf("dry-run should show recording step, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecording_DryRun_BatchIDs(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", "m001,m002,m003", "--dry-run", "--as", "user"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "m001") || !strings.Contains(out, "m002") || !strings.Contains(out, "m003") {
|
||||
t.Errorf("dry-run should list all meeting IDs, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests: fetchRecordingByMeetingID via bot shortcut wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFetchRecordingByMeetingID_Success(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/meetings/m001/recording",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"recording": map[string]interface{}{
|
||||
"url": "https://meetings.feishu.cn/minutes/obcntoken123",
|
||||
"duration": "30000",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
s := common.Shortcut{
|
||||
Service: "test",
|
||||
Command: "+fetch-recording",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
result := fetchRecordingByMeetingID(context.Background(), rctx, "m001")
|
||||
if result["error"] != nil {
|
||||
t.Errorf("unexpected error: %v", result["error"])
|
||||
}
|
||||
if result["minute_token"] != "obcntoken123" {
|
||||
t.Errorf("minute_token = %v, want obcntoken123", result["minute_token"])
|
||||
}
|
||||
if result["duration"] != "30000" {
|
||||
t.Errorf("duration = %v, want 30000", result["duration"])
|
||||
}
|
||||
if result["meeting_id"] != "m001" {
|
||||
t.Errorf("meeting_id = %v, want m001", result["meeting_id"])
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
parent := &cobra.Command{Use: "vc"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+fetch-recording"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRecordingByMeetingID_NoRecording(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/meetings/m002/recording",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
s := common.Shortcut{
|
||||
Service: "test",
|
||||
Command: "+fetch-no-recording",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
result := fetchRecordingByMeetingID(context.Background(), rctx, "m002")
|
||||
errMsg, _ := result["error"].(string)
|
||||
if errMsg == "" {
|
||||
t.Error("expected error for missing recording")
|
||||
}
|
||||
if !strings.Contains(errMsg, "no recording") {
|
||||
t.Errorf("error should mention no recording, got: %s", errMsg)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
parent := &cobra.Command{Use: "vc"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+fetch-no-recording"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRecordingByMeetingID_APIError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/meetings/m003/recording",
|
||||
Body: map[string]interface{}{
|
||||
"code": 121004, "msg": "data not found",
|
||||
},
|
||||
})
|
||||
|
||||
s := common.Shortcut{
|
||||
Service: "test",
|
||||
Command: "+fetch-api-error",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
result := fetchRecordingByMeetingID(context.Background(), rctx, "m003")
|
||||
errMsg, _ := result["error"].(string)
|
||||
if errMsg == "" {
|
||||
t.Error("expected error for API failure")
|
||||
}
|
||||
if !strings.Contains(errMsg, "failed to query recording") {
|
||||
t.Errorf("error should mention query failure, got: %s", errMsg)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
parent := &cobra.Command{Use: "vc"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+fetch-api-error"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRecordingByMeetingID_URLWithoutMinuteToken(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/meetings/m004/recording",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"recording": map[string]interface{}{
|
||||
"url": "https://example.com/some/other/path",
|
||||
"duration": "5000",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
s := common.Shortcut{
|
||||
Service: "test",
|
||||
Command: "+fetch-no-token",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
result := fetchRecordingByMeetingID(context.Background(), rctx, "m004")
|
||||
if result["error"] != nil {
|
||||
t.Errorf("should not error even without minute_token: %v", result["error"])
|
||||
}
|
||||
if _, exists := result["minute_token"]; exists {
|
||||
t.Error("should not have minute_token for non-standard URL")
|
||||
}
|
||||
if result["recording_url"] != "https://example.com/some/other/path" {
|
||||
t.Errorf("recording_url = %v, want the original URL", result["recording_url"])
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
parent := &cobra.Command{Use: "vc"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+fetch-no-token"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests: resolveMeetingIDsFromCalendarEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestResolveMeetingIDs_TypeCoercion(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_001/events/mget_instance_relation_info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"instance_relation_infos": []interface{}{
|
||||
map[string]interface{}{
|
||||
"meeting_instance_ids": []interface{}{
|
||||
float64(12345678),
|
||||
"string_id",
|
||||
nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
s := common.Shortcut{
|
||||
Service: "test",
|
||||
Command: "+resolve-test",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
ids, err := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) != 2 {
|
||||
t.Errorf("expected 2 IDs (nil skipped), got %d: %v", len(ids), ids)
|
||||
}
|
||||
if len(ids) > 0 && ids[0] != "12345678" {
|
||||
t.Errorf("expected float64 coerced to string, got %q", ids[0])
|
||||
}
|
||||
if len(ids) > 1 && ids[1] != "string_id" {
|
||||
t.Errorf("expected string preserved, got %q", ids[1])
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
parent := &cobra.Command{Use: "vc"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+resolve-test"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMeetingIDs_NoMeetings(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_001/events/mget_instance_relation_info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"instance_relation_infos": []interface{}{
|
||||
map[string]interface{}{
|
||||
"meeting_instance_ids": []interface{}{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
s := common.Shortcut{
|
||||
Service: "test",
|
||||
Command: "+resolve-no-meetings",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001")
|
||||
if err == nil {
|
||||
t.Error("expected error for no meetings")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no associated video meeting") {
|
||||
t.Errorf("error should mention no meeting, got: %v", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
parent := &cobra.Command{Use: "vc"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+resolve-no-meetings"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMeetingIDs_NoRelationInfo(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
warmTokenCache(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_001/events/mget_instance_relation_info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"instance_relation_infos": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
s := common.Shortcut{
|
||||
Service: "test",
|
||||
Command: "+resolve-no-info",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001")
|
||||
if err == nil {
|
||||
t.Error("expected error for no relation info")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no event relation info found") {
|
||||
t.Errorf("error should mention no info, got: %v", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
parent := &cobra.Command{Use: "vc"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+resolve-no-info"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration tests: Execute path via bot wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// botExec runs a function within a bot shortcut context, reusing the httpmock registry.
|
||||
func botExec(t *testing.T, name string, f *cmdutil.Factory, fn func(context.Context, *common.RuntimeContext) error) error {
|
||||
t.Helper()
|
||||
warmTokenCache(t)
|
||||
s := common.Shortcut{
|
||||
Service: "test",
|
||||
Command: "+" + name,
|
||||
AuthTypes: []string{"bot"},
|
||||
HasFormat: true,
|
||||
Execute: fn,
|
||||
}
|
||||
parent := &cobra.Command{Use: "vc"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+" + name, "--format", "json"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func TestRecording_Execute_MeetingIDs_PartialFailure(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
// m001 succeeds, m002 fails (API error)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/meetings/m001/recording",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"recording": map[string]interface{}{
|
||||
"url": "https://meetings.feishu.cn/minutes/obcnpartial1",
|
||||
"duration": "10000",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/meetings/m002/recording",
|
||||
Body: map[string]interface{}{"code": 121004, "msg": "data not found"},
|
||||
})
|
||||
|
||||
err := botExec(t, "partial-fail", f, func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
r1 := fetchRecordingByMeetingID(ctx, rctx, "m001")
|
||||
r2 := fetchRecordingByMeetingID(ctx, rctx, "m002")
|
||||
|
||||
if r1["error"] != nil {
|
||||
t.Errorf("m001 should succeed, got error: %v", r1["error"])
|
||||
}
|
||||
if r1["minute_token"] != "obcnpartial1" {
|
||||
t.Errorf("m001 minute_token = %v, want obcnpartial1", r1["minute_token"])
|
||||
}
|
||||
if r2["error"] == nil {
|
||||
t.Error("m002 should fail")
|
||||
}
|
||||
|
||||
// verify counting logic
|
||||
results := []any{r1, r2}
|
||||
successCount := 0
|
||||
for _, r := range results {
|
||||
m, _ := r.(map[string]any)
|
||||
if m["error"] == nil {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
if successCount != 1 {
|
||||
t.Errorf("expected 1 success, got %d", successCount)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecording_Execute_CalendarPath_ResolveAndFetch(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_001/events/mget_instance_relation_info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"instance_relation_infos": []interface{}{
|
||||
map[string]interface{}{
|
||||
"meeting_instance_ids": []interface{}{"m001"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/meetings/m001/recording",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"recording": map[string]interface{}{
|
||||
"url": "https://meetings.feishu.cn/minutes/obcnfromcal",
|
||||
"duration": "60000",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := botExec(t, "cal-resolve", f, func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
ids, resolveErr := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001")
|
||||
if resolveErr != nil {
|
||||
t.Fatalf("resolve failed: %v", resolveErr)
|
||||
}
|
||||
if len(ids) != 1 || ids[0] != "m001" {
|
||||
t.Fatalf("expected [m001], got %v", ids)
|
||||
}
|
||||
|
||||
result := fetchRecordingByMeetingID(ctx, rctx, ids[0])
|
||||
if result["error"] != nil {
|
||||
t.Errorf("fetch should succeed, got: %v", result["error"])
|
||||
}
|
||||
if result["minute_token"] != "obcnfromcal" {
|
||||
t.Errorf("minute_token = %v, want obcnfromcal", result["minute_token"])
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecording_Execute_CalendarPath_MultiMeetingFallback(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
// calendar resolve returns two meetings: m001 (no recording) and m002 (has recording)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_001/events/mget_instance_relation_info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"instance_relation_infos": []interface{}{
|
||||
map[string]interface{}{
|
||||
"meeting_instance_ids": []interface{}{"m001", "m002"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/meetings/m001/recording",
|
||||
Body: map[string]interface{}{"code": 121004, "msg": "data not found"},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/meetings/m002/recording",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"recording": map[string]interface{}{
|
||||
"url": "https://meetings.feishu.cn/minutes/obcnfallback",
|
||||
"duration": "45000",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := botExec(t, "cal-fallback", f, func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
ids, resolveErr := resolveMeetingIDsFromCalendarEvent(rctx, "evt_001", "cal_001")
|
||||
if resolveErr != nil {
|
||||
t.Fatalf("resolve failed: %v", resolveErr)
|
||||
}
|
||||
if len(ids) != 2 {
|
||||
t.Fatalf("expected 2 meeting IDs, got %d", len(ids))
|
||||
}
|
||||
|
||||
// simulate fallback: try each until success
|
||||
var found bool
|
||||
for _, meetingID := range ids {
|
||||
result := fetchRecordingByMeetingID(ctx, rctx, meetingID)
|
||||
if result["error"] == nil {
|
||||
if result["minute_token"] != "obcnfallback" {
|
||||
t.Errorf("minute_token = %v, want obcnfallback", result["minute_token"])
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected fallback to succeed on m002")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecording_Execute_AllFailed_ErrorMessage(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/meetings/m001/recording",
|
||||
Body: map[string]interface{}{"code": 121004, "msg": "data not found"},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/meetings/m002/recording",
|
||||
Body: map[string]interface{}{"code": 121005, "msg": "no permission"},
|
||||
})
|
||||
|
||||
err := botExec(t, "all-fail", f, func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
r1 := fetchRecordingByMeetingID(ctx, rctx, "m001")
|
||||
r2 := fetchRecordingByMeetingID(ctx, rctx, "m002")
|
||||
|
||||
if r1["error"] == nil || r2["error"] == nil {
|
||||
t.Error("both should fail")
|
||||
}
|
||||
e1, _ := r1["error"].(string)
|
||||
e2, _ := r2["error"].(string)
|
||||
if !strings.Contains(e1, "data not found") {
|
||||
t.Errorf("m001 error should contain API message, got: %s", e1)
|
||||
}
|
||||
if !strings.Contains(e2, "no permission") {
|
||||
t.Errorf("m002 error should contain API message, got: %s", e2)
|
||||
}
|
||||
if r1["meeting_id"] != "m001" {
|
||||
t.Errorf("error result should preserve meeting_id, got: %v", r1["meeting_id"])
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecording_Execute_EmptyURL(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/meetings/m001/recording",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"recording": map[string]interface{}{
|
||||
"url": "",
|
||||
"duration": "1000",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := botExec(t, "empty-url", f, func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
result := fetchRecordingByMeetingID(ctx, rctx, "m001")
|
||||
if result["error"] != nil {
|
||||
t.Errorf("empty URL should not cause error: %v", result["error"])
|
||||
}
|
||||
if _, exists := result["minute_token"]; exists {
|
||||
t.Error("empty URL should not produce minute_token")
|
||||
}
|
||||
if _, exists := result["recording_url"]; exists {
|
||||
t.Error("empty URL should not produce recording_url")
|
||||
}
|
||||
if result["duration"] != "1000" {
|
||||
t.Errorf("duration should be preserved, got: %v", result["duration"])
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecording_Execute_RecordingGenerating(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/vc/v1/meetings/m001/recording",
|
||||
Body: map[string]interface{}{"code": 124002, "msg": "recording generating"},
|
||||
})
|
||||
|
||||
err := botExec(t, "generating", f, func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
result := fetchRecordingByMeetingID(ctx, rctx, "m001")
|
||||
errMsg, _ := result["error"].(string)
|
||||
if errMsg == "" {
|
||||
t.Error("should return error for generating recording")
|
||||
}
|
||||
if !strings.Contains(errMsg, "recording generating") {
|
||||
t.Errorf("error should mention recording generating, got: %s", errMsg)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ lark-cli docs +media-insert --doc doxcnXXX --file ./arch.png --align center --ca
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--doc <id>` | 是 | 文档 ID 或 docx URL(仅支持 `/docx/<document_id>` 形式自动提取;**不支持 `/wiki/...` URL 自动提取**) |
|
||||
| `--file <path>` | 是 | 本地文件路径(最大 20MB) |
|
||||
| `--file <path>` | 是 | 本地文件路径(文件大于 20MB 时自动切换分片上传) |
|
||||
| `--type <type>` | 否 | `image`(默认)或 `file` |
|
||||
| `--align <align>` | 否 | 仅图片:`left` / `center`(默认)/ `right` |
|
||||
| `--caption <text>` | 否 | 仅图片:图片描述 |
|
||||
|
||||
@@ -27,8 +27,8 @@ lark-cli mail +draft-create --to alice@example.com --subject '周报' \
|
||||
# 不带收件人的 HTML 草稿(用户之后可自行添加)
|
||||
lark-cli mail +draft-create --subject '周报' --body '<p>草稿内容</p>'
|
||||
|
||||
# 带附件和内嵌图片的 HTML 草稿(CID 为唯一标识符,可用随机十六进制字符串)
|
||||
lark-cli mail +draft-create --to alice@example.com --subject '预览图' --body '<img src="cid:a1b2c3d4e5f6a7b8c9d0">' --attach ./report.pdf --inline '[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'
|
||||
# 带附件和内嵌图片的 HTML 草稿(推荐:直接用相对路径,自动解析)
|
||||
lark-cli mail +draft-create --to alice@example.com --subject '预览图' --body '<p>见附件和图:<img src="./logo.png" /></p>' --attach ./report.pdf
|
||||
|
||||
# 纯文本草稿(仅在内容极简时使用)
|
||||
lark-cli mail +draft-create --to alice@example.com --subject '简短通知' --body '收到,谢谢'
|
||||
@@ -43,13 +43,13 @@ lark-cli mail +draft-create --to alice@example.com --subject '测试' --body 'te
|
||||
|------|------|------|
|
||||
| `--to <emails>` | 否 | 完整收件人列表,多个用逗号分隔。支持 `Alice <alice@example.com>` 格式。省略时草稿不带收件人(之后可通过 `+draft-edit` 添加) |
|
||||
| `--subject <text>` | 是 | 草稿主题 |
|
||||
| `--body <text>` | 是 | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式 |
|
||||
| `--body <text>` | 是 | 邮件正文。推荐使用 HTML 获得富文本排版;也支持纯文本(自动检测)。使用 `--plain-text` 可强制纯文本模式。支持 `<img src="./local.png" />` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) |
|
||||
| `--from <email>` | 否 | 发件人邮箱地址(作为邮箱选择器)。省略时使用当前登录用户的主邮箱地址 |
|
||||
| `--cc <emails>` | 否 | 完整抄送列表,多个用逗号分隔 |
|
||||
| `--bcc <emails>` | 否 | 完整密送列表,多个用逗号分隔 |
|
||||
| `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用 |
|
||||
| `--attach <paths>` | 否 | 普通附件文件路径,多个用逗号分隔。相对路径 |
|
||||
| `--inline <json>` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`(相对路径)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `<img src="cid:a1b2c3d4e5f6a7b8c9d0">` 引用 |
|
||||
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
|
||||
| `--format <mode>` | 否 | 输出格式:`json`(默认)/ `pretty` / `table` / `ndjson` / `csv` |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
@@ -83,8 +83,16 @@ lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_
|
||||
|
||||
### 创建带内嵌图片的 HTML 草稿
|
||||
|
||||
> **推荐方式:** 直接在 `--body` HTML 中使用 `<img src="./logo.png" />`(相对路径),系统会自动创建内嵌 MIME 部分并替换为 `cid:` 引用。仅支持相对路径(如 `./logo.png`),不支持绝对路径(如 `/tmp/logo.png`)。
|
||||
|
||||
```bash
|
||||
# CID 为唯一标识符,可用随机十六进制字符串
|
||||
# 推荐:直接使用相对路径,自动解析为内嵌图片
|
||||
lark-cli mail +draft-create \
|
||||
--to alice@example.com \
|
||||
--subject '通讯稿' \
|
||||
--body '<h1>你好</h1><img src="./banner.png" />'
|
||||
|
||||
# 高级用法:手动指定 CID(CID 为唯一标识符,可用随机十六进制字符串)
|
||||
lark-cli mail +draft-create \
|
||||
--to alice@example.com \
|
||||
--subject '通讯稿' \
|
||||
|
||||
@@ -198,9 +198,9 @@ lark-cli mail +draft-edit --draft-id <draft_id> --inspect
|
||||
{ "op": "add_inline", "path": "./logo.png", "cid": "logo" }
|
||||
```
|
||||
|
||||
> **重要:`add_inline` 仅添加 MIME 二进制部分,不会在 HTML 正文中插入 `<img>` 标签。**
|
||||
> 如需图片在邮件正文中可见,**必须**同时使用 `set_body` 或 `set_reply_body` 更新 HTML 正文并加入 `<img src="cid:...">` 标签。参见[在正文中插入内嵌图片](#在正文中插入内嵌图片)的完整流程。
|
||||
> 如果忘记添加 `<img>` 引用,该内嵌部分在发送时会变成孤立附件。
|
||||
> **推荐方式:** 直接在 `set_body`/`set_reply_body` 的 HTML 中使用 `<img src="./logo.png" />`(相对路径),系统会自动创建 MIME 内嵌部分、生成 CID 并替换为 `cid:` 引用。仅支持相对路径(如 `./logo.png`),不支持绝对路径。删除或替换 `<img>` 标签时,对应的 MIME 部分会自动清理。详见[在正文中插入内嵌图片](#在正文中插入内嵌图片)。
|
||||
>
|
||||
> `add_inline` 仅在需要精确控制 CID 命名时使用。使用时仍需在 HTML 正文中加入 `<img src="cid:...">` 引用。
|
||||
|
||||
`replace_inline`
|
||||
|
||||
@@ -304,23 +304,18 @@ lark-cli mail +draft-edit --draft-id <draft_id> --patch-file ./patch.json
|
||||
|
||||
### 在正文中插入内嵌图片
|
||||
|
||||
添加内嵌图片需要**两个协同编辑**:(1)通过 `add_inline` 添加 MIME 部分,(2)通过 `set_body` 或 `set_reply_body` 在 HTML 正文中插入 `<img src="cid:...">` 标签。
|
||||
直接在 `set_body`/`set_reply_body` 的 HTML 中使用相对路径即可(如 `./logo.png`,不支持绝对路径)。系统会自动创建 MIME 内嵌部分并替换为 `cid:` 引用。
|
||||
|
||||
```bash
|
||||
# 1. 查看草稿以获取当前 HTML 正文和已有的内嵌部分
|
||||
# 1. 查看草稿以获取当前 HTML 正文
|
||||
lark-cli mail +draft-edit --draft-id <draft_id> --inspect
|
||||
# 返回包含:
|
||||
# projection.body_html_summary: "<div>原有内容<img src=\"cid:existing.png\" /></div>"
|
||||
# projection.inline_summary: [{"part_id":"1.1.2","cid":"existing.png", ...}]
|
||||
|
||||
# 2. 编写补丁(注意:回复草稿用 set_reply_body,普通草稿用 set_body)
|
||||
# 2. 编写补丁 — 直接使用相对路径(注意:回复草稿用 set_reply_body,普通草稿用 set_body)
|
||||
cat > ./patch.json << 'EOF'
|
||||
{
|
||||
"ops": [
|
||||
{ "op": "set_body", "value": "<div>原有内容<img src=\"cid:existing.png\" /><img src=\"cid:new-image\" /></div>" },
|
||||
{ "op": "add_inline", "path": "./new-image.png", "cid": "new-image" }
|
||||
],
|
||||
"options": {}
|
||||
{ "op": "set_body", "value": "<div>内容<img src=\"./logo.png\" /><img src=\"./photo.jpg\" /></div>" }
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
@@ -328,6 +323,13 @@ EOF
|
||||
lark-cli mail +draft-edit --draft-id <draft_id> --patch-file ./patch.json
|
||||
```
|
||||
|
||||
内嵌图片的增删改通过 HTML 正文自动联动:
|
||||
- **添加**:在 HTML 中写 `<img src="./image.png" />`,自动创建 MIME 部分
|
||||
- **删除**:从 HTML 中移除 `<img>` 标签,对应 MIME 部分自动清理
|
||||
- **替换**:将 `src` 改为新的相对路径,旧 MIME 部分自动移除、新部分自动创建
|
||||
|
||||
> **高级用法:** 需要精确控制 CID 命名时,仍可使用 `add_inline` 手动添加 MIME 部分,并在 HTML 中用 `<img src="cid:your-cid">` 引用。
|
||||
|
||||
### 使用 patch-file 进行高级编辑
|
||||
|
||||
```bash
|
||||
|
||||
@@ -39,8 +39,8 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --body '<p
|
||||
# 转发并附加说明 + 抄送(草稿)
|
||||
lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --cc bob@example.com --body '<b>请参考</b>'
|
||||
|
||||
# 转发时插入内嵌图片(CID 为唯一标识符,可用随机字符串)
|
||||
lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --body '<img src="cid:a1b2c3d4e5f6a7b8c9d0"> 详见图示。' --inline '[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'
|
||||
# 转发时插入内嵌图片(推荐:直接用相对路径,自动解析)
|
||||
lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --body '<p>详见图示:<img src="./logo.png" /></p>'
|
||||
|
||||
# 纯文本转发(仅在内容极简时使用)
|
||||
lark-cli mail +forward --message-id <邮件ID> --to alice@example.com
|
||||
@@ -58,13 +58,13 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --dry-run
|
||||
|------|------|------|
|
||||
| `--message-id <id>` | 是 | 被转发的邮件 ID |
|
||||
| `--to <emails>` | 是 | 收件人邮箱,多个用逗号分隔 |
|
||||
| `--body <text>` | 否 | 转发时附加的说明文字。推荐使用 HTML 获得富文本排版;也支持纯文本。根据转发正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式 |
|
||||
| `--body <text>` | 否 | 转发时附加的说明文字。推荐使用 HTML 获得富文本排版;也支持纯文本。根据转发正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `<img src="./local.png" />` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) |
|
||||
| `--from <email>` | 否 | 发件人邮箱地址(默认读取 user_mailboxes.profile.primary_email_address) |
|
||||
| `--cc <emails>` | 否 | 抄送邮箱,多个用逗号分隔 |
|
||||
| `--bcc <emails>` | 否 | 密送邮箱,多个用逗号分隔 |
|
||||
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 |
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔,追加在原邮件附件之后。相对路径 |
|
||||
| `--inline <json>` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`(相对路径)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `<img src="cid:...">` 引用 |
|
||||
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
|
||||
| `--confirm-send` | 否 | 确认发送转发(默认只保存草稿)。仅在用户明确确认后使用 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
|
||||
@@ -42,8 +42,8 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '<p>同步更新</p>' --
|
||||
# 从回复名单中排除某些地址(草稿)
|
||||
lark-cli mail +reply-all --message-id <邮件ID> --body '<p>见上</p>' --remove bot@example.com,noreply@example.com
|
||||
|
||||
# 回复全部时插入内嵌图片(CID 为唯一标识符,可用随机字符串)
|
||||
lark-cli mail +reply-all --message-id <邮件ID> --body '<img src="cid:a1b2c3d4e5f6a7b8c9d0"> 详见图示。' --inline '[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'
|
||||
# 回复全部时插入内嵌图片(推荐:直接用相对路径,自动解析)
|
||||
lark-cli mail +reply-all --message-id <邮件ID> --body '<p>详见图示:<img src="./logo.png" /></p>'
|
||||
|
||||
# 纯文本回复全部(仅在内容极简时使用)
|
||||
lark-cli mail +reply-all --message-id <邮件ID> --body '收到,已处理。'
|
||||
@@ -60,7 +60,7 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '测试' --dry-run
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--message-id <id>` | 是 | 被回复的邮件 ID |
|
||||
| `--body <text>` | 是 | 回复正文。推荐使用 HTML 获得富文本排版;也支持纯文本。根据回复正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式 |
|
||||
| `--body <text>` | 是 | 回复正文。推荐使用 HTML 获得富文本排版;也支持纯文本。根据回复正文和原邮件正文自动检测 HTML。使用 `--plain-text` 可强制纯文本模式。支持 `<img src="./local.png" />` 相对路径自动解析为内嵌图片(仅支持相对路径,不支持绝对路径) |
|
||||
| `--from <email>` | 否 | 发件人邮箱地址(默认读取 user_mailboxes.profile.primary_email_address) |
|
||||
| `--to <emails>` | 否 | 额外收件人,多个用逗号分隔(追加到自动聚合结果) |
|
||||
| `--cc <emails>` | 否 | 额外抄送,多个用逗号分隔 |
|
||||
@@ -68,7 +68,7 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '测试' --dry-run
|
||||
| `--remove <emails>` | 否 | 从自动聚合结果中排除的邮箱,多个用逗号分隔 |
|
||||
| `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 |
|
||||
| `--attach <paths>` | 否 | 附件文件路径,多个用逗号分隔。相对路径 |
|
||||
| `--inline <json>` | 否 | 内嵌图片 JSON 数组,每项包含 `cid`(唯一标识符,可用随机十六进制字符串,如 `a1b2c3d4e5f6a7b8c9d0`)和 `file_path`(相对路径)。格式:`'[{"cid":"a1b2c3d4e5f6a7b8c9d0","file_path":"./logo.png"}]'`。不可与 `--plain-text` 同时使用,在 body 中用 `<img src="cid:...">` 引用 |
|
||||
| `--inline <json>` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 `<img src="./path" />`(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `<img src="cid:mycid">` 引用。不可与 `--plain-text` 同时使用 |
|
||||
| `--confirm-send` | 否 | 确认发送回复(默认只保存草稿)。仅在用户明确确认后使用 |
|
||||
| `--dry-run` | 否 | 仅打印请求,不执行 |
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user