test(i18n): close auth config validation gaps

This commit is contained in:
whzy 2026-05-28 12:14:42 +08:00
parent 9f0c63d2fb
commit ceede4ea59
8 changed files with 159 additions and 10 deletions

View File

@ -23,5 +23,8 @@ jobs:
- name: Validate i18n messages
run: go run ./internal/i18n/cmd/check
- name: Scan i18n key references
run: go run ./internal/i18n/cmd/check --scan-code
- name: Run Go tests
run: go test ./...

View File

@ -7,7 +7,6 @@ import (
"io"
"os"
"strings"
"syscall"
"github.com/spf13/cobra"
"golang.org/x/term"
@ -18,6 +17,11 @@ import (
const envTokenVar = "GITLINK_TOKEN"
var (
storeToken = internalAuth.StoreToken
loadToken = internalAuth.LoadToken
)
func NewAuthCmd(tr *i18n.Translator) *cobra.Command {
if tr == nil {
tr = i18n.Default()
@ -42,22 +46,21 @@ func newLoginCmd(tr *i18n.Translator) *cobra.Command {
if tokenMode {
return loginWithToken(cmd.InOrStdin(), cmd.OutOrStdout(), tr)
}
return loginWithPassword(cmd.OutOrStdout(), tr)
return loginWithPassword(cmd.InOrStdin(), cmd.OutOrStdout(), tr)
},
}
cmd.Flags().BoolVar(&tokenMode, "token", false, tr.T("flag.auth.token"))
return cmd
}
func loginWithPassword(out io.Writer, tr *i18n.Translator) error {
reader := bufio.NewReader(os.Stdin)
func loginWithPassword(in io.Reader, out io.Writer, tr *i18n.Translator) error {
reader := bufio.NewReader(in)
fmt.Fprint(out, tr.T("prompt.auth.username"))
username, _ := reader.ReadString('\n')
username = strings.TrimSpace(username)
fmt.Fprint(out, tr.T("prompt.auth.password"))
passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
passwordBytes, err := readPassword(in, reader)
if err != nil {
return fmt.Errorf("failed to read password: %w", err)
}
@ -73,6 +76,20 @@ func loginWithPassword(out io.Writer, tr *i18n.Translator) error {
return nil
}
func readPassword(in io.Reader, reader *bufio.Reader) ([]byte, error) {
if file, ok := in.(*os.File); ok {
fd := int(file.Fd())
if term.IsTerminal(fd) {
return term.ReadPassword(fd)
}
}
password, err := reader.ReadString('\n')
if err != nil && err != io.EOF {
return nil, err
}
return []byte(strings.TrimRight(password, "\r\n")), nil
}
func loginWithToken(in io.Reader, out io.Writer, tr *i18n.Translator) error {
reader := bufio.NewReader(in)
fmt.Fprint(out, tr.T("prompt.auth.token"))
@ -83,7 +100,7 @@ func loginWithToken(in io.Reader, out io.Writer, tr *i18n.Translator) error {
return errors.New(tr.T("error.auth.token_empty"))
}
if err := internalAuth.StoreToken(token); err != nil {
if err := storeToken(token); err != nil {
return errors.New(tr.Tf("error.auth.store_token_failed", i18n.Args{"message": err.Error()}))
}
@ -97,7 +114,7 @@ func newLogoutCmd(tr *i18n.Translator) *cobra.Command {
Short: tr.T("cmd.auth.logout.short"),
RunE: func(cmd *cobra.Command, args []string) error {
if err := internalAuth.DeleteToken(); err != nil {
return fmt.Errorf("failed to delete token: %w", err)
return errors.New(tr.Tf("error.auth.delete_token_failed", i18n.Args{"message": err.Error()}))
}
fmt.Fprintln(cmd.OutOrStdout(), tr.T("success.auth.logged_out"))
return nil
@ -115,7 +132,7 @@ func newStatusCmd(tr *i18n.Translator) *cobra.Command {
fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.auth.logged_in_via_env", i18n.Args{"env": envTokenVar}))
}
token, err := internalAuth.LoadToken()
token, err := loadToken()
if err != nil || token == "" {
if os.Getenv(envTokenVar) == "" {
fmt.Fprintln(cmd.OutOrStdout(), tr.T("warning.auth.not_logged_in"))

83
cmd/auth/auth_test.go Normal file
View File

@ -0,0 +1,83 @@
package auth
import (
"bytes"
"os"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
)
func TestAuthTokenLoginUsesLocalizedPrompt(t *testing.T) {
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
if err != nil {
t.Fatal(err)
}
oldStoreToken := storeToken
var storedToken string
storeToken = func(token string) error {
storedToken = token
return nil
}
t.Cleanup(func() {
storeToken = oldStoreToken
})
cmd := NewAuthCmd(tr)
cmd.SetArgs([]string{"login", "--token"})
cmd.SetIn(strings.NewReader("test-token\n"))
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if storedToken != "test-token" {
t.Fatalf("stored token = %q, want test-token", storedToken)
}
output := out.String()
if !strings.Contains(output, "粘贴你的访问 Token") {
t.Fatalf("expected localized token prompt, got:\n%s", output)
}
if !strings.Contains(output, "Token 已保存") {
t.Fatalf("expected localized token saved message, got:\n%s", output)
}
}
func TestAuthStatusUsesLocalizedNotLoggedIn(t *testing.T) {
t.Setenv("GITLINK_CONFIG_DIR", t.TempDir())
t.Setenv(envTokenVar, "")
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
if err != nil {
t.Fatal(err)
}
oldLoadToken := loadToken
loadToken = func() (string, error) {
return "", os.ErrNotExist
}
t.Cleanup(func() {
loadToken = oldLoadToken
})
cmd := NewAuthCmd(tr)
cmd.SetArgs([]string{"status"})
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
output := out.String()
if !strings.Contains(output, "未登录") {
t.Fatalf("expected localized not logged in message, got:\n%s", output)
}
if !strings.Contains(output, "gitlink-cli auth login") {
t.Fatalf("expected login hint, got:\n%s", output)
}
}

View File

@ -49,7 +49,10 @@ func newSetCmd(tr *i18n.Translator) *cobra.Command {
if err := internalConfig.Set(args[0], args[1]); err != nil {
return err
}
fmt.Printf("✓ %s = %s\n", args[0], args[1])
fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.config.set", i18n.Args{
"key": args[0],
"value": args[1],
}))
return nil
},
}

32
cmd/config/config_test.go Normal file
View File

@ -0,0 +1,32 @@
package config
import (
"bytes"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
)
func TestConfigSetUsesCommandOutputAndI18n(t *testing.T) {
t.Setenv("GITLINK_CONFIG_DIR", t.TempDir())
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
if err != nil {
t.Fatal(err)
}
cmd := NewConfigCmd(tr)
cmd.SetArgs([]string{"set", "lang", "zh-CN"})
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
output := out.String()
if !strings.Contains(output, "已设置 lang = zh-CN") {
t.Fatalf("expected localized config set output, got:\n%s", output)
}
}

View File

@ -55,6 +55,13 @@ Do not invent numbered keys such as `msg001`. Prefer names that describe ownersh
Use `go run ./internal/i18n/cmd/check --fix` to format locale JSON.
Use `go run ./internal/i18n/cmd/check --scan-code` before opening a PR. The scanner is intentionally lightweight:
- Name command-construction translators `tr` when calling `tr.T(...)` or `tr.Tf(...)`.
- Use `ctx.Tr.T(...)` or `ctx.Tr.Tf(...)` in runtime shortcut code.
- Avoid calling translator methods through other variable names such as `translator.T(...)`; the current scan may not detect them.
- Do not add new `i18n.Default().T(...)` or `i18n.Default().Tf(...)` usages.
## Runtime Access
Command construction receives `*i18n.Translator` from `NewRootCmd`. Shortcut execution receives the same translator through `RuntimeContext.Tr`.

View File

@ -80,6 +80,7 @@
"cmd.webhook.test.short": "Trigger a test delivery for a webhook",
"cmd.webhook.update.short": "Update a repository webhook while preserving unspecified fields when available",
"cmd.webhook.view.short": "View webhook details",
"error.auth.delete_token_failed": "failed to delete token: {message}",
"error.auth.login_failed": "login failed: {message}",
"error.auth.store_token_failed": "failed to store token: {message}",
"error.auth.token_empty": "token cannot be empty",
@ -178,6 +179,7 @@
"success.auth.logged_out": "✓ Logged out",
"success.auth.token_saved": "✓ Token saved",
"success.config.initialized": "✓ Config initialized at {path}",
"success.config.set": "✓ {key} = {value}",
"warning.auth.not_logged_in": "✗ Not logged in",
"warning.auth.token_unverified": "✓ Token stored (but cannot verify: {message})",
"warning.auth.user_unavailable": "✓ Token stored (user info unavailable)"

View File

@ -80,6 +80,7 @@
"cmd.webhook.test.short": "触发 Webhook 测试投递",
"cmd.webhook.update.short": "更新仓库 Webhook并在可用时保留未指定字段",
"cmd.webhook.view.short": "查看 Webhook 详情",
"error.auth.delete_token_failed": "删除 Token 失败:{message}",
"error.auth.login_failed": "登录失败:{message}",
"error.auth.store_token_failed": "保存 Token 失败:{message}",
"error.auth.token_empty": "Token 不能为空",
@ -178,6 +179,7 @@
"success.auth.logged_out": "✓ 已退出登录",
"success.auth.token_saved": "✓ Token 已保存",
"success.config.initialized": "✓ 配置已初始化:{path}",
"success.config.set": "✓ 已设置 {key} = {value}",
"warning.auth.not_logged_in": "✗ 未登录",
"warning.auth.token_unverified": "✓ Token 已保存(但无法验证:{message}",
"warning.auth.user_unavailable": "✓ Token 已保存(用户信息不可用)"