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 }