Merge pull request '修复认证凭据 fallback 配置目录不一致' (#133) from Mengz/gitlink-cli:mengz/auth-config-dir-fallback into master
This commit is contained in:
commit
03a9d7a942
|
|
@ -726,7 +726,7 @@ You can also download the binary manually from the release page or build from so
|
|||
|
||||
### Q: Where are credentials stored on Windows?
|
||||
|
||||
gitlink-cli uses Windows Credential Manager for secure token storage. If Credential Manager is unavailable, it automatically falls back to file storage (`~/.config/gitlink-cli/credentials`).
|
||||
gitlink-cli uses Windows Credential Manager for secure token storage. If Credential Manager is unavailable, it automatically falls back to file storage under the gitlink-cli config directory (`$GITLINK_CONFIG_DIR/credentials` when set, otherwise `~/.config/gitlink-cli/credentials`).
|
||||
|
||||
### Q: Where can I find the full API reference?
|
||||
|
||||
|
|
|
|||
|
|
@ -601,7 +601,7 @@ npm install -g @gitlink-ai/cli
|
|||
|
||||
### Q: Windows 上凭证存储在哪里?
|
||||
|
||||
gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Credential Manager 不可用,会自动降级到文件存储(`~/.config/gitlink-cli/credentials`)。
|
||||
gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Credential Manager 不可用,会自动降级到配置目录下的文件存储;设置了 `GITLINK_CONFIG_DIR` 时路径为 `$GITLINK_CONFIG_DIR/credentials`,否则为 `~/.config/gitlink-cli/credentials`。
|
||||
|
||||
### Q: 如何查看完整的 API 参考?
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package auth
|
|||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
|
@ -11,6 +12,13 @@ import (
|
|||
internalAuth "github.com/gitlink-org/gitlink-cli/internal/auth"
|
||||
)
|
||||
|
||||
func tempConfigDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestEnvTokenVar(t *testing.T) {
|
||||
if envTokenVar != "GITLINK_TOKEN" {
|
||||
t.Fatalf("envTokenVar = %q, want GITLINK_TOKEN", envTokenVar)
|
||||
|
|
@ -61,7 +69,7 @@ func TestLoginTokenFlag(t *testing.T) {
|
|||
|
||||
func TestStatusCmdNotLoggedIn(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
tempConfigDir(t)
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
_ = internalAuth.DeleteToken()
|
||||
|
||||
|
|
@ -76,7 +84,7 @@ func TestStatusCmdNotLoggedIn(t *testing.T) {
|
|||
|
||||
func TestStatusCmdEnvToken(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
tempConfigDir(t)
|
||||
t.Setenv("GITLINK_TOKEN", "env-token-123")
|
||||
_ = internalAuth.DeleteToken()
|
||||
|
||||
|
|
@ -86,12 +94,11 @@ func TestStatusCmdEnvToken(t *testing.T) {
|
|||
|
||||
func TestStatusCmdStoredToken(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
dir := tempConfigDir(t)
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
|
||||
os.MkdirAll(dir+"/.config/gitlink-cli", 0700)
|
||||
os.WriteFile(dir+"/.config/gitlink-cli/credentials", []byte("cookie:test=abc"), 0600)
|
||||
os.MkdirAll(dir, 0700)
|
||||
os.WriteFile(filepath.Join(dir, "credentials"), []byte("cookie:test=abc"), 0600)
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "status")
|
||||
cmd.RunE(cmd, nil)
|
||||
|
|
@ -99,12 +106,11 @@ func TestStatusCmdStoredToken(t *testing.T) {
|
|||
|
||||
func TestStatusCmdEnvAndStoredToken(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
dir := tempConfigDir(t)
|
||||
t.Setenv("GITLINK_TOKEN", "env-token")
|
||||
|
||||
os.MkdirAll(dir+"/.config/gitlink-cli", 0700)
|
||||
os.WriteFile(dir+"/.config/gitlink-cli/credentials", []byte("stored-token"), 0600)
|
||||
os.MkdirAll(dir, 0700)
|
||||
os.WriteFile(filepath.Join(dir, "credentials"), []byte("stored-token"), 0600)
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "status")
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
|
|
@ -114,9 +120,9 @@ func TestStatusCmdEnvAndStoredToken(t *testing.T) {
|
|||
|
||||
func TestStatusCmdStoredTokenButLoadFails(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
tempConfigDir(t)
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
// Don't create credentials file — LoadToken returns empty
|
||||
// Do not create credentials; LoadToken should return empty.
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "status")
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
|
|
@ -124,29 +130,26 @@ func TestStatusCmdStoredTokenButLoadFails(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLogoutCmdError(t *testing.T) {
|
||||
func TestLogoutCmdNoStoredToken(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
tempConfigDir(t)
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
// Don't create credentials dir — DeleteToken will fail
|
||||
// Do not create credentials; logout should be idempotent.
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "logout")
|
||||
err := cmd.RunE(cmd, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when DeleteToken fails")
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("logout should succeed without stored token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutCmd(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
dir := tempConfigDir(t)
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
|
||||
// Store a token first so DeleteToken has something to delete
|
||||
credDir := home + "/.config/gitlink-cli"
|
||||
os.MkdirAll(credDir, 0700)
|
||||
os.WriteFile(credDir+"/credentials", []byte("some-token"), 0600)
|
||||
os.MkdirAll(dir, 0700)
|
||||
os.WriteFile(filepath.Join(dir, "credentials"), []byte("some-token"), 0600)
|
||||
|
||||
cmd := findSub(NewAuthCmd(), "logout")
|
||||
if cmd == nil {
|
||||
|
|
@ -159,8 +162,7 @@ func TestLogoutCmd(t *testing.T) {
|
|||
|
||||
func TestLoginWithToken(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
dir := tempConfigDir(t)
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
|
||||
// Mock stdin
|
||||
|
|
@ -185,7 +187,7 @@ func TestLoginWithToken(t *testing.T) {
|
|||
}
|
||||
|
||||
// Verify token was saved to file
|
||||
data, err := os.ReadFile(home + "/.config/gitlink-cli/credentials")
|
||||
data, err := os.ReadFile(filepath.Join(dir, "credentials"))
|
||||
if err != nil {
|
||||
t.Fatalf("read credentials: %v", err)
|
||||
}
|
||||
|
|
@ -196,7 +198,7 @@ func TestLoginWithToken(t *testing.T) {
|
|||
|
||||
func TestLoginWithTokenEmpty(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
tempConfigDir(t)
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
|
||||
oldStdin := os.Stdin
|
||||
|
|
@ -219,7 +221,7 @@ func TestLoginWithTokenEmpty(t *testing.T) {
|
|||
|
||||
func TestLoginWithPasswordNoTerminal(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
tempConfigDir(t)
|
||||
t.Setenv("GITLINK_TOKEN", "")
|
||||
|
||||
// term.ReadPassword will fail because test has no terminal
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
# 认证凭据 fallback 配置目录一致性修复
|
||||
|
||||
`gitlink-cli` 的主配置文件已经支持通过 `GITLINK_CONFIG_DIR` 指定配置目录,但认证模块在系统 Keychain 不可用时仍然把 fallback 凭据写到用户 home 下的 `~/.config/gitlink-cli/credentials`。这会让 CI、Windows 测试、Agent 沙箱和多账号隔离场景出现配置目录与凭据目录不一致的问题,也会导致测试中设置临时 HOME 后仍读写真实用户目录。
|
||||
|
||||
本次修复让文件凭据路径统一复用 `internal/config.ConfigDir()`:设置 `GITLINK_CONFIG_DIR` 时,fallback 凭据保存到 `$GITLINK_CONFIG_DIR/credentials`;未设置时仍保持原有默认路径。`auth logout` 在 fallback 文件不存在时也改为幂等成功,避免用户已经没有本地凭据时退出登录反而报错。
|
||||
|
||||
测试同步改为使用 `GITLINK_CONFIG_DIR` 隔离凭据目录,覆盖默认配置目录、文件创建、保存/读取/删除、Keychain 不可用 fallback、无凭据登出等场景。该修复提升了跨平台稳定性,也让本地全量测试不再因为 Windows `HOME`/`USERPROFILE` 解析差异污染真实用户凭据目录。
|
||||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
|
|
@ -41,8 +42,7 @@ func DeleteToken() error {
|
|||
// File-based fallback
|
||||
|
||||
func credentialPath() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "gitlink-cli", "credentials")
|
||||
return filepath.Join(config.ConfigDir(), "credentials")
|
||||
}
|
||||
|
||||
func storeTokenFile(token string) error {
|
||||
|
|
@ -62,5 +62,9 @@ func loadTokenFile() (string, error) {
|
|||
}
|
||||
|
||||
func deleteTokenFile() error {
|
||||
return os.Remove(credentialPath())
|
||||
err := os.Remove(credentialPath())
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,11 +13,19 @@ func tempHome(t *testing.T) string {
|
|||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
t.Setenv("USERPROFILE", dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
func tempConfigDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITLINK_CONFIG_DIR", dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestStoreLoadDeleteTokenFile(t *testing.T) {
|
||||
tempHome(t)
|
||||
tempConfigDir(t)
|
||||
|
||||
// First, delete any existing token
|
||||
_ = deleteTokenFile()
|
||||
|
|
@ -55,20 +63,31 @@ func TestStoreLoadDeleteTokenFile(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCredentialPath(t *testing.T) {
|
||||
tempHome(t)
|
||||
dir := tempConfigDir(t)
|
||||
got := credentialPath()
|
||||
expected := filepath.Join(os.Getenv("HOME"), ".config", "gitlink-cli", "credentials")
|
||||
expected := filepath.Join(dir, "credentials")
|
||||
if got != expected {
|
||||
t.Fatalf("credentialPath = %q, want %q", got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialPathDefaultsToConfigDir(t *testing.T) {
|
||||
home := tempHome(t)
|
||||
t.Setenv("GITLINK_CONFIG_DIR", "")
|
||||
|
||||
got := credentialPath()
|
||||
expected := filepath.Join(home, ".config", "gitlink-cli", "credentials")
|
||||
if got != expected {
|
||||
t.Fatalf("credentialPath = %q, want %q", got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreTokenFileCreatesDir(t *testing.T) {
|
||||
home := tempHome(t)
|
||||
dir := tempConfigDir(t)
|
||||
_ = deleteTokenFile()
|
||||
|
||||
// Config dir shouldn't exist yet
|
||||
credDir := filepath.Join(home, ".config", "gitlink-cli")
|
||||
credDir := dir
|
||||
os.RemoveAll(credDir)
|
||||
|
||||
if err := storeTokenFile("new-token"); err != nil {
|
||||
|
|
@ -86,17 +105,16 @@ func TestStoreTokenFileCreatesDir(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDeleteTokenFileNonExistent(t *testing.T) {
|
||||
tempHome(t)
|
||||
tempConfigDir(t)
|
||||
_ = deleteTokenFile()
|
||||
// Deleting non-existent file should return an error from os.Remove
|
||||
err := deleteTokenFile()
|
||||
if err == nil {
|
||||
t.Fatal("expected error deleting non-existent file")
|
||||
// Logout-style cleanup should be idempotent when fallback credentials do not exist.
|
||||
if err := deleteTokenFile(); err != nil {
|
||||
t.Fatalf("deleteTokenFile error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreLoadTokenFileEmpty(t *testing.T) {
|
||||
tempHome(t)
|
||||
tempConfigDir(t)
|
||||
_ = deleteTokenFile()
|
||||
|
||||
if err := storeTokenFile(""); err != nil {
|
||||
|
|
@ -114,14 +132,14 @@ func TestStoreLoadTokenFileEmpty(t *testing.T) {
|
|||
|
||||
func TestStoreTokenFallback(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
home := tempHome(t)
|
||||
dir := tempConfigDir(t)
|
||||
_ = deleteTokenFile()
|
||||
|
||||
if err := StoreToken("keychain-fallback-token"); err != nil {
|
||||
t.Fatalf("StoreToken error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(home, ".config", "gitlink-cli", "credentials"))
|
||||
data, err := os.ReadFile(filepath.Join(dir, "credentials"))
|
||||
if err != nil {
|
||||
t.Fatalf("read error: %v", err)
|
||||
}
|
||||
|
|
@ -132,10 +150,10 @@ func TestStoreTokenFallback(t *testing.T) {
|
|||
|
||||
func TestDeleteTokenFallback(t *testing.T) {
|
||||
keyring.MockInitWithError(errors.New("keychain unavailable"))
|
||||
home := tempHome(t)
|
||||
dir := tempConfigDir(t)
|
||||
_ = deleteTokenFile()
|
||||
|
||||
p := filepath.Join(home, ".config", "gitlink-cli", "credentials")
|
||||
p := filepath.Join(dir, "credentials")
|
||||
os.MkdirAll(filepath.Dir(p), 0700)
|
||||
os.WriteFile(p, []byte("delete-me"), 0600)
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ gitlink-cli auth logout
|
|||
|
||||
- GitLink Token 有效期 **7 天**,过期需重新登录
|
||||
- Token 存储在 OS Keychain(macOS Keychain / Linux Secret Service / Windows Credential Manager)
|
||||
- Fallback 存储:`~/.config/gitlink-cli/credentials`
|
||||
- Fallback 存储:`$GITLINK_CONFIG_DIR/credentials`(未设置时为 `~/.config/gitlink-cli/credentials`)
|
||||
|
||||
### 认证错误处理
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue