Files
larksuite-cli/shortcuts/apps/git_credential_test.go
2026-06-30 19:57:04 +08:00

1265 lines
48 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/apps/gitcred"
"github.com/larksuite/cli/shortcuts/common"
)
func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsGitCredentialInit,
[]string{"+git-credential-init", "--app-id", "app_xxx", "--dry-run", "--as", "user"},
factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var payload struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
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())
}
if len(payload.API) != 1 {
t.Fatalf("api len = %d, want 1", len(payload.API))
}
call := payload.API[0]
if call.Method != "GET" {
t.Fatalf("method = %q, want GET", call.Method)
}
if call.URL != "/open-apis/spark/v1/apps/app_xxx/git_info" {
t.Fatalf("url = %q", call.URL)
}
if call.Params["app_id"] != "app_xxx" {
t.Fatalf("app_id param = %v", call.Params["app_id"])
}
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) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsGitCredentialInit, []string{"+git-credential-init", "--app-id", " ", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "--app-id is required") {
t.Fatalf("expected --app-id validation error, got %v", err)
}
}
func TestIssuedFromDataAcceptsBackendGetAppGitInfoFields(t *testing.T) {
expiresAt := time.Now().Add(24 * time.Hour).Unix()
issued, err := issuedFromData("app_xxx", map[string]interface{}{
"gitURL": "https://example.com/git/u/app.git",
"username": "x-access-token",
"token": "pat-token",
"expiredTime": float64(expiresAt),
})
if err != nil {
t.Fatalf("issuedFromData returned error: %v", err)
}
if issued.GitHTTPURL != "https://example.com/git/u/app.git" {
t.Fatalf("GitHTTPURL = %q", issued.GitHTTPURL)
}
if issued.PAT != "pat-token" {
t.Fatalf("PAT = %q", issued.PAT)
}
if issued.ExpiresAt != expiresAt {
t.Fatalf("ExpiresAt = %d", issued.ExpiresAt)
}
}
func TestParseIssueCredentialDataAcceptsDirectBaseRespShape(t *testing.T) {
data, err := parseIssueCredentialData(&larkcore.ApiResp{
StatusCode: http.StatusOK,
RawBody: []byte(`{
"gitURL":"https://example.com/git/u/app.git",
"username":"x-access-token",
"token":"pat-token",
"expiredTime":1780050600,
"BaseResp":{"StatusCode":0,"StatusMessage":"ok"}
}`),
}, nil, errclass.ClassifyContext{})
if err != nil {
t.Fatalf("parseIssueCredentialData returned error: %v", err)
}
if data["gitURL"] != "https://example.com/git/u/app.git" {
t.Fatalf("gitURL = %v", data["gitURL"])
}
if data["token"] != "pat-token" {
t.Fatalf("token = %v", data["token"])
}
}
func TestAppsGitCredentialInitExecutesAndRefreshes(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
kc := newAppsTestKeychain()
factory.Keychain = kc
installAppsFakeGit(t, 0)
expiresAt := time.Now().Add(24 * time.Hour).Unix()
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_xxx/git_info",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"gitURL": "https://example.com/git/u/app.git",
"username": "x-access-token",
"token": "pat-token",
"expiredTime": float64(expiresAt),
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_xxx/git_info",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"gitURL": "https://example.com/git/u/app.git",
"username": "x-access-token",
"token": "newer-token",
"expiredTime": float64(expiresAt + 20000),
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_xxx/git_info",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"gitURL": "https://example.com/git/u/app.git",
"username": "x-access-token",
"token": "new-token",
"expiredTime": float64(expiresAt + 10000),
},
},
})
if err := runAppsShortcut(t, AppsGitCredentialInit, []string{"+git-credential-init", "--app-id", "app_xxx", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute init err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"status": "initialized"`) || !strings.Contains(got, `"repository_url": "https://example.com/git/u/app.git"`) {
t.Fatalf("init stdout = %s", got)
}
meta, err := Read("app_xxx", gitcred.MetadataFilename)
if err != nil {
t.Fatalf("read app-scoped metadata: %v", err)
}
if !strings.Contains(string(meta), `"git_http_url": "https://example.com/git/u/app.git"`) {
t.Fatalf("metadata missing git url: %s", meta)
}
if strings.Contains(string(meta), "pat-token") || strings.Contains(string(meta), `"credentials"`) {
t.Fatalf("metadata should be app-scoped and must not contain PAT: %s", meta)
}
if len(kc.values) != 1 {
t.Fatalf("keychain entries = %#v, want one PAT entry", kc.values)
}
for ref, pat := range kc.values {
if ref == "" {
t.Fatal("keychain ref is empty")
}
if pat != "pat-token" {
t.Fatalf("keychain PAT = %q, want pat-token", pat)
}
}
if err := runAppsShortcut(t, AppsGitCredentialInit, []string{"+git-credential-init", "--app-id", "app_xxx", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute refresh err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"status": "refreshed"`) {
t.Fatalf("refresh stdout = %s", got)
}
if err := runAppsShortcut(t, AppsGitCredentialInit, []string{"+git-credential-init", "--app-id", "app_xxx", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute pretty refresh err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "Git credential refreshed") || !strings.Contains(got, "git clone https://example.com/git/u/app.git") {
t.Fatalf("pretty refresh stdout = %s", got)
}
}
func TestAppsGitCredentialInitPrettyWithGitConfigWarning(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
factory.Keychain = newAppsTestKeychain()
installAppsFakeGit(t, 7)
expiresAt := time.Now().Add(24 * time.Hour).Unix()
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_xxx/git_info",
Body: map[string]interface{}{
"gitURL": "https://example.com/git/u/app.git",
"username": "x-access-token",
"token": "pat-token",
"expiredTime": float64(expiresAt),
"BaseResp": map[string]interface{}{
"StatusCode": 0,
"StatusMessage": "ok",
},
},
})
if err := runAppsShortcut(t, AppsGitCredentialInit, []string{"+git-credential-init", "--app-id", "app_xxx", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute init err=%v", err)
}
got := stdout.String()
for _, want := range []string{
"Git credential initialized",
"Status: initialized",
"Repository URL: https://example.com/git/u/app.git",
"Git credential saved, but Git helper was not configured",
"Next step: lark-cli apps +git-credential-init --app-id app_xxx",
} {
if !strings.Contains(got, want) {
t.Fatalf("pretty stdout missing %q in:\n%s", want, got)
}
}
}
func TestAppsGitCredentialInitAPIError(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
factory.Keychain = newAppsTestKeychain()
installAppsFakeGit(t, 0)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_xxx/git_info",
Status: http.StatusBadRequest,
Body: map[string]interface{}{"msg": "permission denied"},
})
err := runAppsShortcut(t, AppsGitCredentialInit, []string{"+git-credential-init", "--app-id", "app_xxx", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "permission denied") {
t.Fatalf("expected API error, got %v", err)
}
}
func TestAppsGitCredentialInitHooksDirectly(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().String("app-id", "", "")
if err := cmd.Flags().Set("app-id", " "); err != nil {
t.Fatalf("set flag: %v", err)
}
rctx := &common.RuntimeContext{Cmd: cmd}
if err := AppsGitCredentialInit.Validate(context.Background(), rctx); err == nil {
t.Fatal("Validate returned nil for blank app-id")
}
if err := cmd.Flags().Set("app-id", " app_xxx "); err != nil {
t.Fatalf("set flag: %v", err)
}
if AppsGitCredentialInit.DryRun(context.Background(), rctx) == nil {
t.Fatal("DryRun returned nil")
}
}
func TestAppsGitCredentialRemove(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
factory.Keychain = newAppsTestKeychain()
installAppsFakeGit(t, 0)
expiresAt := time.Now().Add(24 * time.Hour).Unix()
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_xxx/git_info",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"gitURL": "https://example.com/git/u/app.git",
"token": "pat-token",
"expiredTime": float64(expiresAt),
},
},
})
if err := runAppsShortcut(t, AppsGitCredentialInit, []string{"+git-credential-init", "--app-id", "app_xxx", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute init err=%v", err)
}
if err := runAppsShortcut(t, AppsGitCredentialRemove, []string{"+git-credential-remove", "--app-id", "app_xxx", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute remove err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "Git credential removed") || !strings.Contains(got, "Status: removed") {
t.Fatalf("remove stdout = %s", got)
}
if err := runAppsShortcut(t, AppsGitCredentialRemove, []string{"+git-credential-remove", "--app-id", "app_xxx", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute remove missing err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "No local Git credential found") {
t.Fatalf("remove missing stdout = %s", got)
}
}
func TestAppsGitCredentialListScansAllLocalAppStorage(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
factory.Keychain = newAppsTestKeychain()
installAppsFakeGit(t, 0)
expiresA := time.Now().Add(24 * time.Hour).Unix()
expiresB := time.Now().Add(48 * time.Hour).Unix()
for _, tc := range []struct {
appID string
url string
token string
expiresAt int64
}{
{appID: "app_b", url: "https://example.com/git/u/b.git", token: "pat-b", expiresAt: expiresB},
{appID: "app_a", url: "https://example.com/git/u/a.git", token: "pat-a", expiresAt: expiresA},
} {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/" + tc.appID + "/git_info",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"gitURL": tc.url,
"token": tc.token,
"expiredTime": float64(tc.expiresAt),
},
},
})
if err := runAppsShortcut(t, AppsGitCredentialInit, []string{"+git-credential-init", "--app-id", tc.appID, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute init %s err=%v", tc.appID, err)
}
}
if err := runAppsShortcut(t, AppsGitCredentialList, []string{"+git-credential-list", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute list pretty err=%v", err)
}
got := stdout.String()
for _, want := range []string{
"App ID",
"Repository URL",
"app_a",
"https://example.com/git/u/a.git",
"app_b",
"https://example.com/git/u/b.git",
gitcred.ListStatusValid,
"Profile switches do not remove old URL-scoped Git helpers automatically.",
"Cleanup: lark-cli apps +git-credential-remove --app-id <app_id>",
} {
if !strings.Contains(got, want) {
t.Fatalf("list pretty stdout missing %q in:\n%s", want, got)
}
}
for _, hidden := range []string{"Expires At", "expires_at", "expired", time.Unix(expiresA, 0).UTC().Format(time.RFC3339), time.Unix(expiresB, 0).UTC().Format(time.RFC3339)} {
if strings.Contains(got, hidden) {
t.Fatalf("list pretty stdout should not expose %q in:\n%s", hidden, got)
}
}
if strings.Index(got, "app_a") > strings.Index(got, "app_b") {
t.Fatalf("list should be sorted by app_id, got:\n%s", got)
}
if err := runAppsShortcut(t, AppsGitCredentialList, []string{"+git-credential-list", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute list json err=%v", err)
}
var envelope struct {
Data struct {
Count int `json:"count"`
Credentials []struct {
AppID string `json:"app_id"`
RepositoryURL string `json:"repository_url"`
Status string `json:"status"`
} `json:"credentials"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout.String()), &envelope); err != nil {
t.Fatalf("decode list output: %v\n%s", err, stdout.String())
}
payload := envelope.Data
if payload.Count != 2 || len(payload.Credentials) != 2 {
t.Fatalf("payload count = %d records=%#v\n%s", payload.Count, payload.Credentials, stdout.String())
}
if payload.Credentials[0].AppID != "app_a" || payload.Credentials[0].RepositoryURL != "https://example.com/git/u/a.git" || payload.Credentials[0].Status != gitcred.ListStatusValid {
t.Fatalf("first credential = %#v", payload.Credentials[0])
}
if strings.Contains(stdout.String(), "expires_at") || strings.Contains(stdout.String(), "expires_at_iso") || strings.Contains(stdout.String(), strconv.FormatInt(expiresA, 10)) || strings.Contains(stdout.String(), strconv.FormatInt(expiresB, 10)) {
t.Fatalf("list json should not expose expiry fields or values:\n%s", stdout.String())
}
}
func TestAppsGitCredentialListEmpty(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
factory.Keychain = newAppsTestKeychain()
if err := runAppsShortcut(t, AppsGitCredentialList, []string{"+git-credential-list", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute list pretty err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "No Git credentials initialized") || !strings.Contains(got, "+git-credential-init --app-id <app_id>") {
t.Fatalf("empty list stdout = %s", got)
}
}
func TestGitCredentialAppStorageListAppIDsSkipsNonCredentialAppDirs(t *testing.T) {
newAppsExecuteFactory(t)
if err := Write("app/a", gitcred.MetadataFilename, []byte("{}")); err != nil {
t.Fatalf("Write escaped app metadata: %v", err)
}
if err := Write("app_b", gitcred.MetadataFilename, []byte("{}")); err != nil {
t.Fatalf("Write app_b metadata: %v", err)
}
root := filepath.Join(core.GetConfigDir(), "spark")
if err := os.WriteFile(filepath.Join(root, "not-an-app-dir"), []byte("x"), 0600); err != nil {
t.Fatalf("write non-dir: %v", err)
}
for _, name := range []string{"%zz", "app%2F..%2Fb"} {
if err := os.Mkdir(filepath.Join(root, name), 0700); err != nil {
t.Fatalf("mkdir %s: %v", name, err)
}
}
appIDs, err := gitCredentialAppStorage{}.ListAppIDs()
if err != nil {
t.Fatalf("ListAppIDs: %v", err)
}
got := map[string]bool{}
for _, appID := range appIDs {
got[appID] = true
}
if len(got) != 2 || !got["app/a"] || !got["app_b"] {
t.Fatalf("appIDs = %v, want app/a and app_b only", appIDs)
}
}
func TestAppsGitCredentialListReturnsScanErrors(t *testing.T) {
t.Run("storage root error", func(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
root := filepath.Join(core.GetConfigDir(), "spark")
if err := os.WriteFile(root, []byte("not a dir"), 0600); err != nil {
t.Fatalf("write storage root blocker: %v", err)
}
err := runAppsShortcut(t, AppsGitCredentialList, []string{"+git-credential-list", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "apps storage: read root") {
t.Fatalf("execute list root error = %v", err)
}
})
t.Run("record error", func(t *testing.T) {
factory, _, _ := newAppsExecuteFactory(t)
if err := Write("app_xxx", gitcred.MetadataFilename, []byte("{bad json")); err != nil {
t.Fatalf("write invalid metadata: %v", err)
}
_, err := listGitCredentialRecords(factory.Keychain, time.Now)
if err == nil || !strings.Contains(err.Error(), "invalid git.json") {
t.Fatalf("listGitCredentialRecords record error = %v", err)
}
})
}
func TestListGitCredentialRecordsSortsDuplicateDecodedAppIDs(t *testing.T) {
factory, _, _ := newAppsExecuteFactory(t)
kc := newAppsTestKeychain()
factory.Keychain = kc
now := time.Unix(1780000000, 0)
manager := newGitCredentialManager("app_x", kc, nil)
manager.Now = func() time.Time { return now }
record := gitcred.CredentialRecord{
AppID: "app_x",
GitHTTPURL: "https://example.com/git/u/app.git",
Username: "x-access-token",
PATRef: "ref",
Status: gitcred.StatusConfirmed,
ExpiresAt: now.Add(time.Hour).Unix(),
}
kc.values[record.PATRef] = "pat"
if err := manager.Store.Upsert(record); err != nil {
t.Fatalf("Upsert returned error: %v", err)
}
if err := os.Mkdir(filepath.Join(core.GetConfigDir(), "spark", "app%5Fx"), 0700); err != nil {
t.Fatalf("mkdir duplicate encoded app dir: %v", err)
}
records, err := listGitCredentialRecords(kc, func() time.Time { return now })
if err != nil {
t.Fatalf("listGitCredentialRecords returned error: %v", err)
}
if len(records) != 2 || records[0].AppID != "app_x" || records[1].AppID != "app_x" {
t.Fatalf("records = %#v, want duplicate decoded app_x records", records)
}
}
func TestGitCredentialListPayloadDoesNotExposeExpiry(t *testing.T) {
payload := gitCredentialListPayload([]gitcred.ListRecord{{
AppID: "app_xxx",
GitHTTPURL: "https://example.com/git/u/app.git",
Status: gitcred.ListStatusExpired,
ExpiresAt: 1780000000,
Expired: true,
}})
for _, key := range []string{"expires_at", "expires_at_iso", "expired"} {
if _, ok := payload[0][key]; ok {
t.Fatalf("payload exposes %s: %#v", key, payload[0])
}
}
if got := payload[0]["status"]; got != "refresh_required" {
t.Fatalf("payload status = %q, want refresh_required", got)
}
for _, value := range payload[0] {
if strings.Contains(fmt.Sprint(value), "expired") {
t.Fatalf("payload exposes expired concept: %#v", payload[0])
}
}
}
func TestAppsGitCredentialRemoveReportsGitConfigWarning(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
factory.Keychain = newAppsTestKeychain()
installAppsFakeGit(t, 7) // unsetting useHttpPath exits non-zero -> ConfigWarning
expiresAt := time.Now().Add(24 * time.Hour).Unix()
for _, appID := range []string{"app_one", "app_two"} {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/" + appID + "/git_info",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"gitURL": "https://example.com/git/u/" + appID + ".git",
"token": "pat-token",
"expiredTime": float64(expiresAt),
},
},
})
if err := runAppsShortcut(t, AppsGitCredentialInit, []string{"+git-credential-init", "--app-id", appID, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("init %s err=%v", appID, err)
}
}
// Pretty output surfaces the cleanup-warning block.
if err := runAppsShortcut(t, AppsGitCredentialRemove, []string{"+git-credential-remove", "--app-id", "app_one", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("remove pretty err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "Git config cleanup warning") || !strings.Contains(got, "Reason:") {
t.Fatalf("pretty remove missing git config warning: %s", got)
}
// JSON output exposes git_config_warning.
if err := runAppsShortcut(t, AppsGitCredentialRemove, []string{"+git-credential-remove", "--app-id", "app_two", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("remove json err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, "git_config_warning") {
t.Fatalf("json remove missing git_config_warning: %s", got)
}
}
func TestAppsGitCredentialRemoveRequiresAppID(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsGitCredentialRemove, []string{"+git-credential-remove", "--app-id", " ", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "--app-id is required") {
t.Fatalf("expected --app-id validation error, got %v", err)
}
}
func TestAppsGitCredentialRemoveReturnsStoreError(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := Write("app_xxx", gitcred.MetadataFilename, []byte("{bad json")); err != nil {
t.Fatalf("write invalid metadata: %v", err)
}
err := runAppsShortcut(t, AppsGitCredentialRemove, []string{"+git-credential-remove", "--app-id", "app_xxx", "--as", "user"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "invalid git.json") {
t.Fatalf("expected remove store error, got %v", err)
}
}
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 app Git credentials", plain)
var configErr *errs.ConfigError
if !errors.As(wrapped, &configErr) {
t.Fatalf("plain local error wrapped as %T, want *errs.ConfigError", wrapped)
}
if !errors.Is(wrapped, plain) {
t.Fatalf("wrapped error does not preserve cause")
}
typed := &errs.ConfigError{Problem: errs.Problem{
Category: errs.CategoryConfig,
Subtype: errs.SubtypeInvalidConfig,
Message: "already typed",
}}
if got := gitCredentialLocalError("action", typed); got != typed {
t.Fatalf("typed error was rewrapped: %#v", got)
}
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad app")
if got := gitCredentialLocalError("action", validationErr); got != error(validationErr) {
t.Fatalf("typed validation error was rewrapped: %#v", got)
}
if got := gitCredentialLocalError("action", nil); got != nil {
t.Fatalf("nil error must stay nil, got %#v", got)
}
}
func TestRunGitCredentialHelperActions(t *testing.T) {
t.Setenv("HOME", t.TempDir())
factory, stdout, _ := newAppsExecuteFactory(t)
kc := newAppsTestKeychain()
factory.Keychain = kc
storage := gitCredentialAppStorage{}
manager := gitcred.NewManager(gitcred.NewAppStore("app_xxx", storage), gitcred.NewSecretStore(kc), nil, testAppsIssuer{next: &gitcred.IssuedCredential{
GitHTTPURL: "https://example.com/git/u/app.git",
Username: "x-access-token",
PAT: "pat-token",
ExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
}})
manager.Now = func() time.Time { return time.Unix(1780000000, 0) }
cfg, err := factory.Config()
if err != nil {
t.Fatalf("factory Config returned error: %v", err)
}
if _, err := manager.Init(context.Background(), profileFromConfig(cfg), "app_xxx"); err != nil {
t.Fatalf("seed Init returned error: %v", err)
}
factory.IOStreams.In = bytes.NewBufferString("protocol=https\nhost=example.com\npath=/git/u/app.git\n\n")
if err := runGitCredentialHelper(context.Background(), factory, "app_xxx", "get"); err != nil {
t.Fatalf("helper get returned error: %v", err)
}
if got := stdout.String(); got != "username=x-access-token\npassword=pat-token\n\n" {
t.Fatalf("helper get stdout = %q", got)
}
stdout.Reset()
factory.IOStreams.In = bytes.NewBufferString("protocol=https\nhost=example.com\n\n")
if err := runGitCredentialHelper(context.Background(), factory, "app_xxx", "store"); err != nil {
t.Fatalf("helper store returned error: %v", err)
}
factory.IOStreams.In = bytes.NewBufferString("protocol=https\nhost=example.com\npath=/git/u/app.git\n\n")
if err := runGitCredentialHelper(context.Background(), factory, "app_xxx", "erase"); err != nil {
t.Fatalf("helper erase returned error: %v", err)
}
var stderr bytes.Buffer
factory.IOStreams.ErrOut = &stderr
factory.IOStreams.In = bytes.NewBufferString("bad-input-without-equals\n")
if err := runGitCredentialHelper(context.Background(), factory, "app_xxx", "get"); err != nil {
t.Fatalf("helper bad get returned error: %v", err)
}
if !strings.Contains(stderr.String(), "protocol and host") {
t.Fatalf("stderr = %q", stderr.String())
}
stderr.Reset()
factory.IOStreams.In = errorReader{}
if err := runGitCredentialHelper(context.Background(), factory, "app_xxx", "get"); err != nil {
t.Fatalf("helper reader error returned error: %v", err)
}
if !strings.Contains(stderr.String(), "read failed") {
t.Fatalf("stderr = %q", stderr.String())
}
stderr.Reset()
factory.Config = func() (*core.CliConfig, error) { return nil, errors.New("config failed") }
factory.IOStreams.In = bytes.NewBufferString("protocol=https\nhost=example.com\npath=/git/u/app.git\n\n")
if err := runGitCredentialHelper(context.Background(), factory, "app_xxx", "get"); err != nil {
t.Fatalf("helper config error returned error: %v", err)
}
if !strings.Contains(stderr.String(), "config failed") {
t.Fatalf("stderr = %q", stderr.String())
}
cfg = &core.CliConfig{AppID: "cli", AppSecret: "secret", Brand: core.BrandFeishu, UserOpenId: "ou_test"}
factory.Config = func() (*core.CliConfig, error) { return cfg, nil }
stderr.Reset()
if err := runGitCredentialHelper(context.Background(), factory, "app_xxx", "unknown"); err != nil {
t.Fatalf("helper unknown returned error: %v", err)
}
if !strings.Contains(stderr.String(), `unsupported git credential action "unknown"`) {
t.Fatalf("stderr = %q", stderr.String())
}
stderr.Reset()
if err := runGitCredentialHelper(context.Background(), factory, "", "get"); err != nil {
t.Fatalf("helper missing appID returned error: %v", err)
}
if !strings.Contains(stderr.String(), "missing app_id") {
t.Fatalf("stderr = %q", stderr.String())
}
if err := runGitCredentialHelper(context.Background(), nil, "app_xxx", "get"); err != nil {
t.Fatalf("helper nil factory returned error: %v", err)
}
if err := runGitCredentialHelper(context.Background(), &cmdutil.Factory{}, "app_xxx", "get"); err != nil {
t.Fatalf("helper nil streams returned error: %v", err)
}
factory.IOStreams.In = bytes.NewBufferString("protocol=https\nhost=example.com\n\n")
cmd := newGitCredentialHelperCommand(factory)
if err := cmd.Flags().Set("app-id", "app_xxx"); err != nil {
t.Fatalf("set app-id returned error: %v", err)
}
if err := cmd.RunE(cmd, []string{"store"}); err != nil {
t.Fatalf("helper command returned error: %v", err)
}
}
func TestFactoryIssuerBranches(t *testing.T) {
factory, _, reg := newAppsExecuteFactory(t)
expiresAt := time.Now().Add(24 * time.Hour).Unix()
issueStub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_xxx/git_info",
Body: map[string]interface{}{
"gitURL": "https://example.com/git/u/app.git",
"token": "pat-token",
"expiredTime": float64(expiresAt),
"BaseResp": map[string]interface{}{
"StatusCode": 0,
},
},
}
reg.Register(issueStub)
issued, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{})
if err != nil {
t.Fatalf("factory issuer returned error: %v", err)
}
if issued.PAT != "pat-token" {
t.Fatalf("PAT = %q", issued.PAT)
}
if got := issueStub.CapturedHeaders.Get(cmdutil.HeaderShortcut); got != gitCredentialHelperReportedShortcut {
t.Fatalf("%s = %q, want %q", cmdutil.HeaderShortcut, got, gitCredentialHelperReportedShortcut)
}
if got := issueStub.CapturedHeaders.Get(cmdutil.HeaderExecutionId); got == "" {
t.Fatalf("%s header missing", cmdutil.HeaderExecutionId)
}
factory.Config = func() (*core.CliConfig, error) { return nil, errors.New("config failed") }
if _, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{}); err == nil {
t.Fatal("factory issuer config error returned nil")
}
factory.Config = func() (*core.CliConfig, error) {
return &core.CliConfig{AppID: "cli", AppSecret: "secret", Brand: core.BrandFeishu}, nil
}
if _, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{}); err == nil {
t.Fatal("factory issuer without login returned nil")
}
factory.Config = func() (*core.CliConfig, error) {
return &core.CliConfig{AppID: "cli", AppSecret: "secret", Brand: core.BrandFeishu, UserOpenId: "ou_test"}, nil
}
factory.LarkClient = func() (*lark.Client, error) { return nil, errors.New("sdk failed") }
if _, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{}); err == nil {
t.Fatal("factory issuer SDK error returned nil")
}
factory, _, reg = newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_xxx/git_info",
RawBody: []byte("{bad json"),
})
if _, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{}); err == nil {
t.Fatal("factory issuer parse error returned nil")
}
factory, _, _ = newAppsExecuteFactory(t)
if _, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{}); err == nil {
t.Fatal("factory issuer request error returned nil")
}
}
func TestContextWithGitCredentialHelperShortcutPreservesExistingShortcut(t *testing.T) {
ctx := cmdutil.ContextWithShortcut(context.Background(), "apps:+git-credential-init", "exec-existing")
got := contextWithGitCredentialHelperShortcut(ctx)
name, ok := cmdutil.ShortcutNameFromContext(got)
if !ok || name != "apps:+git-credential-init" {
t.Fatalf("shortcut = %q ok=%v, want existing shortcut", name, ok)
}
executionID, ok := cmdutil.ExecutionIdFromContext(got)
if !ok || executionID != "exec-existing" {
t.Fatalf("execution id = %q ok=%v, want existing execution id", executionID, ok)
}
}
func TestGitCredentialHelpersAndParsers(t *testing.T) {
if issuePath(" app/with space ") != "/open-apis/spark/v1/apps/app%2Fwith%20space/git_info" {
t.Fatalf("issuePath escaped incorrectly: %s", issuePath(" app/with space "))
}
if got := gitCredentialIssueParams(" app_xxx ")["app_id"]; got != "app_xxx" {
t.Fatalf("param app_id = %q", got)
}
if initStatus(nil) != "initialized" || initStatus(&gitcred.InitResult{Refreshed: true}) != "refreshed" {
t.Fatalf("initStatus mismatch")
}
if got := profileFromConfig(nil); got != (gitcred.ProfileContext{}) {
t.Fatalf("profileFromConfig(nil) = %#v", got)
}
for _, data := range []map[string]interface{}{
{"credential": map[string]interface{}{"gitURL": "https://example.com/repo.git", "token": "pat"}},
{"git_credential": map[string]interface{}{"git_url": "https://example.com/repo.git", "password": "pat"}},
{"gitInfo": map[string]interface{}{"repository_url": "https://example.com/repo.git", "pat": "pat", "expired_time": "1780050600"}},
{"git_info": map[string]interface{}{"GitUrl": "https://example.com/repo.git", "Token": "pat", "ExpiredTime": "1780050600"}},
} {
if _, err := issuedFromData("app_xxx", data); err != nil {
t.Fatalf("issuedFromData nested returned error: %v", err)
}
}
if _, err := issuedFromData("app_xxx", map[string]interface{}{"token": "pat"}); err == nil {
t.Fatal("issuedFromData missing gitURL returned nil error")
}
if _, err := issuedFromData("app_xxx", map[string]interface{}{"gitURL": "https://example.com/repo.git"}); err == nil {
t.Fatal("issuedFromData missing token returned nil error")
}
if got := firstInt64(map[string]interface{}{"n": int(7)}, "n"); got != 7 {
t.Fatalf("firstInt64 int = %d", got)
}
if got := firstInt64(map[string]interface{}{"n": int64(9)}, "n"); got != 9 {
t.Fatalf("firstInt64 int64 = %d", got)
}
if got := firstInt64(map[string]interface{}{"n": "bad"}, "n"); got != 0 {
t.Fatalf("firstInt64 bad string = %d", got)
}
if logIDString(nil) != "" {
t.Fatal("logIDString(nil) should be empty")
}
}
func TestParseIssueCredentialDataErrors(t *testing.T) {
if _, err := parseIssueCredentialData(nil, errors.New("transport failed"), errclass.ClassifyContext{}); err == nil {
t.Fatal("parseIssueCredentialData transport error returned nil")
}
if _, err := parseIssueCredentialData(nil, nil, errclass.ClassifyContext{}); err == nil {
t.Fatal("parseIssueCredentialData nil response returned nil")
}
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte("{bad json")}, nil, errclass.ClassifyContext{}); err == nil {
t.Fatal("parseIssueCredentialData bad json returned nil")
}
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusBadRequest, RawBody: []byte(`{"msg":"bad request"}`), Header: header}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "bad request") {
t.Fatalf("HTTP error = %v", err)
}
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusInternalServerError, RawBody: []byte(`{}`), Header: header}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "HTTP 500") {
t.Fatalf("HTTP fallback error = %v", err)
}
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":999,"msg":"failed"}`), Header: header}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "failed") {
t.Fatalf("code error = %v", err)
}
data, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":0}`), Header: header}, nil, errclass.ClassifyContext{})
if err != nil {
t.Fatalf("code zero without data returned error: %v", err)
}
if data["log_id"] != "log_x" {
t.Fatalf("log_id = %v", data["log_id"])
}
data, err = parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`null`), Header: header}, nil, errclass.ClassifyContext{})
if err != nil {
t.Fatalf("null response with log id returned error: %v", err)
}
if data["log_id"] != "log_x" {
t.Fatalf("null response log_id = %v", data["log_id"])
}
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"BaseResp":{"StatusCode":7,"StatusMessage":"denied"}}`), Header: header}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "denied") {
t.Fatalf("BaseResp error = %v", err)
}
if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"baseResp":{"statusCode":7}}`)}, nil, errclass.ClassifyContext{}); err == nil || !strings.Contains(err.Error(), "non-zero BaseResp") {
t.Fatalf("BaseResp fallback error = %v", err)
}
}
// TestParseIssueCredentialData503IsRetryableWithHint verifies that a 5xx Git
// credential issuance failure is flagged retryable and carries the developer-access hint.
func TestParseIssueCredentialData503IsRetryableWithHint(t *testing.T) {
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
_, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusServiceUnavailable, RawBody: []byte(`{"msg":"upstream busy"}`), Header: header}, nil, errclass.ClassifyContext{})
if err == nil {
t.Fatal("expected 503 error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
}
if !p.Retryable {
t.Fatalf("503 should be retryable, got Retryable=false")
}
if !strings.Contains(p.Hint, "developer access") {
t.Fatalf("hint missing 'developer access': %q", p.Hint)
}
}
// TestParseIssueCredentialDataBusinessCodeHasHintNotRetryable verifies that a
// non-zero business code (no HTTP status) carries the hint but is not retryable.
func TestParseIssueCredentialDataBusinessCodeHasHintNotRetryable(t *testing.T) {
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
_, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":999,"msg":"no developer access"}`), Header: header}, nil, errclass.ClassifyContext{})
if err == nil {
t.Fatal("expected business-code error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
}
if p.Retryable {
t.Fatalf("business code != 0 must not be retryable, got Retryable=true")
}
if !strings.Contains(p.Hint, "developer access") {
t.Fatalf("hint missing 'developer access': %q", p.Hint)
}
}
// 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
wantType errs.Category
wantSubtype errs.Subtype
wantCode int
}{
{
name: "http error path",
resp: &larkcore.ApiResp{
StatusCode: http.StatusForbidden,
RawBody: []byte(`{"msg":"` + serverMsg + `"}`),
Header: header,
},
wantType: errs.CategoryAPI,
wantSubtype: errs.SubtypeUnknown,
wantCode: http.StatusForbidden,
},
{
name: "business code path",
resp: &larkcore.ApiResp{
StatusCode: http.StatusOK,
RawBody: []byte(`{"code":999,"msg":"` + serverMsg + `"}`),
Header: header,
},
wantType: errs.CategoryAPI,
wantSubtype: errs.SubtypeUnknown,
wantCode: 999,
},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := parseIssueCredentialData(tc.resp, nil, errclass.ClassifyContext{})
if err == nil {
t.Fatal("expected an error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs.Problem, got %T: %v", err, err)
}
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)
}
if p.Hint != gitCredentialIssueHint {
t.Fatalf("Hint = %q, want the static gitCredentialIssueHint", p.Hint)
}
for field, val := range map[string]string{"Message": p.Message, "Hint": p.Hint} {
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) {
return 0, errors.New("read failed")
}
type appsTestKeychain struct {
values map[string]string
}
func newAppsTestKeychain() *appsTestKeychain {
return &appsTestKeychain{values: map[string]string{}}
}
func (k *appsTestKeychain) Get(service, account string) (string, error) {
return k.values[account], nil
}
func (k *appsTestKeychain) Set(service, account, value string) error {
k.values[account] = value
return nil
}
func (k *appsTestKeychain) Remove(service, account string) error {
delete(k.values, account)
return nil
}
type testAppsIssuer struct {
next *gitcred.IssuedCredential
}
func (i testAppsIssuer) Issue(ctx context.Context, appID string, profile gitcred.ProfileContext) (*gitcred.IssuedCredential, error) {
out := *i.next
out.AppID = appID
return &out, nil
}
func installAppsFakeGit(t *testing.T, failUseHTTPPathExit int) {
t.Helper()
dir := t.TempDir()
gitPath := filepath.Join(dir, "git")
script := `#!/bin/sh
case "$*" in
*"--get"*) exit 1 ;;
esac
exit 0
`
if failUseHTTPPathExit != 0 {
script = `#!/bin/sh
case "$*" in
*"--get"*) exit 1 ;;
esac
case "$*" in
*useHttpPath*) exit 7 ;;
esac
exit 0
`
}
if err := os.WriteFile(gitPath, []byte(script), 0700); err != nil {
t.Fatalf("write fake git: %v", err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
}
// TestParseIssueCredentialData_SharedClassifierCoverage pins the canonical
// classifications the shared classifier provides on the credential-issue
// path: a generic missing-scope code becomes a typed permission error with
// the missing scopes extracted, and an HTTP 503 becomes a retryable
// network/server_error — neither collapses to api/unknown.
func TestParseIssueCredentialData_SharedClassifierCoverage(t *testing.T) {
header := http.Header{"X-Tt-Logid": []string{"log_x"}}
t.Run("missing scope classifies as authorization with scopes", func(t *testing.T) {
body := `{"code":99991676,"msg":"token scope insufficient","error":{"permission_violations":[{"subject":"spark:app:read"}]}}`
_, err := parseIssueCredentialData(&larkcore.ApiResp{
StatusCode: http.StatusOK, RawBody: []byte(body), Header: header,
}, nil, errclass.ClassifyContext{})
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("want *errs.PermissionError, got %T: %v", err, err)
}
if permErr.Subtype != errs.SubtypeTokenScopeInsufficient {
t.Fatalf("subtype = %q, want %q", permErr.Subtype, errs.SubtypeTokenScopeInsufficient)
}
if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != "spark:app:read" {
t.Fatalf("MissingScopes = %v, want [spark:app:read]", permErr.MissingScopes)
}
})
t.Run("http 503 classifies as retryable network server_error", func(t *testing.T) {
_, err := parseIssueCredentialData(&larkcore.ApiResp{
StatusCode: http.StatusServiceUnavailable, RawBody: []byte(`{"msg":"upstream busy"}`), Header: header,
}, nil, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("want typed problem, got %T: %v", err, err)
}
if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkServer {
t.Fatalf("classification = %s/%s, want network/server_error", p.Category, p.Subtype)
}
if !p.Retryable {
t.Fatalf("retryable = false, want true for 5xx")
}
})
}