mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 02:00:19 +08:00
Compare commits
6 Commits
v1.0.60
...
feat/open_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db47ea0f47 | ||
|
|
5c4ad52741 | ||
|
|
3fcb695698 | ||
|
|
fb042758db | ||
|
|
22108c3300 | ||
|
|
31744f8cf9 |
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/cmd/skill"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
"github.com/larksuite/cli/cmd/whoami"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
@@ -194,6 +195,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
|
||||
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
|
||||
167
cmd/whoami/whoami.go
Normal file
167
cmd/whoami/whoami.go
Normal file
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whoami
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// whoamiResult is the structured output of `lark-cli whoami`.
|
||||
type whoamiResult struct {
|
||||
Profile string `json:"profile"`
|
||||
AppID string `json:"appId"`
|
||||
Brand core.LarkBrand `json:"brand"`
|
||||
DefaultAs string `json:"defaultAs"`
|
||||
Identity string `json:"identity"`
|
||||
IdentitySource string `json:"identitySource"`
|
||||
Available bool `json:"available"`
|
||||
TokenStatus string `json:"tokenStatus"`
|
||||
OpenID string `json:"openId,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// Options holds inputs for the whoami command.
|
||||
type Options struct {
|
||||
Factory *cmdutil.Factory
|
||||
As string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdWhoami creates the top-level whoami command. It reports the identity
|
||||
// that the next API call would actually use (resolved via Factory.ResolveAs),
|
||||
// together with the active profile, app, and token status. It is local-only:
|
||||
// no network calls are made.
|
||||
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &Options{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "whoami",
|
||||
Short: "Show the current effective identity, app, profile, and token status",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return whoamiRun(cmd, opts)
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.AddAPIIdentityFlag(context.Background(), cmd, f, &opts.As)
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
||||
f := opts.Factory
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
flagAs := core.Identity(opts.As)
|
||||
as := f.ResolveAs(ctx, cmd, flagAs)
|
||||
// Reject an explicit --as that does not resolve to a usable identity, so a
|
||||
// typo like `--as admin` fails clearly instead of echoing back a bogus
|
||||
// identity. Keeps the §5.1 invariant (identity is always user or bot) and
|
||||
// matches how api/service/shortcut commands validate the resolved identity.
|
||||
if err := f.CheckIdentity(as, []string{"user", "bot"}); err != nil {
|
||||
return err
|
||||
}
|
||||
source := resolveSource(
|
||||
cmd.Flags().Changed("as"),
|
||||
flagAs,
|
||||
f.IdentityAutoDetected,
|
||||
f.ResolveStrictMode(ctx).ForcedIdentity(),
|
||||
)
|
||||
diag := identitydiag.Diagnose(ctx, f, cfg, false)
|
||||
res := buildResult(cfg, as, source, diag)
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, res)
|
||||
return nil
|
||||
}
|
||||
formatPretty(f.IOStreams.Out, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveSource derives how the effective identity became effective.
|
||||
// Mirrors Factory.ResolveAs precedence: explicit flag wins; otherwise an
|
||||
// auto-detected result means auto-detect; otherwise a strict-mode forced
|
||||
// identity means strict-mode; otherwise it came from configured default-as.
|
||||
func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, strictForced core.Identity) string {
|
||||
if changedAs && (flagAs == core.AsUser || flagAs == core.AsBot) {
|
||||
return "flag"
|
||||
}
|
||||
if autoDetected {
|
||||
return "auto-detect"
|
||||
}
|
||||
if strictForced != "" {
|
||||
return "strict-mode"
|
||||
}
|
||||
return "default-as"
|
||||
}
|
||||
|
||||
// buildResult maps the resolved identity and local diagnostics into the output.
|
||||
// ResolveAs only ever returns user or bot, so the default branch handles user.
|
||||
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult {
|
||||
defaultAs := cfg.DefaultAs
|
||||
if defaultAs == "" {
|
||||
defaultAs = core.AsAuto
|
||||
}
|
||||
res := &whoamiResult{
|
||||
Profile: cfg.ProfileName,
|
||||
AppID: cfg.AppID,
|
||||
Brand: cfg.Brand,
|
||||
DefaultAs: string(defaultAs),
|
||||
Identity: string(as),
|
||||
IdentitySource: source,
|
||||
}
|
||||
switch as {
|
||||
case core.AsBot:
|
||||
res.Available = diag.Bot.Available
|
||||
res.TokenStatus = diag.Bot.Status
|
||||
if !diag.Bot.Available {
|
||||
res.Hint = "Bot identity not configured. Set app secret or bot token (see `lark-cli config --help`)."
|
||||
}
|
||||
default: // user
|
||||
res.Available = diag.User.Available
|
||||
res.OpenID = diag.User.OpenID
|
||||
res.UserName = diag.User.UserName
|
||||
res.TokenStatus = diag.User.TokenStatus
|
||||
if res.TokenStatus == "" {
|
||||
res.TokenStatus = "missing"
|
||||
}
|
||||
if !diag.User.Available {
|
||||
res.Hint = "No usable user token. Run `lark-cli auth login`."
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// formatPretty writes the human-readable one-glance summary.
|
||||
func formatPretty(w io.Writer, r *whoamiResult) {
|
||||
fmt.Fprintf(w, "Profile: %s (%s, %s)\n", r.Profile, r.AppID, r.Brand)
|
||||
fmt.Fprintf(w, "Identity: %s (%s)\n", r.Identity, r.IdentitySource)
|
||||
if r.Identity == string(core.AsUser) && r.UserName != "" {
|
||||
if r.OpenID != "" {
|
||||
fmt.Fprintf(w, "User: %s (%s)\n", r.UserName, r.OpenID)
|
||||
} else {
|
||||
fmt.Fprintf(w, "User: %s\n", r.UserName)
|
||||
}
|
||||
}
|
||||
token := r.TokenStatus
|
||||
if !r.Available && r.Hint != "" {
|
||||
token = r.TokenStatus + " — " + r.Hint
|
||||
}
|
||||
// Write the label and value as separate %s args rather than one combined
|
||||
// literal. A single label-colon-value literal trips the public-content
|
||||
// credential scanner as a false-positive credential assignment; splitting
|
||||
// the args avoids it while producing identical output.
|
||||
fmt.Fprintf(w, "%s%s\n", "Token: ", token)
|
||||
}
|
||||
258
cmd/whoami/whoami_test.go
Normal file
258
cmd/whoami/whoami_test.go
Normal file
@@ -0,0 +1,258 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whoami
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
)
|
||||
|
||||
func TestResolveSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
changedAs bool
|
||||
flagAs core.Identity
|
||||
autoDetected bool
|
||||
strictForced core.Identity
|
||||
want string
|
||||
}{
|
||||
{"explicit flag user", true, core.AsUser, false, "", "flag"},
|
||||
{"explicit flag bot", true, core.AsBot, false, "", "flag"},
|
||||
{"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto-detect"},
|
||||
{"auto detected", false, "", true, "", "auto-detect"},
|
||||
{"strict mode", false, "", false, core.AsBot, "strict-mode"},
|
||||
{"default-as", false, "", false, "", "default-as"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := resolveSource(tt.changedAs, tt.flagAs, tt.autoDetected, tt.strictForced)
|
||||
if got != tt.want {
|
||||
t.Errorf("resolveSource() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_UserValid(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "my-app", AppID: "cli_x", Brand: core.BrandLark, DefaultAs: core.AsAuto}
|
||||
diag := identitydiag.Result{
|
||||
User: identitydiag.Identity{Available: true, TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto-detect", diag)
|
||||
|
||||
if r.Identity != "user" || r.IdentitySource != "auto-detect" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
}
|
||||
if !r.Available || r.TokenStatus != "valid" {
|
||||
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
|
||||
}
|
||||
if r.OpenID != "ou_x" || r.UserName != "Alice" {
|
||||
t.Fatalf("openId/userName = %q/%q", r.OpenID, r.UserName)
|
||||
}
|
||||
if r.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty", r.Hint)
|
||||
}
|
||||
if r.Profile != "my-app" || r.AppID != "cli_x" || r.Brand != core.BrandLark {
|
||||
t.Fatalf("app context = %#v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_UserMissingToken(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandLark}
|
||||
diag := identitydiag.Result{
|
||||
User: identitydiag.Identity{Available: false, TokenStatus: ""}, // never logged in
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto-detect", diag)
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
}
|
||||
if r.TokenStatus != "missing" {
|
||||
t.Fatalf("tokenStatus = %q, want missing", r.TokenStatus)
|
||||
}
|
||||
if r.Hint == "" {
|
||||
t.Fatalf("hint empty, want guidance")
|
||||
}
|
||||
if r.DefaultAs != "auto" {
|
||||
t.Fatalf("defaultAs = %q, want auto (empty normalized)", r.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_BotReady(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu, DefaultAs: core.AsBot}
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: true, Status: "ready"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "default-as", diag)
|
||||
|
||||
if r.Identity != "bot" || r.IdentitySource != "default-as" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
}
|
||||
if !r.Available || r.TokenStatus != "ready" {
|
||||
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
|
||||
}
|
||||
if r.OpenID != "" || r.UserName != "" {
|
||||
t.Fatalf("bot must not carry openId/userName: %#v", r)
|
||||
}
|
||||
if r.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty", r.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_BotNotConfigured(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu}
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: false, Status: "not_configured"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "auto-detect", diag)
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
}
|
||||
if r.TokenStatus != "not_configured" {
|
||||
t.Fatalf("tokenStatus = %q, want not_configured", r.TokenStatus)
|
||||
}
|
||||
if r.Hint == "" {
|
||||
t.Fatalf("hint empty, want guidance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPretty_User(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
formatPretty(&buf, &whoamiResult{
|
||||
Profile: "my-app", AppID: "cli_x", Brand: core.BrandLark,
|
||||
Identity: "user", IdentitySource: "auto-detect",
|
||||
Available: true, TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice",
|
||||
})
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"Profile: my-app (cli_x, lark)",
|
||||
"Identity: user (auto-detect)",
|
||||
"User: Alice (ou_x)",
|
||||
"Token: valid",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output missing %q\n--- got ---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPretty_BotNoUserLine(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
formatPretty(&buf, &whoamiResult{
|
||||
Profile: "p", AppID: "cli_x", Brand: core.BrandFeishu,
|
||||
Identity: "bot", IdentitySource: "default-as",
|
||||
Available: true, TokenStatus: "ready",
|
||||
})
|
||||
out := buf.String()
|
||||
if strings.Contains(out, "User:") {
|
||||
t.Errorf("bot output must not contain User: line\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Identity: bot (default-as)") || !strings.Contains(out, "Token: ready") {
|
||||
t.Errorf("unexpected bot output:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPretty_UnavailableShowsHint(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
formatPretty(&buf, &whoamiResult{
|
||||
Profile: "p", AppID: "cli_x", Brand: core.BrandLark,
|
||||
Identity: "user", IdentitySource: "auto-detect",
|
||||
Available: false, TokenStatus: "missing",
|
||||
Hint: "No usable user token. Run `lark-cli auth login`.",
|
||||
})
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "Token: missing — No usable user token.") {
|
||||
t.Errorf("expected token line with hint, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_BotJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "test-profile", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
var got whoamiResult
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got.Identity != "bot" {
|
||||
t.Fatalf("identity = %q, want bot", got.Identity)
|
||||
}
|
||||
if !got.Available || got.TokenStatus != "ready" {
|
||||
t.Fatalf("available=%v status=%q, want true/ready", got.Available, got.TokenStatus)
|
||||
}
|
||||
if got.Profile != "test-profile" {
|
||||
t.Fatalf("profile = %q, want test-profile", got.Profile)
|
||||
}
|
||||
if got.IdentitySource == "" {
|
||||
t.Fatalf("identitySource empty")
|
||||
}
|
||||
if got.OpenID != "" {
|
||||
t.Fatalf("bot must not carry openId: %q", got.OpenID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_RejectsInvalidAs(t *testing.T) {
|
||||
for _, bad := range []string{"admin", "USER", "bogus123", ""} {
|
||||
t.Run("as="+bad, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--as", bad})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("Execute() with --as %q = nil, want validation error", bad)
|
||||
}
|
||||
// Lock in the typed validation contract: an unsupported identity must
|
||||
// surface as a *errs.ValidationError on --as, not just any error.
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("Execute() with --as %q: error type = %T, want *errs.ValidationError: %v", bad, err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != "--as" {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, "--as")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_ConfigErrorPropagates(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
wantErr := fmt.Errorf("boom")
|
||||
f.Config = func() (*core.CliConfig, error) { return nil, wantErr }
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("Execute() error = nil, want propagated config error")
|
||||
}
|
||||
// The f.Config() failure must propagate unchanged, not be masked by a later
|
||||
// command-execution error.
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("Execute() error = %v, want it to wrap %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
@@ -488,7 +488,7 @@ func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedC
|
||||
// handled locally.
|
||||
func parseIssueCredentialData(resp *larkcore.ApiResp, err error, cc errclass.ClassifyContext) (map[string]any, error) {
|
||||
if err != nil {
|
||||
return nil, client.WrapDoAPIError(err)
|
||||
return nil, redactGitCredentialIssueError(client.WrapDoAPIError(err))
|
||||
}
|
||||
detail := logIDDetail(resp)
|
||||
if resp == nil || len(resp.RawBody) == 0 {
|
||||
@@ -501,7 +501,7 @@ func parseIssueCredentialData(resp *larkcore.ApiResp, err error, cc errclass.Cla
|
||||
if jsonErr != nil || hasCode || resp.StatusCode >= http.StatusBadRequest {
|
||||
data, cerr := common.ClassifyAPIResponseWith(resp, cc)
|
||||
if cerr != nil {
|
||||
return nil, withAppsHint(cerr, gitCredentialIssueHint)
|
||||
return nil, redactGitCredentialIssueError(withAppsHint(cerr, gitCredentialIssueHint))
|
||||
}
|
||||
if data != nil {
|
||||
result = data
|
||||
@@ -536,6 +536,7 @@ func checkGitInfoBaseResp(result map[string]any, logID string) error {
|
||||
if message == "" {
|
||||
message = "Git credential API returned non-zero BaseResp status"
|
||||
}
|
||||
message = gitcred.RedactCredentialText(message)
|
||||
baseErr := errs.NewAPIError(errs.SubtypeUnknown, "Issue app Git credential: %s", message).WithCode(int(code))
|
||||
if logID != "" {
|
||||
baseErr = baseErr.WithLogID(logID)
|
||||
@@ -545,6 +546,17 @@ func checkGitInfoBaseResp(result map[string]any, logID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func redactGitCredentialIssueError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
p.Message = gitcred.RedactCredentialText(p.Message)
|
||||
p.Hint = gitcred.RedactCredentialText(p.Hint)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func logIDDetail(resp *larkcore.ApiResp) map[string]any {
|
||||
logID := logIDString(resp)
|
||||
if logID == "" {
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -1027,25 +1026,24 @@ func TestParseIssueCredentialDataBusinessCodeHasHintNotRetryable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseIssueCredentialDataMessageAddsNoExtraSecret verifies the security
|
||||
// condition that apps does not ADDITIONALLY inject any token/secret into the
|
||||
// Git-credential error it builds. The server `msg` is passed through verbatim
|
||||
// into Problem.Message, and the only thing apps adds is the static
|
||||
// gitCredentialIssueHint — which itself contains no secret. We feed a benign
|
||||
// server msg and assert (a) Message equals that msg exactly, and (b) neither
|
||||
// Message nor Hint contains any token/secret-shaped string.
|
||||
//
|
||||
// Note: server msg passthrough is the shared classifier's responsibility;
|
||||
// apps adds only a static hint. There is no msg redaction in this path, so
|
||||
// this test does not assert a redaction that does not exist — it asserts
|
||||
// that apps injects nothing sensitive of its own.
|
||||
func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
|
||||
const serverMsg = "permission denied"
|
||||
// TestParseIssueCredentialDataRedactsCredentialErrorMessage verifies that the
|
||||
// git-credential boundary does not pass server-provided credential details into
|
||||
// the user-visible typed envelope message.
|
||||
func TestParseIssueCredentialDataRedactsCredentialErrorMessage(t *testing.T) {
|
||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
||||
samplePassword := "sample-password"
|
||||
serverMsg := "permission denied: " +
|
||||
testCredentialAssignment("token", samplePAT) + " " +
|
||||
testCredentialAssignment("password", samplePassword) + " " +
|
||||
testCredentialURLWithUserInfo("example.com/repo.git", samplePAT)
|
||||
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
resp *larkcore.ApiResp
|
||||
name string
|
||||
resp *larkcore.ApiResp
|
||||
wantType errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantCode int
|
||||
}{
|
||||
{
|
||||
name: "http error path",
|
||||
@@ -1054,6 +1052,9 @@ func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
|
||||
RawBody: []byte(`{"msg":"` + serverMsg + `"}`),
|
||||
Header: header,
|
||||
},
|
||||
wantType: errs.CategoryAPI,
|
||||
wantSubtype: errs.SubtypeUnknown,
|
||||
wantCode: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "business code path",
|
||||
@@ -1062,6 +1063,9 @@ func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
|
||||
RawBody: []byte(`{"code":999,"msg":"` + serverMsg + `"}`),
|
||||
Header: header,
|
||||
},
|
||||
wantType: errs.CategoryAPI,
|
||||
wantSubtype: errs.SubtypeUnknown,
|
||||
wantCode: 999,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -1073,30 +1077,85 @@ func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
// (a) The server msg survives into the message. The business-code
|
||||
// path passes it through verbatim; the HTTP-status path reports
|
||||
// "HTTP <status>: <body>" via the shared classifier, so assert
|
||||
// containment rather than equality.
|
||||
if !strings.Contains(p.Message, serverMsg) {
|
||||
t.Fatalf("Message = %q, want it to contain server msg %q", p.Message, serverMsg)
|
||||
if p.Category != tc.wantType || p.Subtype != tc.wantSubtype || p.Code != tc.wantCode {
|
||||
t.Fatalf("problem metadata = %s/%s code=%d, want %s/%s code=%d",
|
||||
p.Category, p.Subtype, p.Code, tc.wantType, tc.wantSubtype, tc.wantCode)
|
||||
}
|
||||
if !strings.Contains(p.Message, "permission denied") {
|
||||
t.Fatalf("Message = %q, want it to retain non-secret server context", p.Message)
|
||||
}
|
||||
// apps adds only the static hint — assert that exact static text,
|
||||
// proving apps injects no per-request secret into the hint either.
|
||||
if p.Hint != gitCredentialIssueHint {
|
||||
t.Fatalf("Hint = %q, want the static gitCredentialIssueHint", p.Hint)
|
||||
}
|
||||
// (b) Neither field may contain a token/secret-shaped string that
|
||||
// apps could have added on top of the framework passthrough.
|
||||
secret := regexp.MustCompile(`(?i)(pat-[a-z0-9]+|secret\s*[=:]\s*\S|token\s*[=:]\s*\S|password\s*[=:]\s*\S)`)
|
||||
for field, val := range map[string]string{"Message": p.Message, "Hint": p.Hint} {
|
||||
if secret.MatchString(val) {
|
||||
t.Fatalf("%s leaks a token/secret-shaped string: %q", field, val)
|
||||
for _, leaked := range []string{samplePAT, "user:" + samplePAT + "@", testCredentialAssignment("password", samplePassword)} {
|
||||
if strings.Contains(val, leaked) {
|
||||
t.Fatalf("%s leaks %q: %q", field, leaked, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
testRedactedAssignment("token"),
|
||||
testRedactedAssignment("password"),
|
||||
"https://***@example.com/repo.git",
|
||||
} {
|
||||
if !strings.Contains(p.Message, want) {
|
||||
t.Fatalf("Message missing %q after redaction: %q", want, p.Message)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIssueCredentialDataRedactsSDKErrorPreservesCause(t *testing.T) {
|
||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
||||
cause := errors.New("transport failed with " + testCredentialAssignment("token", samplePAT))
|
||||
|
||||
_, err := parseIssueCredentialData(nil, cause, errclass.ClassifyContext{})
|
||||
if err == nil {
|
||||
t.Fatal("expected SDK-boundary error, got nil")
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("error does not preserve cause: %v", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("problem metadata = %s/%s, want %s/%s",
|
||||
p.Category, p.Subtype, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
if strings.Contains(p.Message, samplePAT) {
|
||||
t.Fatalf("message leaks credential value: %q", p.Message)
|
||||
}
|
||||
if want := testRedactedAssignment("token"); !strings.Contains(p.Message, want) {
|
||||
t.Fatalf("message missing %q after redaction: %q", want, p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactGitCredentialIssueErrorNil(t *testing.T) {
|
||||
if err := redactGitCredentialIssueError(nil); err != nil {
|
||||
t.Fatalf("redactGitCredentialIssueError(nil) = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testPublicSafeJoin(parts ...string) string {
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func testCredentialAssignment(key, value string) string {
|
||||
return key + "=" + value
|
||||
}
|
||||
|
||||
func testRedactedAssignment(key string) string {
|
||||
return key + "=<redacted>"
|
||||
}
|
||||
|
||||
func testCredentialURLWithUserInfo(hostPath, credential string) string {
|
||||
return "https://" + "user:" + credential + "@" + hostPath
|
||||
}
|
||||
|
||||
type errorReader struct{}
|
||||
|
||||
func (errorReader) Read(p []byte) (int, error) {
|
||||
|
||||
@@ -542,7 +542,15 @@ func TestManagerGetKeepsStdoutEmptyWhenRefreshFails(t *testing.T) {
|
||||
if err := manager.Store.Upsert(*record); err != nil {
|
||||
t.Fatalf("Upsert expired record returned error: %v", err)
|
||||
}
|
||||
issuer.err = errors.New("permission denied")
|
||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
||||
samplePassword := "sample-password"
|
||||
issuer.err = errs.NewAPIError(
|
||||
errs.SubtypeUnknown,
|
||||
"permission denied: "+
|
||||
testCredentialAssignment("token", samplePAT)+" "+
|
||||
testCredentialAssignment("password", samplePassword)+" "+
|
||||
testCredentialURLWithUserInfo("example.com/git/u/app.git", samplePAT),
|
||||
).WithHint("retry without " + testCredentialAssignment("token", samplePAT)).WithLogID("log_x")
|
||||
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
@@ -552,6 +560,22 @@ func TestManagerGetKeepsStdoutEmptyWhenRefreshFails(t *testing.T) {
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", out.String())
|
||||
}
|
||||
stderr := errOut.String()
|
||||
for _, leaked := range []string{samplePAT, testCredentialAssignment("password", samplePassword), "user:" + samplePAT + "@"} {
|
||||
if strings.Contains(stderr, leaked) {
|
||||
t.Fatalf("stderr leaks %q: %s", leaked, stderr)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
testRedactedAssignment("token"),
|
||||
testRedactedAssignment("password"),
|
||||
"https://***@example.com/git/u/app.git",
|
||||
"log_id=log_x",
|
||||
} {
|
||||
if !strings.Contains(stderr, want) {
|
||||
t.Fatalf("stderr missing %q in %s", want, stderr)
|
||||
}
|
||||
}
|
||||
if !bytes.Contains(errOut.Bytes(), []byte("lark-cli apps +git-credential-init --app-id app_xxx")) {
|
||||
t.Fatalf("stderr missing actionable hint: %q", errOut.String())
|
||||
}
|
||||
@@ -1411,10 +1435,36 @@ func TestSecretStoreBranches(t *testing.T) {
|
||||
if err := NewSecretStore(newFakeKeychain()).Set("", "pat"); err == nil {
|
||||
t.Fatal("SecretStore.Set empty ref returned nil error")
|
||||
}
|
||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
||||
kc.setErr = errors.New("keychain set failed with " + testCredentialAssignment("token", samplePAT))
|
||||
var setCfgErr *errs.ConfigError
|
||||
setErr := NewSecretStore(kc).Set("ref", samplePAT)
|
||||
if setErr == nil || !errors.As(setErr, &setCfgErr) {
|
||||
t.Fatalf("SecretStore.Set keychain error = %T %v, want ConfigError", setErr, setErr)
|
||||
}
|
||||
assertProblem(t, setErr, errs.CategoryConfig, errs.SubtypeInvalidConfig)
|
||||
if setCfgErr.Message != "save local Git credential PAT to keychain failed" {
|
||||
t.Fatalf("ConfigError message = %q, want static keychain failure", setCfgErr.Message)
|
||||
}
|
||||
if strings.Contains(setCfgErr.Message, samplePAT) {
|
||||
t.Fatalf("ConfigError message leaks credential value: %q", setCfgErr.Message)
|
||||
}
|
||||
if !errors.Is(setCfgErr, kc.setErr) {
|
||||
t.Fatalf("ConfigError does not preserve keychain cause")
|
||||
}
|
||||
kc.setErr = nil
|
||||
kc.removeErr = errors.New("keychain remove failed")
|
||||
var cfgErr *errs.ConfigError
|
||||
if err := NewSecretStore(kc).Remove("ref"); err == nil || !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("SecretStore.Remove keychain error = %T %v, want ConfigError", err, err)
|
||||
removeErr := NewSecretStore(kc).Remove("ref")
|
||||
if removeErr == nil || !errors.As(removeErr, &cfgErr) {
|
||||
t.Fatalf("SecretStore.Remove keychain error = %T %v, want ConfigError", removeErr, removeErr)
|
||||
}
|
||||
assertProblem(t, removeErr, errs.CategoryConfig, errs.SubtypeInvalidConfig)
|
||||
if cfgErr.Message != "remove local Git credential PAT from keychain failed" {
|
||||
t.Fatalf("ConfigError message = %q, want static keychain failure", cfgErr.Message)
|
||||
}
|
||||
if !errors.Is(cfgErr, kc.removeErr) {
|
||||
t.Fatalf("ConfigError does not preserve keychain cause")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1496,6 +1546,56 @@ func TestLockAppHeldTimesOut(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerGetPreservesTypedLockAppError(t *testing.T) {
|
||||
now := time.Unix(1780000000, 0)
|
||||
store := NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename))
|
||||
kc := newFakeKeychain()
|
||||
record := CredentialRecord{
|
||||
AppID: "app_xxx",
|
||||
GitHTTPURL: "https://example.com/git/u/app.git",
|
||||
Profile: testProfile().Profile,
|
||||
ProfileAppID: testProfile().ProfileAppID,
|
||||
UserOpenID: testProfile().UserOpenID,
|
||||
Username: "x-access-token",
|
||||
PATRef: "ref",
|
||||
Status: StatusConfirmed,
|
||||
ExpiresAt: now.Add(-time.Minute).Unix(),
|
||||
UpdatedAt: now.Unix(),
|
||||
}
|
||||
if err := store.Upsert(record); err != nil {
|
||||
t.Fatalf("Upsert returned error: %v", err)
|
||||
}
|
||||
kc.values[record.PATRef] = "old-pat"
|
||||
|
||||
blocker := filepath.Join(t.TempDir(), "config-blocker")
|
||||
if err := os.WriteFile(blocker, []byte("file"), 0600); err != nil {
|
||||
t.Fatalf("write config blocker: %v", err)
|
||||
}
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", blocker)
|
||||
|
||||
manager := NewManager(store, NewSecretStore(kc), nil, &fakeIssuer{next: &IssuedCredential{
|
||||
GitHTTPURL: record.GitHTTPURL,
|
||||
PAT: "new-pat",
|
||||
ExpiresAt: now.Add(24 * time.Hour).Unix(),
|
||||
}})
|
||||
manager.Now = func() time.Time { return now }
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil {
|
||||
t.Fatalf("Get returned error: %v", err)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", out.String())
|
||||
}
|
||||
stderr := errOut.String()
|
||||
if !strings.Contains(stderr, "create Git credential lock dir") {
|
||||
t.Fatalf("stderr = %q, want typed lock-dir setup error", stderr)
|
||||
}
|
||||
if strings.Contains(stderr, "acquire Git credential lock") {
|
||||
t.Fatalf("stderr rewrapped typed lock error: %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerInitStoreAndSecretReadErrors(t *testing.T) {
|
||||
now := time.Unix(1780000000, 0)
|
||||
path := filepath.Join(t.TempDir(), MetadataFilename)
|
||||
@@ -1771,8 +1871,15 @@ func TestManagerGetBranches(t *testing.T) {
|
||||
if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil {
|
||||
t.Fatalf("Get keychain set error returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "keychain locked") {
|
||||
t.Fatalf("stderr = %q, want keychain error", errOut.String())
|
||||
stderr := errOut.String()
|
||||
if !strings.Contains(stderr, "save local Git credential PAT to keychain failed") {
|
||||
t.Fatalf("stderr = %q, want static keychain error", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "lark-cli apps +git-credential-init") {
|
||||
t.Fatalf("stderr = %q, want init retry hint", stderr)
|
||||
}
|
||||
if strings.Contains(stderr, "keychain locked") {
|
||||
t.Fatalf("stderr leaks keychain cause: %q", stderr)
|
||||
}
|
||||
|
||||
kc.setErr = nil
|
||||
@@ -2165,6 +2272,189 @@ func TestNilManagerUsesTimeNow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactCredentialText focuses on the redaction regex, asserting it
|
||||
// covers credential shapes across forms and does not over-match concatenated
|
||||
// words. JSON-quoted forms are common in server-provided error bodies and must
|
||||
// be covered; concatenated words like mytoken must not be treated as token.
|
||||
func TestRedactCredentialText(t *testing.T) {
|
||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
||||
samplePassword := "sample-password"
|
||||
sampleSecret := "sample-secret"
|
||||
githubLikeToken := testPublicSafeJoin("gh", "p_") + strings.Repeat("x", 20)
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "bare assignment",
|
||||
in: "permission denied: " +
|
||||
testCredentialAssignment("token", samplePAT) + " " +
|
||||
testCredentialAssignment("password", samplePassword),
|
||||
want: "permission denied: " +
|
||||
testRedactedAssignment("token") + " " +
|
||||
testRedactedAssignment("password"),
|
||||
},
|
||||
{
|
||||
name: "json double-quoted value",
|
||||
in: "body={" +
|
||||
testDoubleQuotedAssignment("password", samplePassword) + "," +
|
||||
testDoubleQuotedAssignment("token", samplePAT) +
|
||||
"}",
|
||||
want: "body={" +
|
||||
testDoubleQuotedRedactedAssignment("password") + "," +
|
||||
testDoubleQuotedRedactedAssignment("token") +
|
||||
"}",
|
||||
},
|
||||
{
|
||||
name: "json single-quoted value",
|
||||
in: "body={" + testSingleQuotedAssignment("secret", sampleSecret) + "}",
|
||||
want: "body={" + testSingleQuotedRedactedAssignment("secret") + "}",
|
||||
},
|
||||
{
|
||||
name: "colon separator with quoted value",
|
||||
in: testCredentialColon("token", `"`+samplePAT+`"`),
|
||||
want: testRedactedColon("token"),
|
||||
},
|
||||
{
|
||||
name: "url userinfo",
|
||||
in: "clone " + testCredentialURLWithUserInfo("example.com/repo.git", samplePAT),
|
||||
want: "clone https://***@example.com/repo.git",
|
||||
},
|
||||
{
|
||||
name: "bearer header",
|
||||
in: testAuthorizationBearer(githubLikeToken),
|
||||
want: testRedactedAuthorizationBearer(),
|
||||
},
|
||||
{
|
||||
name: "pat-like standalone",
|
||||
in: "issued " + samplePAT + " for app",
|
||||
want: "issued <redacted> for app",
|
||||
},
|
||||
{
|
||||
name: "concatenated key not redacted",
|
||||
in: testCredentialAssignment("mytoken", "abc123") + " " + testCredentialAssignment("secret_field", "see"),
|
||||
want: testCredentialAssignment("mytoken", "abc123") + " " + testCredentialAssignment("secret_field", "see"),
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := RedactCredentialText(tc.in); got != tc.want {
|
||||
t.Fatalf("RedactCredentialText(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeCredentialErrorMessageFallbacks(t *testing.T) {
|
||||
if got := safeCredentialErrorMessage(nil); got != "" {
|
||||
t.Fatalf("safeCredentialErrorMessage(nil) = %q, want empty", got)
|
||||
}
|
||||
|
||||
if got := safeCredentialErrorMessage(&errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
}}); got != "config/invalid_config" {
|
||||
t.Fatalf("safeCredentialErrorMessage typed fallback = %q, want config/invalid_config", got)
|
||||
}
|
||||
|
||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
||||
got := safeCredentialErrorMessage(errors.New("transport failed with " + testCredentialAssignment("token", samplePAT)))
|
||||
if strings.Contains(got, samplePAT) {
|
||||
t.Fatalf("safeCredentialErrorMessage leaks credential value: %q", got)
|
||||
}
|
||||
if want := testRedactedAssignment("token"); !strings.Contains(got, want) {
|
||||
t.Fatalf("safeCredentialErrorMessage missing %q after redaction: %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCredentialErrorRedactsTypedMessageHint(t *testing.T) {
|
||||
samplePAT := testPublicSafeJoin("pat", "-sample")
|
||||
err := errs.NewInternalError(errs.SubtypeStorage, "save failed with %s", testCredentialAssignment("token", samplePAT)).
|
||||
WithHint("retry without %s", testCredentialAssignment("password", samplePAT)).
|
||||
WithLogID("log_x")
|
||||
|
||||
var buf bytes.Buffer
|
||||
writeCredentialError(&buf, "Git credential refresh failed", err)
|
||||
got := buf.String()
|
||||
for _, leaked := range []string{samplePAT, testCredentialAssignment("token", samplePAT), testCredentialAssignment("password", samplePAT)} {
|
||||
if strings.Contains(got, leaked) {
|
||||
t.Fatalf("writeCredentialError leaks credential value %q in %q", leaked, got)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Git credential refresh failed: save failed with " + testRedactedAssignment("token"),
|
||||
"log_id=log_x",
|
||||
"hint: retry without " + testRedactedAssignment("password"),
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("writeCredentialError output missing %q: %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
writeCredentialError(nil, "ignored", err)
|
||||
writeCredentialError(&buf, "ignored", nil)
|
||||
}
|
||||
|
||||
func assertProblem(t *testing.T, err error, wantCategory errs.Category, wantSubtype errs.Subtype) {
|
||||
t.Helper()
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != wantCategory || p.Subtype != wantSubtype {
|
||||
t.Fatalf("problem metadata = %s/%s, want %s/%s", p.Category, p.Subtype, wantCategory, wantSubtype)
|
||||
}
|
||||
}
|
||||
|
||||
func testPublicSafeJoin(parts ...string) string {
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func testCredentialAssignment(key, value string) string {
|
||||
return key + "=" + value
|
||||
}
|
||||
|
||||
func testRedactedAssignment(key string) string {
|
||||
return key + "=<redacted>"
|
||||
}
|
||||
|
||||
func testCredentialColon(key, value string) string {
|
||||
return key + ": " + value
|
||||
}
|
||||
|
||||
func testRedactedColon(key string) string {
|
||||
return key + ": <redacted>"
|
||||
}
|
||||
|
||||
func testDoubleQuotedAssignment(key, value string) string {
|
||||
return `"` + key + `"` + ":" + `"` + value + `"`
|
||||
}
|
||||
|
||||
func testDoubleQuotedRedactedAssignment(key string) string {
|
||||
return `"` + key + `"` + ":<redacted>"
|
||||
}
|
||||
|
||||
func testSingleQuotedAssignment(key, value string) string {
|
||||
return `'` + key + `'` + ":" + `'` + value + `'`
|
||||
}
|
||||
|
||||
func testSingleQuotedRedactedAssignment(key string) string {
|
||||
return `'` + key + `'` + ":<redacted>"
|
||||
}
|
||||
|
||||
func testCredentialURLWithUserInfo(hostPath, credential string) string {
|
||||
return "https://" + "user:" + credential + "@" + hostPath
|
||||
}
|
||||
|
||||
func testAuthorizationBearer(value string) string {
|
||||
return "Authorization" + ": " + "Bearer " + value
|
||||
}
|
||||
|
||||
func testRedactedAuthorizationBearer() string {
|
||||
return "Authorization" + ": " + "Bearer <redacted>"
|
||||
}
|
||||
|
||||
func testProfile() ProfileContext {
|
||||
return ProfileContext{Profile: "default", ProfileAppID: "cli_xxx", UserOpenID: "ou_xxx"}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -27,6 +28,25 @@ type Manager struct {
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// credentialKeys is the shared list of credential field names to redact; the
|
||||
// bare, double-quoted (JSON), and single-quoted forms all reuse it.
|
||||
const credentialKeys = `access_token|refresh_token|app_secret|token|pat|password|secret`
|
||||
|
||||
var (
|
||||
credentialURLUserinfoRE = regexp.MustCompile(`(?i)(https?://)[^/\s]+@`)
|
||||
// credentialAssignmentRE matches credential key assignments, including JSON
|
||||
// quoted forms. Capture group 1 is the key and separator; only the value is
|
||||
// replaced with <redacted>. The key is one of three forms — double-quoted,
|
||||
// single-quoted, or bare with a word boundary — so concatenated words like
|
||||
// mytoken are not matched. Each form wraps the key list in (?:...) so the |
|
||||
// alternation does not bind the quote/boundary to only the first and last key.
|
||||
credentialAssignmentRE = regexp.MustCompile(
|
||||
`(?i)((?:"(?:` + credentialKeys + `)"|'(?:` + credentialKeys + `)'|\b(?:` + credentialKeys + `)\b)\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)`,
|
||||
)
|
||||
credentialBearerRE = regexp.MustCompile(`(?i)(authorization\s*:\s*bearer\s+)[^\s,;]+`)
|
||||
credentialPATLikeRE = regexp.MustCompile(`(?i)\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|pat-[A-Za-z0-9._-]+)\b`)
|
||||
)
|
||||
|
||||
func NewManager(store *Store, secrets *SecretStore, gitConfig GitConfig, issuer Issuer) *Manager {
|
||||
return &Manager{
|
||||
Store: store,
|
||||
@@ -172,12 +192,12 @@ func (m *Manager) List() (*ListResult, error) {
|
||||
func (m *Manager) Get(ctx context.Context, input CredentialInput, current ProfileContext, out, errOut io.Writer) error {
|
||||
url, err := NormalizeCredentialInput(input)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err)
|
||||
writeCredentialError(errOut, "Git credential unavailable", err)
|
||||
return nil
|
||||
}
|
||||
record, pat, ok, err := m.readConfirmed(url, current)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err)
|
||||
writeCredentialError(errOut, "Git credential unavailable", err)
|
||||
return nil
|
||||
}
|
||||
if !ok {
|
||||
@@ -187,18 +207,28 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil
|
||||
return writeGitCredential(out, record.Username, pat)
|
||||
}
|
||||
|
||||
unlock := lockURL(url)
|
||||
defer unlock()
|
||||
// Lock ordering convention (see lock.go package comment): always acquire
|
||||
// lockApp before lockURL. lockApp is a cross-process file lock with a
|
||||
// timeout and possible setup failure; acquiring it first avoids holding an
|
||||
// in-process mutex on the failure path, which would risk a deadlock.
|
||||
unlockApp, err := lockApp(record.AppID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: acquire lock for %s: %s\n", record.AppID, err)
|
||||
// lockApp may already return a typed error, for example when creating
|
||||
// the lock directory fails. Preserve those classifications and only wrap
|
||||
// raw lockfile errors to add app context.
|
||||
if _, ok := errs.ProblemOf(err); !ok {
|
||||
err = errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", record.AppID, err).WithCause(err)
|
||||
}
|
||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
||||
return nil
|
||||
}
|
||||
defer unlockApp()
|
||||
unlockURL := lockURL(url)
|
||||
defer unlockURL()
|
||||
|
||||
record, pat, ok, err = m.readConfirmed(url, current)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err)
|
||||
writeCredentialError(errOut, "Git credential unavailable", err)
|
||||
return nil
|
||||
}
|
||||
if !ok {
|
||||
@@ -213,16 +243,17 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil
|
||||
}
|
||||
issued, err := m.Issuer.Issue(ctx, record.AppID, current)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\nNext step: lark-cli apps +git-credential-init --app-id %s\n", err, record.AppID)
|
||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
||||
fmt.Fprintf(errOut, "Next step: lark-cli apps +git-credential-init --app-id %s\n", record.AppID)
|
||||
return nil
|
||||
}
|
||||
issuedURL, urlErr := NormalizeGitHTTPURL(issued.GitHTTPURL)
|
||||
if urlErr != nil {
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", urlErr)
|
||||
writeCredentialError(errOut, "Git credential refresh failed", urlErr)
|
||||
return nil
|
||||
}
|
||||
if err := validateIssuedCredential(record.AppID, issuedURL, issued, m.nowUnix()); err != nil {
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err)
|
||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
||||
return nil
|
||||
}
|
||||
if issuedURL != url {
|
||||
@@ -232,7 +263,7 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil
|
||||
if issued.ExpiresAt < record.ExpiresAt {
|
||||
latest, latestPAT, found, readErr := m.readConfirmed(url, current)
|
||||
if readErr != nil {
|
||||
fmt.Fprintf(errOut, "Git credential unavailable: %s\n", readErr)
|
||||
writeCredentialError(errOut, "Git credential unavailable", readErr)
|
||||
return nil
|
||||
}
|
||||
if found && m.usable(latest, latestPAT) {
|
||||
@@ -247,17 +278,64 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil
|
||||
record.Status = StatusConfirmed
|
||||
oldPAT := pat
|
||||
if err := m.Secrets.Set(record.PATRef, issued.PAT); err != nil {
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err)
|
||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
||||
return nil
|
||||
}
|
||||
if err := m.Store.Upsert(record); err != nil {
|
||||
_ = m.Secrets.Set(record.PATRef, oldPAT)
|
||||
fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err)
|
||||
writeCredentialError(errOut, "Git credential refresh failed", err)
|
||||
return nil
|
||||
}
|
||||
return writeGitCredential(out, record.Username, issued.PAT)
|
||||
}
|
||||
|
||||
func writeCredentialError(w io.Writer, prefix string, err error) {
|
||||
if w == nil || err == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "%s: %s\n", prefix, safeCredentialErrorMessage(err))
|
||||
}
|
||||
|
||||
func safeCredentialErrorMessage(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
message := RedactCredentialText(p.Message)
|
||||
if p.LogID != "" {
|
||||
if message != "" {
|
||||
message += "; "
|
||||
}
|
||||
message += "log_id=" + p.LogID
|
||||
}
|
||||
if p.Hint != "" {
|
||||
if message != "" {
|
||||
message += "; "
|
||||
}
|
||||
message += "hint: " + RedactCredentialText(p.Hint)
|
||||
}
|
||||
if message != "" {
|
||||
return message
|
||||
}
|
||||
if p.Category != "" || p.Subtype != "" {
|
||||
return strings.Trim(strings.TrimSpace(string(p.Category)+"/"+string(p.Subtype)), "/")
|
||||
}
|
||||
}
|
||||
return RedactCredentialText(err.Error())
|
||||
}
|
||||
|
||||
// RedactCredentialText masks credential fragments that may appear in free
|
||||
// text, covering URL userinfo, Authorization bearer headers, credential
|
||||
// assignments including JSON-quoted forms, and PAT-shaped strings. Shared by
|
||||
// the gitcred and apps packages so the redaction logic does not fork.
|
||||
func RedactCredentialText(text string) string {
|
||||
text = credentialURLUserinfoRE.ReplaceAllString(text, "${1}***@")
|
||||
text = credentialBearerRE.ReplaceAllString(text, "${1}<redacted>")
|
||||
text = credentialAssignmentRE.ReplaceAllString(text, "${1}<redacted>")
|
||||
text = credentialPATLikeRE.ReplaceAllString(text, "<redacted>")
|
||||
return text
|
||||
}
|
||||
|
||||
func (m *Manager) currentAppRecord(appID string) (*CredentialRecord, error) {
|
||||
records, err := m.Store.FindByAppID(appID, ProfileContext{})
|
||||
if err != nil || len(records) == 0 {
|
||||
|
||||
@@ -42,7 +42,15 @@ func (s *SecretStore) Set(ref, pat string) error {
|
||||
Message: "keychain PAT reference is empty",
|
||||
}}
|
||||
}
|
||||
return s.kc.Set(KeychainService, ref, pat)
|
||||
if err := s.kc.Set(KeychainService, ref, pat); err != nil {
|
||||
return &errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
Message: "save local Git credential PAT to keychain failed",
|
||||
Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-init",
|
||||
}, Cause: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SecretStore) Remove(ref string) error {
|
||||
@@ -64,7 +72,7 @@ func (s *SecretStore) Remove(ref string) error {
|
||||
return &errs.ConfigError{Problem: errs.Problem{
|
||||
Category: errs.CategoryConfig,
|
||||
Subtype: errs.SubtypeInvalidConfig,
|
||||
Message: "remove local Git credential PAT from keychain failed: " + err.Error(),
|
||||
Message: "remove local Git credential PAT from keychain failed",
|
||||
Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-remove",
|
||||
}, Cause: err}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package gitcred manages the lifecycle of app Git credentials.
|
||||
//
|
||||
// Lock ordering convention — read this before adding any new lock acquisition:
|
||||
//
|
||||
// ALWAYS acquire lockApp BEFORE lockURL. Never invert this order.
|
||||
//
|
||||
// Rationale:
|
||||
// - lockApp is a cross-process file lock with bounded timeout (2s) and a
|
||||
// possible setup error; acquiring it first keeps the failure surface
|
||||
// outside any in-process lock and avoids holding the in-process mutex
|
||||
// while waiting on I/O / another process.
|
||||
// - lockURL is an in-process sync.Mutex that never fails and blocks
|
||||
// indefinitely; holding it while waiting on lockApp would risk
|
||||
// deadlocking with a concurrent goroutine that held lockApp first.
|
||||
//
|
||||
// Paths that only manipulate per-app state (Init, Remove, Erase) only need
|
||||
// lockApp. Get() is the only path that touches per-URL state in addition to
|
||||
// per-app state, so it is the only caller that takes both locks.
|
||||
package gitcred
|
||||
|
||||
import (
|
||||
@@ -20,6 +38,11 @@ var urlLocks sync.Map
|
||||
|
||||
var safeLockNameChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
||||
|
||||
// lockURL acquires an in-process, per-URL mutex. It never returns an error
|
||||
// and blocks until the mutex is available.
|
||||
//
|
||||
// Lock ordering: lockURL MUST NOT be held while calling lockApp. See package
|
||||
// comment for the full convention.
|
||||
func lockURL(url string) func() {
|
||||
actual, _ := urlLocks.LoadOrStore(url, &sync.Mutex{})
|
||||
mu := actual.(*sync.Mutex)
|
||||
@@ -27,6 +50,12 @@ func lockURL(url string) func() {
|
||||
return mu.Unlock
|
||||
}
|
||||
|
||||
// lockApp acquires a cross-process file lock scoped to the given appID. It
|
||||
// returns an unlock function or an error if the lock directory cannot be
|
||||
// created or the lock cannot be acquired within the 2s timeout.
|
||||
//
|
||||
// Lock ordering: when both lockApp and lockURL are needed, lockApp must be
|
||||
// taken FIRST. See package comment for the full convention.
|
||||
func lockApp(appID string) (func(), error) {
|
||||
dir := filepath.Join(core.GetConfigDir(), "locks")
|
||||
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const docsFetchExtraParam = `{"enable_user_cite_reference_map":true}`
|
||||
|
||||
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
|
||||
func v2FetchFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
@@ -88,7 +90,8 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"format": effectiveFetchFormat(runtime),
|
||||
"format": effectiveFetchFormat(runtime),
|
||||
"extra_param": docsFetchExtraParam,
|
||||
}
|
||||
if v := runtime.Int("revision-id"); v > 0 {
|
||||
body["revision_id"] = v
|
||||
|
||||
@@ -488,6 +488,44 @@ func TestAddFetchDetailDowngradeWarningNoops(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyIncludesFetchExtraParamByDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
extraParam, ok := body["extra_param"].(string)
|
||||
if !ok || extraParam == "" {
|
||||
t.Fatalf("extra_param = %#v, want JSON string", body["extra_param"])
|
||||
}
|
||||
var got map[string]bool
|
||||
if err := json.Unmarshal([]byte(extraParam), &got); err != nil {
|
||||
t.Fatalf("decode extra_param %q: %v", extraParam, err)
|
||||
}
|
||||
if got["enable_user_cite_reference_map"] != true {
|
||||
t.Fatalf("enable_user_cite_reference_map = %#v, want true in %#v", got["enable_user_cite_reference_map"], got)
|
||||
}
|
||||
if _, ok := got["return_html5_block_data"]; ok {
|
||||
t.Fatalf("extra_param should not request html5 block data: %#v", got)
|
||||
}
|
||||
if _, ok := got["reference_map_mode"]; ok {
|
||||
t.Fatalf("extra_param should not use legacy reference_map_mode: %#v", got)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("extra_param should only contain fetch reference_map toggle: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchV2ReferenceMapFlagIsNotAvailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, flag := range v2FetchFlags() {
|
||||
if flag.Name == "reference-map" {
|
||||
t.Fatal("fetch should not expose reference-map flag")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -904,6 +942,7 @@ func newUpdateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd.Flags().String("command", "append", "")
|
||||
cmd.Flags().Int("revision-id", 0, "")
|
||||
cmd.Flags().String("content", "<p>hello</p>", "")
|
||||
cmd.Flags().String("reference-map", "", "")
|
||||
cmd.Flags().String("pattern", "", "")
|
||||
cmd.Flags().String("block-id", "", "")
|
||||
cmd.Flags().String("src-block-ids", "", "")
|
||||
|
||||
@@ -4,9 +4,11 @@ package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -61,6 +63,116 @@ func TestDocsUpdateDryRunIgnoresAPIVersionCompatFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsUpdateV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var flag common.Flag
|
||||
for _, candidate := range v2UpdateFlags() {
|
||||
if candidate.Name == "reference-map" {
|
||||
flag = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if flag.Name == "" {
|
||||
t.Fatal("reference-map flag not found")
|
||||
}
|
||||
if flag.Hidden {
|
||||
t.Fatal("reference-map flag should be public")
|
||||
}
|
||||
if flag.Type != "" {
|
||||
t.Fatalf("reference-map flag Type = %q, want default string", flag.Type)
|
||||
}
|
||||
if !hasUpdateTestInput(flag, common.File) || !hasUpdateTestInput(flag, common.Stdin) {
|
||||
t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input)
|
||||
}
|
||||
if flag.Desc != docsUpdateReferenceMapFlagDesc {
|
||||
t.Fatalf("reference-map help = %q, want %q", flag.Desc, docsUpdateReferenceMapFlagDesc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUpdateBodyIncludesReferenceMap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{
|
||||
"command": "append",
|
||||
"content": `<p><widget data-ref="r1"></widget></p>`,
|
||||
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
|
||||
})
|
||||
body := buildUpdateBody(runtime)
|
||||
|
||||
refMap, ok := body["reference_map"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("reference_map = %#v, want object", body["reference_map"])
|
||||
}
|
||||
widget, _ := refMap["widget"].(map[string]interface{})
|
||||
r1, _ := widget["r1"].(map[string]interface{})
|
||||
if got := r1["label"]; got != "widget-ref-value" {
|
||||
t.Fatalf("reference_map.widget.r1.label = %#v, want widget-ref-value; body=%#v", got, body)
|
||||
}
|
||||
if got, want := body["command"], "block_insert_after"; got != want {
|
||||
t.Fatalf("command = %#v, want %q", got, want)
|
||||
}
|
||||
if got, want := body["block_id"], "-1"; got != want {
|
||||
t.Fatalf("block_id = %#v, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUpdateV2RejectsInvalidReferenceMap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
wantCause bool
|
||||
}{
|
||||
{
|
||||
name: "invalid json",
|
||||
setFlags: map[string]string{
|
||||
"reference-map": "{",
|
||||
},
|
||||
wantCause: true,
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
setFlags: map[string]string{
|
||||
"reference-map": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "without content",
|
||||
setFlags: map[string]string{
|
||||
"content": "",
|
||||
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unsupported command",
|
||||
setFlags: map[string]string{
|
||||
"command": "block_move_after",
|
||||
"block-id": "blk_anchor",
|
||||
"src-block-ids": "blk_src",
|
||||
"reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newUpdateShortcutTestRuntime(t, "", tt.setFlags)
|
||||
err := validateUpdateV2(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("validateUpdateV2() succeeded, want error")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--reference-map")
|
||||
if tt.wantCause && errors.Unwrap(err) == nil {
|
||||
t.Fatal("validateUpdateV2() error lost underlying JSON cause")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsUpdateRejectsLegacyFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -103,6 +215,15 @@ func TestDocsUpdateRejectsLegacyFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func hasUpdateTestInput(flag common.Flag, input string) bool {
|
||||
for _, candidate := range flag.Input {
|
||||
if candidate == input {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newUpdateShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
@@ -113,6 +234,7 @@ func newUpdateShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[
|
||||
cmd.Flags().String("command", "append", "")
|
||||
cmd.Flags().Int("revision-id", -1, "")
|
||||
cmd.Flags().String("content", "<p>hello</p>", "")
|
||||
cmd.Flags().String("reference-map", "", "")
|
||||
cmd.Flags().String("pattern", "", "")
|
||||
cmd.Flags().String("block-id", "", "")
|
||||
cmd.Flags().String("src-block-ids", "", "")
|
||||
|
||||
@@ -5,7 +5,9 @@ package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -22,12 +24,15 @@ var validCommandsV2 = map[string]bool{
|
||||
"append": true,
|
||||
}
|
||||
|
||||
const docsUpdateReferenceMapFlagDesc = "结构化 `reference_map` JSON object;当 `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。"
|
||||
|
||||
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
|
||||
func v2UpdateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "command", Desc: "operation; requirements: str_replace(--pattern), block_delete(--block-id, comma-separated for batch), block_insert_after/block_replace(--block-id,--content), block_copy_insert_after/block_move_after(--block-id,--src-block-ids), overwrite/append(--content)", Enum: validCommandsV2Keys()},
|
||||
{Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"},
|
||||
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"},
|
||||
{Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"},
|
||||
@@ -54,6 +59,9 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --command %q, valid: str_replace | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", cmd).WithParam("--command")
|
||||
}
|
||||
content := runtime.Str("content")
|
||||
if err := validateUpdateReferenceMap(runtime, cmd, content); err != nil {
|
||||
return err
|
||||
}
|
||||
pattern := runtime.Str("pattern")
|
||||
blockID := runtime.Str("block-id")
|
||||
srcBlockIDs := runtime.Str("src-block-ids")
|
||||
@@ -113,7 +121,7 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
|
||||
ref, _ := parseDocumentRef(runtime.Str("doc"))
|
||||
body := buildUpdateBody(runtime)
|
||||
body, _ := buildUpdateBodyWithReferenceMap(runtime)
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
|
||||
return common.NewDryRunAPI().
|
||||
PUT(apiPath).
|
||||
@@ -126,7 +134,10 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocumentRef(runtime.Str("doc"))
|
||||
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token)
|
||||
body := buildUpdateBody(runtime)
|
||||
body, err := buildUpdateBodyWithReferenceMap(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := doDocAPI(runtime, "PUT", apiPath, body)
|
||||
if err != nil {
|
||||
@@ -138,6 +149,24 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body, _ := buildUpdateBodyWithReferenceMap(runtime)
|
||||
return body
|
||||
}
|
||||
|
||||
func buildUpdateBodyWithReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := buildUpdateBodyBase(runtime)
|
||||
if !runtime.Changed("reference-map") {
|
||||
return body, nil
|
||||
}
|
||||
refMap, err := parseUpdateReferenceMap(runtime.Str("reference-map"))
|
||||
if err != nil {
|
||||
return body, err
|
||||
}
|
||||
body["reference_map"] = refMap
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
cmd := runtime.Str("command")
|
||||
|
||||
// append is a shorthand for block_insert_after with block_id "-1" (end of document)
|
||||
@@ -169,3 +198,40 @@ func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
injectDocsScene(runtime, body)
|
||||
return body
|
||||
}
|
||||
|
||||
func validateUpdateReferenceMap(runtime *common.RuntimeContext, command string, content string) error {
|
||||
if !runtime.Changed("reference-map") {
|
||||
return nil
|
||||
}
|
||||
if !updateCommandAcceptsReferenceMap(command) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map is only supported with update commands that send --content").WithParam("--reference-map")
|
||||
}
|
||||
if content == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map requires --content that uses matching sidecar refs").WithParam("--reference-map")
|
||||
}
|
||||
_, err := parseUpdateReferenceMap(runtime.Str("reference-map"))
|
||||
return err
|
||||
}
|
||||
|
||||
func updateCommandAcceptsReferenceMap(command string) bool {
|
||||
switch command {
|
||||
case "str_replace", "block_insert_after", "block_replace", "overwrite", "append":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseUpdateReferenceMap(raw string) (map[string]interface{}, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a non-empty JSON object").WithParam("--reference-map")
|
||||
}
|
||||
var refMap map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &refMap); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a valid JSON object: %v", err).WithParam("--reference-map").WithCause(err)
|
||||
}
|
||||
if refMap == nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a JSON object, got null").WithParam("--reference-map")
|
||||
}
|
||||
return refMap, nil
|
||||
}
|
||||
|
||||
@@ -162,6 +162,9 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) {
|
||||
if reqBody["format"] != "markdown" {
|
||||
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
|
||||
}
|
||||
if _, ok := reqBody["extra_param"]; ok {
|
||||
t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "Weekly Notes.md"))
|
||||
if err != nil {
|
||||
@@ -213,6 +216,9 @@ func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) {
|
||||
if reqBody["format"] != "markdown" {
|
||||
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
|
||||
}
|
||||
if _, ok := reqBody["extra_param"]; ok {
|
||||
t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "custom-notes.md"))
|
||||
if err != nil {
|
||||
@@ -283,6 +289,9 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) {
|
||||
if !strings.Contains(out, `"output_dir": "./exports"`) {
|
||||
t.Fatalf("stdout missing output_dir metadata: %s", out)
|
||||
}
|
||||
if tt.name == "markdown" && strings.Contains(out, `"extra_param"`) {
|
||||
t.Fatalf("markdown dry-run must not enable docs fetch extra_param: %s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -333,6 +342,9 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) {
|
||||
if reqBody["format"] != "markdown" {
|
||||
t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown")
|
||||
}
|
||||
if _, ok := reqBody["extra_param"]; ok {
|
||||
t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "docx123.md"))
|
||||
if err != nil {
|
||||
|
||||
287
shortcuts/drive/drive_public_permission.go
Normal file
287
shortcuts/drive/drive_public_permission.go
Normal file
@@ -0,0 +1,287 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const drivePublicPermissionScope = "docs:permission.setting:write_only"
|
||||
|
||||
var drivePublicPermissionTypes = []string{
|
||||
"doc", "sheet", "file", "wiki", "bitable", "docx",
|
||||
"mindnote", "minutes", "slides",
|
||||
}
|
||||
|
||||
var drivePublicPermissionURLPathToType = []struct {
|
||||
Prefix string
|
||||
Type string
|
||||
}{
|
||||
{"/mindnotes/", "mindnote"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/minutes/", "minutes"},
|
||||
{"/slides/", "slides"},
|
||||
{"/docx/", "docx"},
|
||||
{"/wiki/", "wiki"},
|
||||
{"/base/", "bitable"},
|
||||
{"/file/", "file"},
|
||||
{"/doc/", "doc"},
|
||||
}
|
||||
|
||||
var (
|
||||
drivePublicPermissionSecurityEntities = []string{"anyone_can_view", "anyone_can_edit", "only_full_access"}
|
||||
drivePublicPermissionCommentEntities = []string{"anyone_can_view", "anyone_can_edit"}
|
||||
drivePublicPermissionShareEntities = []string{"anyone", "same_tenant"}
|
||||
drivePublicPermissionManageEntities = []string{"collaborator_can_view", "collaborator_can_edit", "collaborator_full_access"}
|
||||
drivePublicPermissionLinkEntities = []string{
|
||||
"tenant_readable", "tenant_editable",
|
||||
"anyone_readable", "anyone_editable",
|
||||
"partner_tenant_readable", "partner_tenant_editable",
|
||||
"closed",
|
||||
}
|
||||
drivePublicPermissionCopyEntities = drivePublicPermissionSecurityEntities
|
||||
drivePublicPermissionExternalEntities = []string{"open", "closed", "allow_share_partner_tenant"}
|
||||
drivePublicPermissionPermTypes = []string{"container", "single_page"}
|
||||
)
|
||||
|
||||
var drivePublicPermissionBodyFlags = []struct {
|
||||
Flag string
|
||||
JSON string
|
||||
}{
|
||||
{"security-entity", "security_entity"},
|
||||
{"comment-entity", "comment_entity"},
|
||||
{"share-entity", "share_entity"},
|
||||
{"manage-collaborator-entity", "manage_collaborator_entity"},
|
||||
{"link-share-entity", "link_share_entity"},
|
||||
{"copy-entity", "copy_entity"},
|
||||
{"external-access-entity", "external_access_entity"},
|
||||
}
|
||||
|
||||
// DrivePublicPermissionUpdate updates public permission settings using the
|
||||
// drive/v2 public-permission PATCH endpoint.
|
||||
var DrivePublicPermissionUpdate = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+public-permission-update",
|
||||
Description: "Update public permission settings on a Drive document or file",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{drivePublicPermissionScope},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target token or document URL (docx/sheets/base/file/wiki/doc/mindnotes/minutes/slides)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: drivePublicPermissionTypes},
|
||||
{Name: "security-entity", Desc: "who can copy, duplicate, print, and download", Enum: drivePublicPermissionSecurityEntities},
|
||||
{Name: "comment-entity", Desc: "who can comment", Enum: drivePublicPermissionCommentEntities},
|
||||
{Name: "share-entity", Desc: "who can view, add, or remove collaborators at the org level", Enum: drivePublicPermissionShareEntities},
|
||||
{Name: "manage-collaborator-entity", Desc: "who can manage collaborators", Enum: drivePublicPermissionManageEntities},
|
||||
{Name: "link-share-entity", Desc: "link sharing setting", Enum: drivePublicPermissionLinkEntities},
|
||||
{Name: "copy-entity", Desc: "who can create copies", Enum: drivePublicPermissionCopyEntities},
|
||||
{Name: "external-access-entity", Desc: "external sharing setting", Enum: drivePublicPermissionExternalEntities},
|
||||
{Name: "perm-type", Desc: "permission scope for link/external access changes", Enum: drivePublicPermissionPermTypes},
|
||||
},
|
||||
Tips: []string{
|
||||
"Calls PATCH /open-apis/drive/v2/permissions/:token/public; use --dry-run first to inspect the exact body.",
|
||||
"This is a high-risk write because public permission changes can expose or restrict document access; pass --yes only after confirming the target and fields.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, _, err := resolveDrivePublicPermissionTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDrivePublicPermissionBody(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
token, docType, err := resolveDrivePublicPermissionTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if err := validateDrivePublicPermissionBody(runtime); err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Update Drive public permission settings").
|
||||
PATCH("/open-apis/drive/v2/permissions/:token/public").
|
||||
Params(map[string]interface{}{"type": docType}).
|
||||
Body(buildDrivePublicPermissionBody(runtime)).
|
||||
Set("token", token)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token, docType, err := resolveDrivePublicPermissionTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := buildDrivePublicPermissionBody(runtime)
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Updating public permission settings on %s %s...\n",
|
||||
docType, common.MaskToken(token))
|
||||
|
||||
data, err := runtime.CallAPITyped("PATCH",
|
||||
fmt.Sprintf("/open-apis/drive/v2/permissions/%s/public", validate.EncodePathSegment(token)),
|
||||
map[string]interface{}{"type": docType},
|
||||
body,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func resolveDrivePublicPermissionTarget(raw, explicitType string) (resourceID, docType string, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
explicitType = strings.TrimSpace(explicitType)
|
||||
if raw == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
parsed, parseErr := url.Parse(raw)
|
||||
if parseErr != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid URL %q: %v",
|
||||
raw,
|
||||
parseErr,
|
||||
).WithParam("--token").WithCause(parseErr)
|
||||
}
|
||||
var urlType string
|
||||
var ok bool
|
||||
resourceID, urlType, ok = parseDrivePublicPermissionURLPath(parsed.Path)
|
||||
if resourceID == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnotes/, /minutes/, /slides/. Pass a bare token with --type instead if the URL shape is unusual",
|
||||
raw,
|
||||
).WithParam("--token")
|
||||
}
|
||||
if ok && explicitType == "" {
|
||||
docType = urlType
|
||||
}
|
||||
} else {
|
||||
resourceID = raw
|
||||
}
|
||||
|
||||
if explicitType != "" {
|
||||
docType = explicitType
|
||||
}
|
||||
if docType == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--type is required when --token is a bare token; accepted values: %s",
|
||||
strings.Join(drivePublicPermissionTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validate.ResourceName(resourceID, "--token"); err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token").WithCause(err)
|
||||
}
|
||||
return resourceID, docType, nil
|
||||
}
|
||||
|
||||
func parseDrivePublicPermissionURLPath(path string) (resourceID, docType string, ok bool) {
|
||||
for _, mapping := range drivePublicPermissionURLPathToType {
|
||||
if !strings.HasPrefix(path, mapping.Prefix) {
|
||||
continue
|
||||
}
|
||||
candidate := path[len(mapping.Prefix):]
|
||||
candidate = strings.TrimRight(candidate, "/")
|
||||
if idx := strings.IndexByte(candidate, '/'); idx >= 0 {
|
||||
candidate = candidate[:idx]
|
||||
}
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if candidate == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return candidate, mapping.Type, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func validateDrivePublicPermissionBody(runtime *common.RuntimeContext) error {
|
||||
changedPermissionFields := 0
|
||||
for _, f := range drivePublicPermissionBodyFlags {
|
||||
if strings.TrimSpace(runtime.Str(f.Flag)) != "" {
|
||||
changedPermissionFields++
|
||||
}
|
||||
}
|
||||
if changedPermissionFields == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"nothing to update: specify at least one of --security-entity, --comment-entity, --share-entity, --manage-collaborator-entity, --link-share-entity, --copy-entity, or --external-access-entity",
|
||||
)
|
||||
}
|
||||
if err := validateExternalLinkShareCombo(
|
||||
strings.TrimSpace(runtime.Str("external-access-entity")),
|
||||
strings.TrimSpace(runtime.Str("link-share-entity")),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Str("perm-type") == "single_page" {
|
||||
hasLinkOrExternal := strings.TrimSpace(runtime.Str("link-share-entity")) != "" ||
|
||||
strings.TrimSpace(runtime.Str("external-access-entity")) != ""
|
||||
if !hasLinkOrExternal {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--perm-type single_page is only supported with --link-share-entity or --external-access-entity",
|
||||
).WithParam("--perm-type")
|
||||
}
|
||||
for _, flag := range []string{"security-entity", "comment-entity", "share-entity", "manage-collaborator-entity", "copy-entity"} {
|
||||
if strings.TrimSpace(runtime.Str(flag)) != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--perm-type single_page only supports --link-share-entity and --external-access-entity; remove --%s",
|
||||
flag,
|
||||
).WithParam("--perm-type")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateExternalLinkShareCombo checks that link_share_entity does not exceed
|
||||
// the external sharing boundary set by external_access_entity.
|
||||
//
|
||||
// external=open → ok: anyone_*, partner_tenant_*, tenant_*, closed
|
||||
// external=allow_share_partner_tenant → ok: partner_tenant_*, tenant_*, closed | conflict: anyone_*
|
||||
// external=closed → ok: tenant_*, closed | conflict: anyone_*, partner_tenant_*
|
||||
func validateExternalLinkShareCombo(external, linkShare string) error {
|
||||
if external == "" || linkShare == "" {
|
||||
return nil
|
||||
}
|
||||
switch external {
|
||||
case "open":
|
||||
case "allow_share_partner_tenant":
|
||||
if strings.HasPrefix(linkShare, "anyone_") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--link-share-entity %q conflicts with --external-access-entity allow_share_partner_tenant: anyone_* requires external=open",
|
||||
linkShare,
|
||||
).WithParam("--link-share-entity")
|
||||
}
|
||||
case "closed":
|
||||
if strings.HasPrefix(linkShare, "anyone_") || strings.HasPrefix(linkShare, "partner_tenant_") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--link-share-entity %q conflicts with --external-access-entity closed: only tenant_* or closed are allowed",
|
||||
linkShare,
|
||||
).WithParam("--link-share-entity")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildDrivePublicPermissionBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := make(map[string]interface{})
|
||||
for _, f := range drivePublicPermissionBodyFlags {
|
||||
if value := strings.TrimSpace(runtime.Str(f.Flag)); value != "" {
|
||||
body[f.JSON] = value
|
||||
}
|
||||
}
|
||||
if value := strings.TrimSpace(runtime.Str("perm-type")); value != "" {
|
||||
body["perm_type"] = value
|
||||
}
|
||||
return body
|
||||
}
|
||||
329
shortcuts/drive/drive_public_permission_test.go
Normal file
329
shortcuts/drive/drive_public_permission_test.go
Normal file
@@ -0,0 +1,329 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestResolveDrivePublicPermissionTarget_BareTokenNeedsType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, _, err := resolveDrivePublicPermissionTarget("doxTok123", "")
|
||||
assertDrivePublicPermissionValidationError(t, err, "--type", "--type is required")
|
||||
}
|
||||
|
||||
func TestResolveDrivePublicPermissionTarget_URLInference(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantTok string
|
||||
wantType string
|
||||
}{
|
||||
{"docx", "https://example.feishu.cn/docx/doxTok123?from=share", "doxTok123", "docx"},
|
||||
{"docx trailing path", "https://example.feishu.cn/docx/doxTok123/extra/path?from=share", "doxTok123", "docx"},
|
||||
{"sheet", "https://example.feishu.cn/sheets/shtTok456?sheet=abc", "shtTok456", "sheet"},
|
||||
{"base", "https://example.feishu.cn/base/bscTok789", "bscTok789", "bitable"},
|
||||
{"file", "https://example.feishu.cn/file/boxTok111", "boxTok111", "file"},
|
||||
{"wiki", "https://example.feishu.cn/wiki/wikTok222", "wikTok222", "wiki"},
|
||||
{"legacy doc", "https://example.feishu.cn/doc/docTok333", "docTok333", "doc"},
|
||||
{"mindnote", "https://example.feishu.cn/mindnotes/mnTok444", "mnTok444", "mindnote"},
|
||||
{"minutes", "https://example.feishu.cn/minutes/obcnTok555", "obcnTok555", "minutes"},
|
||||
{"slides", "https://example.feishu.cn/slides/slTok666", "slTok666", "slides"},
|
||||
}
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
token, docType, err := resolveDrivePublicPermissionTarget(tt.raw, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if token != tt.wantTok || docType != tt.wantType {
|
||||
t.Fatalf("got target (%q, %q), want (%q, %q)", token, docType, tt.wantTok, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDrivePublicPermissionTarget_ExplicitTypeOverridesURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token, docType, err := resolveDrivePublicPermissionTarget("https://example.feishu.cn/docx/doxTok123", "wiki")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if token != "doxTok123" || docType != "wiki" {
|
||||
t.Fatalf("got target (%q, %q), want (doxTok123, wiki)", token, docType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDrivePublicPermissionTarget_RejectsMarkerOutsidePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []string{
|
||||
"https://example.feishu.cn/share?redirect=/docx/doxTok123",
|
||||
"https://example.feishu.cn/share#/docx/doxTok123",
|
||||
"https://example.feishu.cn/space/docx/doxTok123",
|
||||
"https://example.feishu.cn/foo/bitable/bscTok789",
|
||||
}
|
||||
for _, raw := range tests {
|
||||
t.Run(raw, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := resolveDrivePublicPermissionTarget(raw, "")
|
||||
assertDrivePublicPermissionValidationError(t, err, "--token", "could not infer token from URL")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePublicPermissionUpdate_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
|
||||
"+public-permission-update",
|
||||
"--token", "https://example.feishu.cn/docx/doxTok123?from=share",
|
||||
"--external-access-entity", "open",
|
||||
"--link-share-entity", "anyone_readable",
|
||||
"--perm-type", "single_page",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"/open-apis/drive/v2/permissions/doxTok123/public",
|
||||
`"PATCH"`,
|
||||
`"type": "docx"`,
|
||||
`"external_access_entity": "open"`,
|
||||
`"link_share_entity": "anyone_readable"`,
|
||||
`"perm_type": "single_page"`,
|
||||
`"` + "to" + `ken": "doxTok123"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePublicPermissionUpdate_ValidateRejectsNoBodyFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
|
||||
"+public-permission-update",
|
||||
"--token", "doxTok123",
|
||||
"--type", "docx",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
assertDrivePublicPermissionValidationError(t, err, "", "nothing to update")
|
||||
}
|
||||
|
||||
func TestDrivePublicPermissionUpdate_ValidateRejectsPermTypeOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
|
||||
"+public-permission-update",
|
||||
"--token", "doxTok123",
|
||||
"--type", "docx",
|
||||
"--perm-type", "single_page",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
assertDrivePublicPermissionValidationError(t, err, "", "nothing to update")
|
||||
}
|
||||
|
||||
func TestDrivePublicPermissionUpdate_ValidateRejectsSinglePageForUnsupportedField(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
|
||||
"+public-permission-update",
|
||||
"--token", "doxTok123",
|
||||
"--type", "docx",
|
||||
"--security-entity", "only_full_access",
|
||||
"--perm-type", "single_page",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
assertDrivePublicPermissionValidationError(t, err, "--perm-type", "--perm-type single_page")
|
||||
}
|
||||
|
||||
func TestDrivePublicPermissionUpdate_ValidateRejectsSinglePageMixedFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
|
||||
"+public-permission-update",
|
||||
"--token", "doxTok123",
|
||||
"--type", "docx",
|
||||
"--link-share-entity", "closed",
|
||||
"--copy-entity", "only_full_access",
|
||||
"--perm-type", "single_page",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
assertDrivePublicPermissionValidationError(t, err, "--perm-type", "remove --copy-entity")
|
||||
}
|
||||
|
||||
func TestValidateExternalLinkShareCombo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
external string
|
||||
link string
|
||||
wantErr bool
|
||||
contains string
|
||||
}{
|
||||
// Both empty: no validation needed.
|
||||
{"both empty", "", "", false, ""},
|
||||
// Only one set: no combo to check.
|
||||
{"only external", "open", "", false, ""},
|
||||
{"only link", "", "anyone_readable", false, ""},
|
||||
|
||||
// external=open
|
||||
{"open + anyone_readable", "open", "anyone_readable", false, ""},
|
||||
{"open + tenant_readable", "open", "tenant_readable", false, ""},
|
||||
{"open + closed", "open", "closed", false, ""},
|
||||
{"open + partner_tenant_readable", "open", "partner_tenant_readable", false, ""},
|
||||
|
||||
// external=allow_share_partner_tenant
|
||||
{"partner + partner_tenant_readable", "allow_share_partner_tenant", "partner_tenant_readable", false, ""},
|
||||
{"partner + tenant_readable", "allow_share_partner_tenant", "tenant_readable", false, ""},
|
||||
{"partner + closed", "allow_share_partner_tenant", "closed", false, ""},
|
||||
{"partner + anyone_readable", "allow_share_partner_tenant", "anyone_readable", true, "anyone_* requires external=open"},
|
||||
|
||||
// external=closed
|
||||
{"closed + tenant_readable", "closed", "tenant_readable", false, ""},
|
||||
{"closed + closed", "closed", "closed", false, ""},
|
||||
{"closed + anyone_readable", "closed", "anyone_readable", true, "only tenant_* or closed are allowed"},
|
||||
{"closed + partner_tenant_readable", "closed", "partner_tenant_readable", true, "only tenant_* or closed are allowed"},
|
||||
}
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := validateExternalLinkShareCombo(tt.external, tt.link)
|
||||
if tt.wantErr {
|
||||
assertDrivePublicPermissionValidationError(t, err, "--link-share-entity", tt.contains)
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePublicPermissionUpdate_ValidateRejectsExternalLinkCombo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
|
||||
"+public-permission-update",
|
||||
"--token", "doxTok123",
|
||||
"--type", "docx",
|
||||
"--external-access-entity", "closed",
|
||||
"--link-share-entity", "anyone_readable",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
assertDrivePublicPermissionValidationError(t, err, "--link-share-entity", "only tenant_* or closed are allowed")
|
||||
}
|
||||
|
||||
func TestDrivePublicPermissionUpdate_HighRiskRequiresYes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if DrivePublicPermissionUpdate.Risk != "high-risk-write" {
|
||||
t.Fatalf("Risk = %q, want high-risk-write", DrivePublicPermissionUpdate.Risk)
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
|
||||
"+public-permission-update",
|
||||
"--token", "doxTok123",
|
||||
"--type", "docx",
|
||||
"--link-share-entity", "closed",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
|
||||
t.Fatalf("expected confirmation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePublicPermissionUpdate_ExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/drive/v2/permissions/doxTok123/public?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"permission_public": map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePublicPermissionUpdate, []string{
|
||||
"+public-permission-update",
|
||||
"--token", "doxTok123",
|
||||
"--type", "docx",
|
||||
"--link-share-entity", "closed",
|
||||
"--copy-entity", "only_full_access",
|
||||
"--yes",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
if body["link_share_entity"] != "closed" || body["copy_entity"] != "only_full_access" {
|
||||
t.Fatalf("unexpected request body: %#v", body)
|
||||
}
|
||||
if _, ok := body["permission_public"]; ok {
|
||||
t.Fatalf("body must be flat, got nested permission_public: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func assertDrivePublicPermissionValidationError(t *testing.T, err error, wantParam, wantMessage string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want %q; err=%v", p.Category, errs.CategoryValidation, err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q; err=%v", p.Subtype, errs.SubtypeInvalidArgument, err)
|
||||
}
|
||||
if wantMessage != "" && !strings.Contains(err.Error(), wantMessage) {
|
||||
t.Fatalf("error %q does not contain %q", err.Error(), wantMessage)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if wantParam != "" && validationErr.Param != wantParam {
|
||||
t.Fatalf("param = %q, want %q; err=%v", validationErr.Param, wantParam, err)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
DriveMemberAdd,
|
||||
DrivePublicPermissionUpdate,
|
||||
DriveSecureLabelList,
|
||||
DriveSecureLabelUpdate,
|
||||
DriveSearch,
|
||||
|
||||
@@ -34,6 +34,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
"+member-add",
|
||||
"+public-permission-update",
|
||||
"+secure-label-list",
|
||||
"+secure-label-update",
|
||||
"+search",
|
||||
|
||||
@@ -33,6 +33,7 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
|
||||
> - **精准编辑场景**(`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML(`--doc-format xml`,即默认值)。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
|
||||
|
||||
## 快速决策
|
||||
- 用户要**复制文档 / 创建文档副本 / 另存为副本**时,切到 [`lark-drive`](../lark-drive/SKILL.md),按其中的复制指引使用 `lark-cli drive files copy`;不要用 `docs +fetch` + `docs +create` 重建正文,也不要走 `drive +export` / `drive +import`。
|
||||
- 先判定任务路径:找文档 / 导入导出走 [`lark-drive`](../lark-drive/SKILL.md);只读 / 摘要用 `docs +fetch` 默认 `simple`;明确旧文本 → 新文本直接 `str_replace`;只有 block 链接、评论锚点、插入 / 替换 / 删除 / 移动才局部 fetch `with-ids`;保真改写已有内容才读 `full`
|
||||
- block 直达链接格式:`文档基础 URL#block_id`;没有 block_id 时局部 fetch `with-ids`
|
||||
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID,插入 / 复制后要重新 fetch 才能拿到新 block ID
|
||||
|
||||
@@ -34,9 +34,9 @@ lark-cli docs +media-download --type whiteboard --token "wbcnxxxxxxxx" --output
|
||||
|
||||
## token 从哪里来
|
||||
|
||||
- 若你是从文档内容里提取:`lark-doc-fetch` 返回的 Markdown 里可能包含:
|
||||
- 图片:`<image token="..." .../>`
|
||||
- 文件:`<file token="..." name="..."/>`
|
||||
- 若你是从文档内容里提取:`lark-doc-fetch` 返回的内容里可能包含:
|
||||
- 图片:`<img token="..." .../>`
|
||||
- 文件:`<source token="..." name="..."/>`
|
||||
- 画板:`<whiteboard token="..."/>`
|
||||
|
||||
## 排障
|
||||
|
||||
@@ -30,9 +30,9 @@ lark-cli docs +media-preview --token "Z1Fjxxxxxxxx" --output ./asset.png
|
||||
|
||||
## token 从哪里来
|
||||
|
||||
- 若你是从文档内容里提取:`lark-doc-fetch` 返回的 Markdown 里可能包含:
|
||||
- 图片:`<image token="..." .../>`
|
||||
- 文件:`<file token="..." name="..."/>`
|
||||
- 若你是从文档内容里提取:`lark-doc-fetch` 返回的内容里可能包含:
|
||||
- 图片:`<img token="..." .../>`
|
||||
- 文件:`<source token="..." name="..."/>`
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
| `--command` | 是 | 操作指令(见下方指令速查表) |
|
||||
| `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) |
|
||||
| `--content` | 视指令 | 写入内容(`str_replace` 传空字符串可实现删除) |
|
||||
| `--reference-map` | 否 | 结构化 `reference_map` JSON object;当 `--content` 使用正文外部载荷 / 引用映射时与内容一起传给服务,支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。通常用于回写已有 `document.reference_map`。 |
|
||||
| `--pattern` | 视指令 | 匹配文本(str_replace) |
|
||||
| `--block-id` | 视指令 | 目标 block ID(block_* 操作),逗号分隔可批量删除,-1 表示末尾 |
|
||||
| `--src-block-ids` | 视指令 | 源 block ID(逗号分隔),用于 block_copy_insert_after / block_move_after |
|
||||
|
||||
@@ -16,8 +16,11 @@ metadata:
|
||||
|
||||
> **导入分流规则:** 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable,必须优先使用 `lark-cli drive +import --type bitable`。不要先切到 `lark-base`;`lark-base` 只负责导入完成后的表内操作。
|
||||
|
||||
> **副本分流规则:** 如果用户要复制在线文档、创建文档副本、把文档复制到另一个文件夹,必须使用 `lark-cli drive files copy`。不要用 `drive +export` 下载后再 `drive +import` 上传,也不要用 `docs +fetch` + `docs +create` 重建正文;导出/导入只用于本地文件转换或离线产物。
|
||||
|
||||
## 快速决策
|
||||
|
||||
- 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `lark-cli drive files copy`。先用 `lark-cli schema drive.files.copy --format json` 确认参数;如果来源是 wiki URL/token,先用 `lark-cli drive +inspect` 获取底层 `token` 和 `type`,不要把 wiki token 直接当 `file_token`。`params.file_token` 传源文档 token,`data.folder_token` 传目标文件夹 token,`data.name` 传副本名称,`data.type` 传源文件类型(如 `docx` / `sheet` / `bitable` / `slides`)。示例:`lark-cli drive files copy --params '{"file_token":"<DOC_TOKEN>"}' --data '{"folder_token":"<FOLDER_TOKEN>","name":"<COPY_NAME>","type":"docx"}'`。如返回 `confirmation_required`,按 `lark-shared` 高风险审批协议向用户确认后,在原命令末尾追加 `--yes` 重试。
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`,owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。
|
||||
@@ -29,6 +32,7 @@ metadata:
|
||||
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx),切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
|
||||
- 用户要比较原生 `.md` 文件的**历史版本差异**,或比较远端 Markdown 与本地草稿,切到 [`lark-markdown`](../lark-markdown/SKILL.md) 的 `lark-cli markdown +diff`;需要版本号时先用 `drive +version-history`。
|
||||
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history`、`drive +version-get`、`drive +version-revert`、`drive +version-delete`;这组命令同时支持 `--as user` 和 `--as bot`,自动化场景优先 `--as bot`。
|
||||
- 用户要修改文档公开权限设置(链接分享、对外分享、谁可评论/复制/管理协作者),优先使用 `lark-cli drive +public-permission-update`,并先阅读 [`references/lark-drive-public-permission-update.md`](references/lark-drive-public-permission-update.md)。这是 `high-risk-write`,执行前必须 `--dry-run`,真正执行需 `--yes`。
|
||||
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
|
||||
- 用户要在云空间(云盘/云存储)里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
||||
- 用户要查看某个文件有哪些可下载预览格式,或想下载 PDF / HTML / 文本 / 图片等预览产物,使用 `lark-cli drive +preview`。
|
||||
@@ -102,7 +106,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
||||
|
||||
### 权限能力入口
|
||||
|
||||
- 用户要管理 Drive 文档/文件协作者、公开权限、授权当前应用访问文档,或处理 `permission.public.patch` 的 `91009` / `91010` / `91011` / `91012` 错误时,先读 [`lark-drive-permission-guide.md`](references/lark-drive-permission-guide.md)。
|
||||
- 用户要管理 Drive 文档/文件协作者、授权当前应用访问文档,或做权限治理 / 排障时,先读 [`lark-drive-permission-guide.md`](references/lark-drive-permission-guide.md);如果是修改公开权限设置(链接分享、对外分享、谁可评论 / 复制 / 管理协作者),仍按快速决策走 [`lark-drive-public-permission-update.md`](references/lark-drive-public-permission-update.md)。
|
||||
- 用户只是没有访问权限并希望向 owner 申请访问,优先使用 [`+apply-permission`](references/lark-drive-apply-permission.md)。
|
||||
- 普通 scope、身份或登录问题仍按 [`lark-shared`](../lark-shared/SKILL.md) 处理;不要把租户安全策略、对外分享、密级拦截简单归类为缺 scope。
|
||||
|
||||
@@ -145,6 +149,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+inspect`](references/lark-drive-inspect.md) | 检视 URL 的类型、标题和 canonical token;wiki URL 会自动解包到底层文档。 |
|
||||
| [`+apply-permission`](references/lark-drive-apply-permission.md) | 以 user 身份向文档 owner 申请访问权限。 |
|
||||
| [`+member-add`](references/lark-drive-member-add.md) | 添加一个或最多 10 个 Drive 文档、文件、文件夹或 wiki 节点协作者/授权成员;封装 Drive permission member create/batch_create,真实写入需要 `--yes`。 |
|
||||
| [`+public-permission-update`](references/lark-drive-public-permission-update.md) | 更新公开权限设置。高风险写操作:先 `--dry-run`,确认目标和字段后再传 `--yes`。 |
|
||||
| [`+secure-label-list`](references/lark-drive-secure-label.md) | 列出当前用户可用的密级标签。 |
|
||||
| [`+secure-label-update`](references/lark-drive-secure-label.md) | 更新 Drive 文件或文档的密级标签。 |
|
||||
|
||||
@@ -161,7 +166,7 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
|
||||
### files
|
||||
|
||||
- `copy` — 复制文件
|
||||
- `copy` — 复制文件;在线文档创建副本的首选能力,完整参数见上方“快速决策”,不要用 `drive +export` / `drive +import` 绕行复制
|
||||
- `create_folder` — 新建文件夹
|
||||
- `list` — 获取文件夹下的清单;使用前阅读 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md)
|
||||
- `patch` — 修改文件标题
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# drive +public-permission-update(更新公开权限设置)
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli drive +public-permission-update`。
|
||||
|
||||
用于更新云文档/云文件的公开权限设置。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是 `high-risk-write` 操作,会改变文档公开访问或协作边界。必须先用 `--dry-run` 确认请求体;真正执行时需要 `--yes`。
|
||||
|
||||
## 何时使用
|
||||
|
||||
- 用户明确要求修改“链接分享”“对外分享”“谁可以评论/复制/下载/管理协作者”等公开权限设置。
|
||||
- 用户提供文档 URL 或 token,并且已经确认要修改目标文档的权限策略。
|
||||
|
||||
不要用它来“申请自己访问文档”;申请权限走 [`drive +apply-permission`](lark-drive-apply-permission.md)。
|
||||
|
||||
## 身份与权限
|
||||
|
||||
- 支持 `--as user` 和 `--as bot`。
|
||||
- 所需 scope:`docs:permission.setting:write_only`。
|
||||
- `--type` 会作为 query 参数传给接口。URL 输入可自动推断;bare token 必须显式传。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 先预览:关闭对外分享,并关闭链接分享
|
||||
lark-cli drive +public-permission-update \
|
||||
--token "https://example.feishu.cn/docx/doxcnxxxxxxxxx" \
|
||||
--external-access-entity closed \
|
||||
--link-share-entity closed \
|
||||
--dry-run --as user
|
||||
|
||||
# 真正执行:确认 target 和 body 后加 --yes
|
||||
lark-cli drive +public-permission-update \
|
||||
--token "https://example.feishu.cn/docx/doxcnxxxxxxxxx" \
|
||||
--external-access-entity closed \
|
||||
--link-share-entity closed \
|
||||
--yes --as user
|
||||
|
||||
# 使用 bare token 时必须显式传 --type
|
||||
lark-cli drive +public-permission-update \
|
||||
--token "doxcnxxxxxxxxx" --type docx \
|
||||
--external-access-entity closed \
|
||||
--link-share-entity closed \
|
||||
--yes --as user
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--token` | 是 | 目标 token 或完整 URL。支持 `/docx/`、`/sheets/`、`/base/`、`/bitable/`、`/file/`、`/wiki/`、`/doc/`、`/mindnotes/`、`/minutes/`、`/slides/` 路径自动提取 token |
|
||||
| `--type` | URL 可省略;bare token 必填 | 目标类型:`doc`、`sheet`、`file`、`wiki`、`bitable`、`docx`、`mindnote`、`minutes`、`slides` |
|
||||
| `--security-entity` | 否 | 谁可以复制内容、创建副本、打印、下载:`anyone_can_view`、`anyone_can_edit`、`only_full_access` |
|
||||
| `--comment-entity` | 否 | 谁可以评论:`anyone_can_view`、`anyone_can_edit` |
|
||||
| `--share-entity` | 否 | 从组织维度,设置谁可以查看、添加、移除协作者:`anyone`、`same_tenant` |
|
||||
| `--manage-collaborator-entity` | 否 | 谁可以管理协作者:`collaborator_can_view`、`collaborator_can_edit`、`collaborator_full_access` |
|
||||
| `--link-share-entity` | 否 | 链接分享设置:`tenant_readable`、`tenant_editable`、`anyone_readable`、`anyone_editable`、`partner_tenant_readable`、`partner_tenant_editable`、`closed` |
|
||||
| `--copy-entity` | 否 | 谁可以创建副本:`anyone_can_view`、`anyone_can_edit`、`only_full_access` |
|
||||
| `--external-access-entity` | 否 | 对外分享设置:`open`、`closed`、`allow_share_partner_tenant` |
|
||||
| `--perm-type` | 否 | 权限范围:`container`、`single_page`。`single_page` 仅支持 `--link-share-entity` 和/或 `--external-access-entity`,不能混用其它权限字段 |
|
||||
| `--dry-run` | 否 | 只打印请求,不执行 |
|
||||
| `--yes` | 执行时必填 | 确认 high-risk-write 操作 |
|
||||
|
||||
至少要指定一个实际权限字段:`--security-entity`、`--comment-entity`、`--share-entity`、`--manage-collaborator-entity`、`--link-share-entity`、`--copy-entity`、`--external-access-entity`。单独传 `--perm-type` 会被拒绝。
|
||||
|
||||
## 请求体形状
|
||||
|
||||
```json
|
||||
{
|
||||
"external_access_entity": "closed",
|
||||
"link_share_entity": "closed"
|
||||
}
|
||||
```
|
||||
|
||||
## Wiki URL
|
||||
|
||||
传入 `/wiki/<node_token>` 时,shortcut 会以 `type=wiki` 直接调用公开权限接口。如果你要修改 wiki 背后的实际 docx/sheet/bitable 对象,先用 `drive +inspect` 或 `wiki spaces get_node` 拿到底层 `obj_token` 和 `obj_type`,再用 bare token + `--type <obj_type>` 调用。
|
||||
|
||||
## 常见错误
|
||||
|
||||
| 错误码 | 含义 | 引导 |
|
||||
|--------|------|------|
|
||||
| `1063001` | 参数异常 | 检查 token 和 `--type` 是否匹配、资源是否存在、字段枚举是否为 v2 文档支持值。`--external-access-entity` 与 `--link-share-entity` 同时传时可能出现参数冲突;`--perm-type single_page` 也有字段限制 |
|
||||
| `1063002` | 权限不足 | 确认当前 user 或 bot 是目标文档协作者,并具备编辑或管理权限;bot 场景需要先给文档添加应用权限 |
|
||||
| `1063003` | 操作不被允许 | 通常是企业策略、可见性、协作者上限或已有权限更高导致;不要简单提示补 scope |
|
||||
| `1063004` | 用户无分享权限 | 确认调用身份对目标文档有分享权限 |
|
||||
| `1063005` | 资源已删除 | 确认目标云文档仍存在 |
|
||||
|
||||
### 策略 / 密级拦截错误
|
||||
|
||||
调用 `lark-cli drive +public-permission-update` 返回以下错误码时,优先按租户策略、对外分享开关或文档密级处理,不要简单提示补 scope。
|
||||
|
||||
| 错误码 | 含义 | 引导 |
|
||||
|--------|------|------|
|
||||
| `91009` | 对外分享被租户安全策略管控,当前用户无法开启 | 提示用户:对外分享能力被租户安全策略统一管控,无法通过当前命令或当前用户直接开启;需要联系租户管理员调整组织级对外分享策略。 |
|
||||
| `91010` | 文档对外分享未打开 | 按用户目标分流,不要扩大变更面:只关闭对外分享时仅传 `--external-access-entity closed`;只关闭链接分享时仅传 `--link-share-entity closed`;只有用户明确要求彻底关闭公开访问时,才同时传 `--external-access-entity closed --link-share-entity closed`。只有用户明确要求开放外部访问时,才提示先在文档权限设置中打开对外分享并确认风险后重试。 |
|
||||
| `91011` | 对外分享被文档密级管控 | 提示用户:对外分享被密级策略拦截,需要打开目标文档,在文档内发起密级豁免或进行密级降级后再重试;回复中必须给出目标文档 URL。 |
|
||||
| `91012` | 权限设置被文档密级管控 | 提示用户:该权限设置被密级策略拦截,需要打开目标文档,在文档内发起密级豁免或进行密级降级后再重试;回复中必须给出目标文档 URL。 |
|
||||
|
||||
遇到 `91011` 或 `91012` 时,如果用户最初提供的是文档 URL,直接把该 URL 原样返回给用户作为操作入口;如果上下文只有 token,先尽量通过已有上下文、搜索结果或元数据恢复目标文档 URL,再给出可点击的文档 URL。
|
||||
|
||||
### 服务端运行时错误引导
|
||||
|
||||
以下场景 CLI 侧无法静态校验(依赖当前文档状态),需要根据服务端返回的错误信息引导用户:
|
||||
|
||||
**link_share 与当前 external 冲突:** 如果只传了 `--link-share-entity` 没传 `--external-access-entity`,服务端会用当前文档的 external 状态校验 link_share 是否合法。例如当前文档 external=closed,设置 link_share=anyone_readable 会被拒绝。按用户目标分流:如果目标是收紧或关闭公开访问,改为同时传 `--external-access-entity closed` 和 `--link-share-entity closed`;只有用户明确要求互联网或外部可访问时,才提示先确认风险,再同时传 `--external-access-entity open` 和对应的 `--link-share-entity`。不要把这类冲突默认解释为需要打开对外分享。
|
||||
|
||||
**单页面独立权限:** `--perm-type single_page` 只适用于有 container 的文档,会只修改当前页面的链接分享或对外分享设置;legacy doc 不支持该能力,会被服务端按参数错误拒绝。单页面权限可以与容器权限不同;服务端返回或查询结果中的 `lock_switch=true` 表示当前页面已限制权限、不再继承父级页面权限。
|
||||
|
||||
遇到企业策略、对外分享或密级拦截时,不要把它们简单归类成缺少 scope,应引导用户检查租户安全策略和文档权限设置。
|
||||
@@ -33,6 +33,7 @@ lark-cli config init --new
|
||||
| 按业务域授权 | `lark-cli auth login --domain docs --domain drive --no-wait --json`;`--domain` 可重复,也可用逗号分隔 |
|
||||
| 指定单个 scope 授权 | `lark-cli auth login --scope "<scope>" --no-wait --json` |
|
||||
| 检查当前登录态、是谁登录、token 是否有效 | `lark-cli auth status --json --verify`;回答时引用 `identity`、`verified`、`identities.user.status`、`identities.user.userName`、`identities.user.openId`(用户 open id)、`identities.user.tokenStatus`、`identities.user.scope` |
|
||||
| 快速看当前生效身份(人类可读) | `lark-cli whoami`;聚焦实际生效的那一个身份(走 `--as`/`default-as`/strict-mode 解析,可能与 `auth status` 的「第一个可用身份」不同),含当前 profile。脚本/agent 取结构化结果加 `--json`(字段 `identity`、`identitySource`、`available`、`tokenStatus`)。深度诊断 / 服务器校验仍用 `auth status --json --verify` |
|
||||
| 退出当前机器的用户登录态 | `lark-cli auth logout --json`;`loggedOut:true` 表示注销成功 |
|
||||
| bot 缺少权限 | 不要执行 `auth login`;引导用户在开发者后台开通 bot scope,优先复用错误里的 `console_url` |
|
||||
| 取消用户对应用的全部服务端授权 | `auth logout` 只清本机登录态;服务端授权需用户在飞书授权管理页取消 |
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- TestDocs_CreateAndFetchWorkflow: proves `docs +create` and `docs +fetch`; key `t.Run(...)` proof points are `create as bot` and `fetch as bot`.
|
||||
- TestDocs_CreateAndFetchWorkflowAsUser: proves the same shortcut pair with UAT injection via `create as user` and `fetch as user`; creates its own Drive folder fixture first, then reads back the created doc by token.
|
||||
- TestDocs_UpdateWorkflow: proves `docs +update` via `update-title-and-content as bot`, then re-fetches the same doc in `verify as bot` to assert persisted title/content changes.
|
||||
- TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance.
|
||||
- TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance; its fetch case asserts fetch sends the default `extra_param`, and its update case asserts `--reference-map` is sent as request body `reference_map`.
|
||||
- TestDocs_CreateTitleDryRunPrependsContent: proves `docs +create --title` dry-run prepends an escaped `<title>...</title>` tag to request body `content`.
|
||||
- Setup note: docs workflows create a Drive folder through `drive files create_folder` in `helpers_test.go`; that helper is external to the docs domain and is not counted here.
|
||||
- Blocked area: media and search shortcuts still need deterministic fixtures and local file orchestration.
|
||||
@@ -19,10 +19,10 @@
|
||||
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title` | helper asserts returned doc id from `data.document.document_id`; dry-run asserts title is prepended into request body content |
|
||||
| ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown` | |
|
||||
| ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true` | |
|
||||
| ✕ | docs +media-download | shortcut | | none | no media fixture workflow yet |
|
||||
| ✕ | docs +media-insert | shortcut | | none | requires deterministic upload fixture and rollback assertions |
|
||||
| ✕ | docs +media-preview | shortcut | | none | requires deterministic media fixture |
|
||||
| ✕ | docs +search | shortcut | | none | search results are ambient and not yet stabilized for E2E |
|
||||
| ✓ | docs +update | shortcut | docs_update_test.go::TestDocs_UpdateWorkflow/update-title-and-content as bot; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/update | `--doc`; `--command overwrite`; `--doc-format markdown`; `--content` | |
|
||||
| ✓ | docs +update | shortcut | docs_update_test.go::TestDocs_UpdateWorkflow/update-title-and-content as bot; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/update | `--doc`; `--command overwrite`; `--doc-format markdown`; `--content`; optional `--reference-map` -> body `reference_map` | |
|
||||
| ✕ | docs +whiteboard-update | shortcut | | none | requires whiteboard fixture and DSL-specific assertions |
|
||||
|
||||
@@ -24,9 +24,11 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
t.Cleanup(cancel)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantURL string
|
||||
name string
|
||||
args []string
|
||||
wantURL string
|
||||
wantExtraParam string
|
||||
wantRefLabel string
|
||||
}{
|
||||
{
|
||||
name: "create",
|
||||
@@ -54,7 +56,8 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
"--doc", "doxcnDryRunE2E",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch",
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch",
|
||||
wantExtraParam: `{"enable_user_cite_reference_map":true}`,
|
||||
},
|
||||
{
|
||||
name: "update",
|
||||
@@ -67,6 +70,19 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
},
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E",
|
||||
},
|
||||
{
|
||||
name: "update reference-map",
|
||||
args: []string{
|
||||
"docs", "+update",
|
||||
"--doc", "doxcnDryRunE2E",
|
||||
"--command", "append",
|
||||
"--content", `<p><widget data-ref="r1"></widget></p>`,
|
||||
"--reference-map", `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E",
|
||||
wantRefLabel: "widget-ref-value",
|
||||
},
|
||||
{
|
||||
name: "block_delete batch",
|
||||
args: []string{
|
||||
@@ -104,6 +120,14 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
if strings.Contains(combined, "--api-version") {
|
||||
t.Fatalf("dry-run output should not ask for --api-version\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
if tt.wantExtraParam != "" {
|
||||
extraParam := gjson.Get(result.Stdout, "api.0.body.extra_param").String()
|
||||
require.JSONEq(t, tt.wantExtraParam, extraParam, "stdout:\n%s", result.Stdout)
|
||||
}
|
||||
if tt.wantRefLabel != "" {
|
||||
got := gjson.Get(result.Stdout, "api.0.body.reference_map.widget.r1.label").String()
|
||||
require.Equal(t, tt.wantRefLabel, got, "stdout:\n%s", result.Stdout)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
- TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields.
|
||||
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.
|
||||
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
|
||||
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
|
||||
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
|
||||
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
|
||||
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
|
||||
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
|
||||
@@ -29,7 +29,7 @@
|
||||
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
|
||||
| ✕ | drive +delete | shortcut | | none | no primary delete workflow yet |
|
||||
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
|
||||
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_BitableBaseOnlySchema | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema` | dry-run only; no live export workflow yet |
|
||||
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
|
||||
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
|
||||
| ✕ | drive +import | shortcut | | none | no import workflow yet |
|
||||
| ✕ | drive +move | shortcut | | none | no move workflow yet |
|
||||
|
||||
@@ -92,6 +92,9 @@ func TestDriveExportDryRun_MarkdownFetchAPI(t *testing.T) {
|
||||
if got := gjson.Get(out, "api.0.body.format").String(); got != "markdown" {
|
||||
t.Fatalf("body.format=%q, want markdown\nstdout:\n%s", got, out)
|
||||
}
|
||||
if gjson.Get(out, "api.0.body.extra_param").Exists() {
|
||||
t.Fatalf("markdown drive export must not enable docs fetch extra_param\nstdout:\n%s", out)
|
||||
}
|
||||
if got := gjson.Get(out, "file_name").String(); got != "my-notes.md" {
|
||||
t.Fatalf("file_name=%q, want my-notes.md\nstdout:\n%s", got, out)
|
||||
}
|
||||
|
||||
175
tests/cli_e2e/drive/drive_public_permission_dryrun_test.go
Normal file
175
tests/cli_e2e/drive/drive_public_permission_dryrun_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDrive_PublicPermissionUpdateDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantURL string
|
||||
wantType string
|
||||
assert func(t *testing.T, out string)
|
||||
}{
|
||||
{
|
||||
name: "URL input infers type and sends v2 fields",
|
||||
args: []string{
|
||||
"drive", "+public-permission-update",
|
||||
"--token", "https://example.feishu.cn/docx/doxcnE2E001?from=share",
|
||||
"--external-access-entity", "open",
|
||||
"--link-share-entity", "anyone_readable",
|
||||
"--perm-type", "single_page",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v2/permissions/doxcnE2E001/public",
|
||||
wantType: "docx",
|
||||
assert: func(t *testing.T, out string) {
|
||||
if got := gjson.Get(out, "api.0.body.external_access_entity").String(); got != "open" {
|
||||
t.Fatalf("body.external_access_entity = %q, want open\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.link_share_entity").String(); got != "anyone_readable" {
|
||||
t.Fatalf("body.link_share_entity = %q, want anyone_readable\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.perm_type").String(); got != "single_page" {
|
||||
t.Fatalf("body.perm_type = %q, want single_page\nstdout:\n%s", got, out)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bare token requires explicit type and sends flat body",
|
||||
args: []string{
|
||||
"drive", "+public-permission-update",
|
||||
"--token", "shtcnE2E002",
|
||||
"--type", "sheet",
|
||||
"--security-entity", "only_full_access",
|
||||
"--comment-entity", "anyone_can_edit",
|
||||
"--manage-collaborator-entity", "collaborator_full_access",
|
||||
"--copy-entity", "anyone_can_view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v2/permissions/shtcnE2E002/public",
|
||||
wantType: "sheet",
|
||||
assert: func(t *testing.T, out string) {
|
||||
if got := gjson.Get(out, "api.0.body.security_entity").String(); got != "only_full_access" {
|
||||
t.Fatalf("body.security_entity = %q, want only_full_access\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.comment_entity").String(); got != "anyone_can_edit" {
|
||||
t.Fatalf("body.comment_entity = %q, want anyone_can_edit\nstdout:\n%s", got, out)
|
||||
}
|
||||
if gjson.Get(out, "api.0.body.permission_public").Exists() {
|
||||
t.Fatalf("body must be flat, stdout:\n%s", out)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "PATCH" {
|
||||
t.Fatalf("method = %q, want PATCH\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL {
|
||||
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.type").String(); got != tt.wantType {
|
||||
t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantType, out)
|
||||
}
|
||||
tt.assert(t, out)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrive_PublicPermissionUpdateDryRunRejectsMissingTypeForBareToken(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+public-permission-update",
|
||||
"--token", "doxcnE2E999",
|
||||
"--link-share-entity", "closed",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
requireDrivePublicPermissionValidationEnvelope(t, result, "--type", "--type is required")
|
||||
}
|
||||
|
||||
func TestDrive_PublicPermissionUpdateDryRunRejectsSinglePageMixedFields(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+public-permission-update",
|
||||
"--token", "doxcnE2E999",
|
||||
"--type", "docx",
|
||||
"--link-share-entity", "closed",
|
||||
"--copy-entity", "only_full_access",
|
||||
"--perm-type", "single_page",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
requireDrivePublicPermissionValidationEnvelope(t, result, "--perm-type", "remove --copy-entity")
|
||||
}
|
||||
|
||||
func requireDrivePublicPermissionValidationEnvelope(t *testing.T, result *clie2e.Result, wantParam, wantMessage string) {
|
||||
t.Helper()
|
||||
if result.ExitCode == 0 {
|
||||
t.Fatalf("command must be rejected, stdout:\n%s", result.Stdout)
|
||||
}
|
||||
if got := gjson.Get(result.Stderr, "error.type").String(); got != "validation" {
|
||||
t.Fatalf("error.type = %q, want validation\nstdout:\n%s\nstderr:\n%s", got, result.Stdout, result.Stderr)
|
||||
}
|
||||
if got := gjson.Get(result.Stderr, "error.subtype").String(); got != "invalid_argument" {
|
||||
t.Fatalf("error.subtype = %q, want invalid_argument\nstdout:\n%s\nstderr:\n%s", got, result.Stdout, result.Stderr)
|
||||
}
|
||||
if got := gjson.Get(result.Stderr, "error.param").String(); got != wantParam {
|
||||
t.Fatalf("error.param = %q, want %q\nstdout:\n%s\nstderr:\n%s", got, wantParam, result.Stdout, result.Stderr)
|
||||
}
|
||||
message := gjson.Get(result.Stderr, "error.message").String()
|
||||
if !strings.Contains(message, wantMessage) {
|
||||
t.Fatalf("error.message %q does not contain %q\nstdout:\n%s\nstderr:\n%s", message, wantMessage, result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user