mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
1265 lines
48 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|