本次重构实现了标准化的本地配置系统,替换原有的硬编码环境变量读取逻辑: 1. 新增跨平台的原子化配置文件读写,支持Unix和Windows系统 2. 新增init命令用于安全初始化和更新本地凭据 3. 替换原有错误提示文案为更友好的中文提示 4. 更新文档说明新的配置流程和安全规范 5. 新增完整的配置相关测试用例 6. 添加必要的依赖包支持
344 lines
9.9 KiB
Go
344 lines
9.9 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestLoadUsesProductionDefaultAndRepositoryEnvFile(t *testing.T) {
|
|
envFile := filepath.Join(t.TempDir(), EnvFileName)
|
|
writeTestEnvFile(t, envFile, TokenEnv+"=file-token\n", 0o600)
|
|
|
|
value, err := load(envFile, emptyEnvironment)
|
|
if err != nil {
|
|
t.Fatalf("load config: %v", err)
|
|
}
|
|
if value.APIBaseURL != DefaultAPIBaseURL || value.Token != "file-token" {
|
|
t.Fatalf("config = %#v", value)
|
|
}
|
|
}
|
|
|
|
func TestInitializeWritesAndReplacesRepositoryRootEnvFile(t *testing.T) {
|
|
repositoryRoot := makeTestRepository(t)
|
|
t.Chdir(repositoryRoot)
|
|
wantPath := filepath.Join(repositoryRoot, EnvFileName)
|
|
|
|
path, err := Initialize("first-token")
|
|
if err != nil {
|
|
t.Fatalf("initialize first token: %v", err)
|
|
}
|
|
if path != wantPath {
|
|
t.Fatalf("env file = %q, want %q", path, wantPath)
|
|
}
|
|
|
|
path, err = Initialize("second-token")
|
|
if err != nil {
|
|
t.Fatalf("initialize second token: %v", err)
|
|
}
|
|
if path != wantPath {
|
|
t.Fatalf("replacement env file = %q, want %q", path, wantPath)
|
|
}
|
|
data, err := os.ReadFile(wantPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(data) != TokenEnv+"=second-token\n" {
|
|
t.Fatalf("replaced env file = %q", data)
|
|
}
|
|
}
|
|
|
|
func TestLoadEnvironmentTokenTakesPriority(t *testing.T) {
|
|
envFile := filepath.Join(t.TempDir(), EnvFileName)
|
|
writeTestEnvFile(t, envFile, TokenEnv+"=file-token\n", 0o600)
|
|
environment := map[string]string{
|
|
APIBaseURLEnv: "https://staging.example.com/",
|
|
TokenEnv: " environment-token ",
|
|
}
|
|
|
|
value, err := load(envFile, mapEnvironment(environment))
|
|
if err != nil {
|
|
t.Fatalf("load config: %v", err)
|
|
}
|
|
if value.APIBaseURL != "https://staging.example.com" || value.Token != "environment-token" {
|
|
t.Fatalf("config = %#v", value)
|
|
}
|
|
}
|
|
|
|
func TestLoadAPIOverrideRequiresEnvironmentToken(t *testing.T) {
|
|
envFile := filepath.Join(t.TempDir(), EnvFileName)
|
|
writeTestEnvFile(t, envFile, TokenEnv+"=production-token\n", 0o600)
|
|
|
|
_, err := load(envFile, mapEnvironment(map[string]string{APIBaseURLEnv: "https://staging.example.com"}))
|
|
if err == nil || !strings.Contains(err.Error(), TokenEnv) {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLoadRejectsInvalidExplicitAPIWithoutProductionFallback(t *testing.T) {
|
|
for _, value := range []string{"/", "////", "not-a-url"} {
|
|
t.Run(value, func(t *testing.T) {
|
|
_, err := load(filepath.Join(t.TempDir(), EnvFileName), mapEnvironment(map[string]string{
|
|
APIBaseURLEnv: value,
|
|
TokenEnv: "test-token",
|
|
}))
|
|
if err == nil || !strings.Contains(err.Error(), "有效的 http/https URL") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReadEnvFileRejectsMissingUnsafeOrMalformedFiles(t *testing.T) {
|
|
t.Run("missing", func(t *testing.T) {
|
|
_, err := readEnvFile(filepath.Join(t.TempDir(), EnvFileName))
|
|
if err == nil || !strings.Contains(err.Error(), "shop-crm-agent init") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("directory", func(t *testing.T) {
|
|
_, err := readEnvFile(t.TempDir())
|
|
if err == nil || !strings.Contains(err.Error(), "普通文件") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
})
|
|
|
|
if runtime.GOOS != "windows" {
|
|
t.Run("symlink", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
target := filepath.Join(dir, "target")
|
|
writeTestEnvFile(t, target, TokenEnv+"=token\n", 0o600)
|
|
path := filepath.Join(dir, EnvFileName)
|
|
if err := os.Symlink(target, path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := readEnvFile(path)
|
|
if err == nil || !strings.Contains(err.Error(), "普通文件") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("missing key", func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), EnvFileName)
|
|
writeTestEnvFile(t, path, "OTHER=value\n", 0o600)
|
|
_, err := readEnvFile(path)
|
|
if err == nil || !strings.Contains(err.Error(), "缺少凭证") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("multiple lines", func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), EnvFileName)
|
|
writeTestEnvFile(t, path, TokenEnv+"=token\nOTHER=value\n", 0o600)
|
|
_, err := readEnvFile(path)
|
|
if err == nil || !strings.Contains(err.Error(), "格式无效") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("too large", func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), EnvFileName)
|
|
writeTestEnvFile(t, path, strings.Repeat("x", maxEnvFileSize+1), 0o600)
|
|
_, err := readEnvFile(path)
|
|
if err == nil || !strings.Contains(err.Error(), "16 KiB") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
})
|
|
|
|
if runtime.GOOS != "windows" {
|
|
t.Run("permissions", func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), EnvFileName)
|
|
writeTestEnvFile(t, path, TokenEnv+"=token\n", 0o644)
|
|
_, err := readEnvFile(path)
|
|
if err == nil || !strings.Contains(err.Error(), "0600") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWriteEnvFileCreatesPrivateFileAndReplacesItOnEveryRun(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), EnvFileName)
|
|
if err := writeEnvFile(path, "first-token"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(data) != TokenEnv+"=first-token\n" {
|
|
t.Fatalf("env file = %q", data)
|
|
}
|
|
if runtime.GOOS != "windows" {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.Mode().Perm() != 0o600 {
|
|
t.Fatalf("mode = %o", info.Mode().Perm())
|
|
}
|
|
}
|
|
|
|
if err := writeEnvFile(path, "second-token"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data, _ = os.ReadFile(path)
|
|
if string(data) != TokenEnv+"=second-token\n" {
|
|
t.Fatalf("replaced env file = %q", data)
|
|
}
|
|
matches, err := filepath.Glob(filepath.Join(filepath.Dir(path), EnvFileName+".tmp-*"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(matches) != 0 {
|
|
t.Fatalf("temporary files remain: %v", matches)
|
|
}
|
|
}
|
|
|
|
func TestWriteEnvFileKeepsExistingCredentialsWhenAtomicReplaceFails(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), EnvFileName)
|
|
writeTestEnvFile(t, path, TokenEnv+"=existing-token\n", 0o600)
|
|
|
|
err := writeEnvFileWithReplace(path, "replacement-token", func(source, target string) error {
|
|
return errors.New("injected replace failure")
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "替换") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
data, readErr := os.ReadFile(path)
|
|
if readErr != nil {
|
|
t.Fatal(readErr)
|
|
}
|
|
if string(data) != TokenEnv+"=existing-token\n" {
|
|
t.Fatalf("existing credentials changed: %q", data)
|
|
}
|
|
matches, globErr := filepath.Glob(filepath.Join(filepath.Dir(path), EnvFileName+".tmp-*"))
|
|
if globErr != nil {
|
|
t.Fatal(globErr)
|
|
}
|
|
if len(matches) != 0 {
|
|
t.Fatalf("temporary files remain: %v", matches)
|
|
}
|
|
}
|
|
|
|
func TestWriteEnvFileDoesNotPublishWhenInitialAtomicReplaceFails(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, EnvFileName)
|
|
|
|
err := writeEnvFileWithReplace(path, "initial-token", func(source, target string) error {
|
|
return errors.New("injected replace failure")
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "替换") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
if _, statErr := os.Lstat(path); !errors.Is(statErr, os.ErrNotExist) {
|
|
t.Fatalf("unexpected env file after failed initialization: %v", statErr)
|
|
}
|
|
matches, globErr := filepath.Glob(filepath.Join(dir, EnvFileName+".tmp-*"))
|
|
if globErr != nil {
|
|
t.Fatal(globErr)
|
|
}
|
|
if len(matches) != 0 {
|
|
t.Fatalf("temporary files remain: %v", matches)
|
|
}
|
|
}
|
|
|
|
func TestWriteEnvFileRejectsTokenThatCannotFitEnvFile(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), EnvFileName)
|
|
err := writeEnvFile(path, strings.Repeat("x", maxEnvFileSize))
|
|
if err == nil || !strings.Contains(err.Error(), "大小限制") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) {
|
|
t.Fatalf("unexpected env file: %v", statErr)
|
|
}
|
|
}
|
|
|
|
func TestWriteEnvFileRejectsSymlink(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Windows symlink creation requires privileges on some environments")
|
|
}
|
|
dir := t.TempDir()
|
|
target := filepath.Join(dir, "target")
|
|
writeTestEnvFile(t, target, "unchanged", 0o600)
|
|
path := filepath.Join(dir, EnvFileName)
|
|
if err := os.Symlink(target, path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writeEnvFile(path, "token"); err == nil {
|
|
t.Fatal("symlink env file was overwritten")
|
|
}
|
|
data, _ := os.ReadFile(target)
|
|
if string(data) != "unchanged" {
|
|
t.Fatalf("symlink target changed: %q", data)
|
|
}
|
|
}
|
|
|
|
func TestResolveEnvFileUsesBuiltBinaryRepositoryThenWorkingDirectory(t *testing.T) {
|
|
builtRepo := makeTestRepository(t)
|
|
builtBinary := filepath.Join(builtRepo, "bin", "shop-crm-agent")
|
|
if err := os.MkdirAll(filepath.Dir(builtBinary), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
workingRepo := makeTestRepository(t)
|
|
|
|
path, err := resolveEnvFile(builtBinary, workingRepo)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if path != filepath.Join(builtRepo, EnvFileName) {
|
|
t.Fatalf("built binary env file = %q", path)
|
|
}
|
|
|
|
path, err = resolveEnvFile(filepath.Join(t.TempDir(), "shop-crm-agent"), workingRepo)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if path != filepath.Join(workingRepo, EnvFileName) {
|
|
t.Fatalf("go run env file = %q", path)
|
|
}
|
|
}
|
|
|
|
func TestResolveEnvFileRejectsModulePathMentionedOnlyInComment(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeTestEnvFile(t, filepath.Join(dir, "go.mod"), "module example.com/other\n// module "+modulePath+"\n", 0o644)
|
|
|
|
_, err := resolveEnvFile(filepath.Join(t.TempDir(), "shop-crm-agent"), dir)
|
|
if err == nil || !strings.Contains(err.Error(), "无法定位") {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
}
|
|
|
|
func emptyEnvironment(string) (string, bool) { return "", false }
|
|
|
|
func mapEnvironment(values map[string]string) func(string) (string, bool) {
|
|
return func(key string) (string, bool) {
|
|
value, exists := values[key]
|
|
return value, exists
|
|
}
|
|
}
|
|
|
|
func writeTestEnvFile(t *testing.T, path, contents string, mode os.FileMode) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(contents), mode); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if runtime.GOOS != "windows" {
|
|
if err := os.Chmod(path, mode); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func makeTestRepository(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
writeTestEnvFile(t, filepath.Join(dir, "go.mod"), "module "+modulePath+"\n", 0o644)
|
|
return dir
|
|
}
|