From 6d8dc402acd2b56fd0b63fbecc2270afc16310fb Mon Sep 17 00:00:00 2001 From: linchao5102 Date: Thu, 11 Jun 2026 01:49:06 +0800 Subject: [PATCH] fix: support git credential dry-run (#1390) * fix: support git credential dry-run * test: cover git credential dry-run output --- shortcuts/apps/git_credential.go | 35 ++++++ shortcuts/apps/git_credential_test.go | 118 ++++++++++++++++++ .../apps/apps_git_credential_dryrun_test.go | 57 ++++++++- 3 files changed, 209 insertions(+), 1 deletion(-) diff --git a/shortcuts/apps/git_credential.go b/shortcuts/apps/git_credential.go index 250445f7..2e1789eb 100644 --- a/shortcuts/apps/git_credential.go +++ b/shortcuts/apps/git_credential.go @@ -11,6 +11,7 @@ import ( "io" "net/http" "net/url" + "path/filepath" "sort" "strconv" "strings" @@ -61,7 +62,15 @@ var AppsGitCredentialInit = common.Shortcut{ return common.NewDryRunAPI(). GET(gitCredentialIssuePath). Desc("Issue a Miaoda Git repository PAT"). + Set("mode", "api-plus-local-setup"). + Set("action", "initialize_local_git_credential"). Set("app_id", appID). + Set("metadata_file", appKeyPath(appID, gitcred.MetadataFilename)). + Set("local_effects", []string{ + "save the issued PAT in the local system credential store", + "write app-scoped git credential metadata", + "configure a URL-scoped Git credential helper in global git config when possible", + }). Params(gitCredentialIssueParams(appID)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { @@ -124,6 +133,21 @@ var AppsGitCredentialRemove = common.Shortcut{ } return validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id") }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID := strings.TrimSpace(rctx.Str("app-id")) + return common.NewDryRunAPI(). + Desc("Preview local Git credential cleanup (no API call; would clean up local-only state)."). + Set("mode", "local-cleanup-only"). + Set("action", "remove_local_git_credential"). + Set("app_id", appID). + Set("metadata_file", appKeyPath(appID, gitcred.MetadataFilename)). + Set("effects", []string{ + "read app-scoped git credential metadata", + "remove the saved PAT from the local system credential store", + "remove the app-scoped Git helper from global git config when present", + "delete the local metadata record after cleanup succeeds", + }) + }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID := strings.TrimSpace(rctx.Str("app-id")) manager := newGitCredentialManager(appID, rctx.Factory.Keychain, nil) @@ -171,6 +195,17 @@ var AppsGitCredentialList = common.Shortcut{ Scopes: []string{}, AuthTypes: []string{"user"}, HasFormat: true, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + Desc("Preview local Git credential listing (no API call, read-only local state)."). + Set("mode", "local-read-only"). + Set("action", "list_local_git_credentials"). + Set("storage_root", filepath.Join(core.GetConfigDir(), storageRoot)). + Set("reads", []string{ + "scan app-scoped git credential metadata under the CLI config directory", + "derive per-app repository URLs and local credential status from local metadata", + }) + }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { records, err := listGitCredentialRecords(rctx.Factory.Keychain, time.Now) if err != nil { diff --git a/shortcuts/apps/git_credential_test.go b/shortcuts/apps/git_credential_test.go index 16146f8e..8aeab946 100644 --- a/shortcuts/apps/git_credential_test.go +++ b/shortcuts/apps/git_credential_test.go @@ -45,6 +45,11 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) { Params map[string]interface{} `json:"params"` Body interface{} `json:"body"` } `json:"api"` + Mode string `json:"mode"` + Action string `json:"action"` + AppID string `json:"app_id"` + MetadataFile string `json:"metadata_file"` + LocalEffects []string `json:"local_effects"` } if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil { t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String()) @@ -65,6 +70,107 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) { if call.Body != nil { t.Fatalf("body = %#v, want nil", call.Body) } + if payload.Mode != "api-plus-local-setup" { + t.Fatalf("mode = %q", payload.Mode) + } + if payload.Action != "initialize_local_git_credential" { + t.Fatalf("action = %q", payload.Action) + } + if payload.AppID != "app_xxx" { + t.Fatalf("app_id = %q", payload.AppID) + } + if !strings.HasSuffix(payload.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) { + t.Fatalf("metadata_file = %q", payload.MetadataFile) + } + assertStringSliceEqual(t, payload.LocalEffects, []string{ + "save the issued PAT in the local system credential store", + "write app-scoped git credential metadata", + "configure a URL-scoped Git credential helper in global git config when possible", + }) +} + +func TestAppsGitCredentialListDryRunDescribesLocalReads(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsGitCredentialList, + []string{"+git-credential-list", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var payload struct { + Description string `json:"description"` + API []interface{} `json:"api"` + Mode string `json:"mode"` + Action string `json:"action"` + StorageRoot string `json:"storage_root"` + Reads []string `json:"reads"` + } + if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String()) + } + if payload.Description != "Preview local Git credential listing (no API call, read-only local state)." { + t.Fatalf("description = %q", payload.Description) + } + if len(payload.API) != 0 { + t.Fatalf("api len = %d, want 0", len(payload.API)) + } + if payload.Mode != "local-read-only" { + t.Fatalf("mode = %q", payload.Mode) + } + if payload.Action != "list_local_git_credentials" { + t.Fatalf("action = %q", payload.Action) + } + if !strings.HasSuffix(payload.StorageRoot, filepath.Join("spark")) { + t.Fatalf("storage_root = %q", payload.StorageRoot) + } + assertStringSliceEqual(t, payload.Reads, []string{ + "scan app-scoped git credential metadata under the CLI config directory", + "derive per-app repository URLs and local credential status from local metadata", + }) +} + +func TestAppsGitCredentialRemoveDryRunDescribesLocalCleanup(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsGitCredentialRemove, + []string{"+git-credential-remove", "--app-id", "app_xxx", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var payload struct { + Description string `json:"description"` + API []interface{} `json:"api"` + Mode string `json:"mode"` + Action string `json:"action"` + AppID string `json:"app_id"` + MetadataFile string `json:"metadata_file"` + Effects []string `json:"effects"` + } + if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String()) + } + if payload.Description != "Preview local Git credential cleanup (no API call; would clean up local-only state)." { + t.Fatalf("description = %q", payload.Description) + } + if len(payload.API) != 0 { + t.Fatalf("api len = %d, want 0", len(payload.API)) + } + if payload.Mode != "local-cleanup-only" { + t.Fatalf("mode = %q", payload.Mode) + } + if payload.Action != "remove_local_git_credential" { + t.Fatalf("action = %q", payload.Action) + } + if payload.AppID != "app_xxx" { + t.Fatalf("app_id = %q", payload.AppID) + } + if !strings.HasSuffix(payload.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) { + t.Fatalf("metadata_file = %q", payload.MetadataFile) + } + assertStringSliceEqual(t, payload.Effects, []string{ + "read app-scoped git credential metadata", + "remove the saved PAT from the local system credential store", + "remove the app-scoped Git helper from global git config when present", + "delete the local metadata record after cleanup succeeds", + }) } func TestAppsGitCredentialInitRequiresAppID(t *testing.T) { @@ -579,6 +685,18 @@ func TestAppsGitCredentialRemoveReturnsStoreError(t *testing.T) { } } +func assertStringSliceEqual(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("slice len = %d, want %d; got %#v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("slice[%d] = %q, want %q; got %#v", i, got[i], want[i], got) + } + } +} + func TestGitCredentialLocalErrorWrapsOnlyPlainErrors(t *testing.T) { plain := errors.New("git config failed") wrapped := gitCredentialLocalError("List local Miaoda Git credentials", plain) diff --git a/tests/cli_e2e/apps/apps_git_credential_dryrun_test.go b/tests/cli_e2e/apps/apps_git_credential_dryrun_test.go index 854ecca3..67718a7e 100644 --- a/tests/cli_e2e/apps/apps_git_credential_dryrun_test.go +++ b/tests/cli_e2e/apps/apps_git_credential_dryrun_test.go @@ -5,6 +5,8 @@ package apps import ( "context" + "path/filepath" + "strings" "testing" "time" @@ -26,7 +28,8 @@ func TestAppsGitCredentialInitDryRun(t *testing.T) { "--app-id", "app_xxx", "--dry-run", }, - DefaultAs: "user", + BinaryPath: "../../../lark-cli", + DefaultAs: "user", }) require.NoError(t, err) result.AssertExitCode(t, 0) @@ -35,4 +38,56 @@ func TestAppsGitCredentialInitDryRun(t *testing.T) { assert.Equal(t, "/open-apis/spark/v1/apps/app_xxx/git_info", gjson.Get(result.Stdout, "api.0.url").String()) assert.Equal(t, "app_xxx", gjson.Get(result.Stdout, "api.0.params.app_id").String()) assert.False(t, gjson.Get(result.Stdout, "api.0.body").Exists()) + assert.Equal(t, "api-plus-local-setup", gjson.Get(result.Stdout, "mode").String()) + assert.Equal(t, "initialize_local_git_credential", gjson.Get(result.Stdout, "action").String()) + assert.True(t, strings.HasSuffix(gjson.Get(result.Stdout, "metadata_file").String(), filepath.Join("spark", "app_xxx", "git.json"))) + assert.Equal(t, int64(3), gjson.Get(result.Stdout, "local_effects.#").Int()) + assert.Equal(t, "save the issued PAT in the local system credential store", gjson.Get(result.Stdout, "local_effects.0").String()) + assert.Equal(t, "write app-scoped git credential metadata", gjson.Get(result.Stdout, "local_effects.1").String()) + assert.Equal(t, "configure a URL-scoped Git credential helper in global git config when possible", gjson.Get(result.Stdout, "local_effects.2").String()) +} + +func TestAppsGitCredentialListDryRun(t *testing.T) { + setAppsDryRunEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+git-credential-list", "--dry-run"}, + BinaryPath: "../../../lark-cli", + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.Equal(t, "Preview local Git credential listing (no API call, read-only local state).", gjson.Get(result.Stdout, "description").String()) + assert.Equal(t, "local-read-only", gjson.Get(result.Stdout, "mode").String()) + assert.Equal(t, "list_local_git_credentials", gjson.Get(result.Stdout, "action").String()) + assert.Equal(t, int64(0), gjson.Get(result.Stdout, "api.#").Int()) + assert.Contains(t, gjson.Get(result.Stdout, "storage_root").String(), filepath.Join("", "spark")) + assert.Equal(t, "scan app-scoped git credential metadata under the CLI config directory", gjson.Get(result.Stdout, "reads.0").String()) +} + +func TestAppsGitCredentialRemoveDryRun(t *testing.T) { + setAppsDryRunEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+git-credential-remove", "--app-id", "app_xxx", "--dry-run"}, + BinaryPath: "../../../lark-cli", + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.Equal(t, "Preview local Git credential cleanup (no API call; would clean up local-only state).", gjson.Get(result.Stdout, "description").String()) + assert.Equal(t, "local-cleanup-only", gjson.Get(result.Stdout, "mode").String()) + assert.Equal(t, "remove_local_git_credential", gjson.Get(result.Stdout, "action").String()) + assert.Equal(t, "app_xxx", gjson.Get(result.Stdout, "app_id").String()) + assert.Equal(t, int64(0), gjson.Get(result.Stdout, "api.#").Int()) + assert.True(t, strings.HasSuffix(gjson.Get(result.Stdout, "metadata_file").String(), filepath.Join("spark", "app_xxx", "git.json"))) + assert.Equal(t, "read app-scoped git credential metadata", gjson.Get(result.Stdout, "effects.0").String()) }