Merge pull request '修复认证凭据 fallback 配置目录不一致' (#133) from Mengz/gitlink-cli:mengz/auth-config-dir-fallback into master

This commit is contained in:
wbtiger 2026-06-08 02:23:06 +08:00
commit 03a9d7a942
7 changed files with 80 additions and 49 deletions

View File

@ -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?

View File

@ -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 参考?

View File

@ -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

View File

@ -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` 解析差异污染真实用户凭据目录。

View File

@ -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
}

View File

@ -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)

View File

@ -34,7 +34,7 @@ gitlink-cli auth logout
- GitLink Token 有效期 **7 天**,过期需重新登录
- Token 存储在 OS KeychainmacOS Keychain / Linux Secret Service / Windows Credential Manager
- Fallback 存储:`~/.config/gitlink-cli/credentials`
- Fallback 存储:`$GITLINK_CONFIG_DIR/credentials`(未设置时为 `~/.config/gitlink-cli/credentials`
### 认证错误处理