fix(review): address Copilot and CodeRabbit PR feedback
- commit
- 078b9507577c5f67082820777642e294255c1ac5
- author
- bnema · · 2026-01-30 21:45 UTC
- parent
- 3659333
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +179 -13
M docs/config/auth.md +2 -2
| @@ -45,7 +45,7 @@ Token auth always works. Adding `username` + `password_hash` enables interactive | |||
| 45 | 45 | `token_secret` and `password_hash` are read through the configured backend: | |
| 46 | 46 | ||
| 47 | 47 | | Backend | Description | | |
| 48 | - | |---------|-------------| | |
| 48 | + | | --------- | ------------- | | |
| 49 | 49 | | `pass` | Unix password manager (GPG-encrypted) | | |
| 50 | 50 | | `sops` | Mozilla SOPS encrypted files | | |
| 51 | 51 | | `unsafe` | Plain text files (development only) | | |
| @@ -112,7 +112,7 @@ gordon remotes set-token prod <token> | |||
| 112 | 112 | Registry scopes: | |
| 113 | 113 | ||
| 114 | 114 | | Scope | Permission | | |
| 115 | - | |-------|------------| | |
| 115 | + | | ------- | ------------ | | |
| 116 | 116 | | `push` | Push images to registry | | |
| 117 | 117 | | `pull` | Pull images from registry | | |
| 118 | 118 | | `push,pull` | Both push and pull (default) | | |
| @@ -45,7 +45,7 @@ Token auth always works. Adding `username` + `password_hash` enables interactive | |||||
| 45 | `token_secret` and `password_hash` are read through the configured backend: | 45 | `token_secret` and `password_hash` are read through the configured backend: | ||
| 46 | 46 | ||||
| 47 | | Backend | Description | | 47 | | Backend | Description | | ||
| 48 | - | |---------|-------------| | 48 | + | | --------- | ------------- | |
| 49 | | `pass` | Unix password manager (GPG-encrypted) | | 49 | | `pass` | Unix password manager (GPG-encrypted) | | ||
| 50 | | `sops` | Mozilla SOPS encrypted files | | 50 | | `sops` | Mozilla SOPS encrypted files | | ||
| 51 | | `unsafe` | Plain text files (development only) | | 51 | | `unsafe` | Plain text files (development only) | | ||
| @@ -112,7 +112,7 @@ gordon remotes set-token prod <token> | |||||
| 112 | Registry scopes: | 112 | Registry scopes: | ||
| 113 | 113 | ||||
| 114 | | Scope | Permission | | 114 | | Scope | Permission | | ||
| 115 | - | |-------|------------| | 115 | + | | ------- | ------------ | |
| 116 | | `push` | Push images to registry | | 116 | | `push` | Push images to registry | | ||
| 117 | | `pull` | Pull images from registry | | 117 | | `pull` | Pull images from registry | | ||
| 118 | | `push,pull` | Both push and pull (default) | | 118 | | `push,pull` | Both push and pull (default) | | ||
M docs/config/secrets.md +1 -1
| @@ -55,7 +55,7 @@ token_secret = "gordon/auth/token_secret" | |||
| 55 | 55 | - Works with team GPG keys | |
| 56 | 56 | ||
| 57 | 57 | **Route secrets storage:** | |
| 58 | - | - `gordon secrets set` stores per-domain secrets in pass under `gordon/env/<domain>/<KEY>` | |
| 58 | + | - `gordon secrets set` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores) | |
| 59 | 59 | - Existing `.env` files are auto-migrated on startup and renamed to `.env.migrated` | |
| 60 | 60 | ||
| 61 | 61 | ### SOPS | |
| @@ -55,7 +55,7 @@ token_secret = "gordon/auth/token_secret" | |||||
| 55 | - Works with team GPG keys | 55 | - Works with team GPG keys | ||
| 56 | 56 | ||||
| 57 | **Route secrets storage:** | 57 | **Route secrets storage:** | ||
| 58 | - | - `gordon secrets set` stores per-domain secrets in pass under `gordon/env/<domain>/<KEY>` | 58 | + | - `gordon secrets set` stores per-domain secrets in pass under `gordon/env/<sanitized-domain>/<KEY>` (dots/colons/slashes → underscores) |
| 59 | - Existing `.env` files are auto-migrated on startup and renamed to `.env.migrated` | 59 | - Existing `.env` files are auto-migrated on startup and renamed to `.env.migrated` | ||
| 60 | 60 | ||||
| 61 | ### SOPS | 61 | ### SOPS | ||
M docs/getting-started.md +1 -0
| @@ -54,6 +54,7 @@ openssl rand -base64 32 | pass insert -m gordon/auth/token_secret | |||
| 54 | 54 | ``` | |
| 55 | 55 | ||
| 56 | 56 | **Or set it via environment variable:** | |
| 57 | + | ||
| 57 | 58 | ```bash | |
| 58 | 59 | export GORDON_AUTH_TOKEN_SECRET="your-32-character-secret-here" | |
| 59 | 60 | ``` | |
| @@ -54,6 +54,7 @@ openssl rand -base64 32 | pass insert -m gordon/auth/token_secret | |||||
| 54 | ``` | 54 | ``` | ||
| 55 | 55 | ||||
| 56 | **Or set it via environment variable:** | 56 | **Or set it via environment variable:** | ||
| 57 | + | ||||
| 57 | ```bash | 58 | ```bash | ||
| 58 | export GORDON_AUTH_TOKEN_SECRET="your-32-character-secret-here" | 59 | export GORDON_AUTH_TOKEN_SECRET="your-32-character-secret-here" | ||
| 59 | ``` | 60 | ``` | ||
M internal/adapters/out/domainsecrets/pass_store.go +2 -1
| @@ -387,7 +387,8 @@ func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, er | |||
| 387 | 387 | } | |
| 388 | 388 | ||
| 389 | 389 | clean := ansiRegex.ReplaceAllString(string(output), "") | |
| 390 | - | return strings.TrimSpace(clean), true, nil | |
| 390 | + | clean = strings.TrimRight(clean, "\r\n") | |
| 391 | + | return clean, true, nil | |
| 391 | 392 | } | |
| 392 | 393 | ||
| 393 | 394 | func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) { | |
| @@ -387,7 +387,8 @@ func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, er | |||||
| 387 | } | 387 | } | ||
| 388 | 388 | ||||
| 389 | clean := ansiRegex.ReplaceAllString(string(output), "") | 389 | clean := ansiRegex.ReplaceAllString(string(output), "") | ||
| 390 | - | return strings.TrimSpace(clean), true, nil | 390 | + | clean = strings.TrimRight(clean, "\r\n") |
| 391 | + | return clean, true, nil | |||
| 391 | } | 392 | } | ||
| 392 | 393 | ||||
| 393 | func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) { | 394 | func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) { | ||
M internal/adapters/out/domainsecrets/pass_store_test.go +14 -8
| @@ -1,9 +1,9 @@ | |||
| 1 | 1 | package domainsecrets | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "fmt" | |
| 5 | 6 | "os/exec" | |
| 6 | - | "strings" | |
| 7 | 7 | "testing" | |
| 8 | 8 | "time" | |
| 9 | 9 | ||
| @@ -13,16 +13,22 @@ import ( | |||
| 13 | 13 | "github.com/bnema/gordon/internal/domain" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | + | func passCmd(args ...string) error { | |
| 17 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 18 | + | defer cancel() | |
| 19 | + | return exec.CommandContext(ctx, "pass", args...).Run() | |
| 20 | + | } | |
| 21 | + | ||
| 16 | 22 | func requirePass(t *testing.T) { | |
| 17 | - | if err := exec.Command("pass", "version").Run(); err != nil { | |
| 23 | + | if err := passCmd("version"); err != nil { | |
| 18 | 24 | t.Skip("pass not available") | |
| 19 | 25 | } | |
| 20 | - | if err := exec.Command("pass", "ls").Run(); err != nil { | |
| 26 | + | if err := passCmd("ls"); err != nil { | |
| 21 | 27 | t.Skip("pass store not initialized") | |
| 22 | 28 | } | |
| 23 | 29 | } | |
| 24 | 30 | ||
| 25 | - | func cleanupPassDomain(t *testing.T, domainName string, keys []string) { | |
| 31 | + | func cleanupPassDomain(_ *testing.T, domainName string, keys []string) { | |
| 26 | 32 | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | |
| 27 | 33 | if err != nil { | |
| 28 | 34 | return | |
| @@ -30,11 +36,11 @@ func cleanupPassDomain(t *testing.T, domainName string, keys []string) { | |||
| 30 | 36 | ||
| 31 | 37 | for _, key := range keys { | |
| 32 | 38 | path := fmt.Sprintf("%s/%s/%s", passDomainSecretsPath, safeDomain, key) | |
| 33 | - | _ = exec.Command("pass", "rm", "-f", path).Run() | |
| 39 | + | _ = passCmd("rm", "-f", path) | |
| 34 | 40 | } | |
| 35 | 41 | ||
| 36 | 42 | manifestPath := fmt.Sprintf("%s/%s/.keys", passDomainSecretsPath, safeDomain) | |
| 37 | - | _ = exec.Command("pass", "rm", "-f", manifestPath).Run() | |
| 43 | + | _ = passCmd("rm", "-f", manifestPath) | |
| 38 | 44 | } | |
| 39 | 45 | ||
| 40 | 46 | func TestPassStore_SetGetDelete(t *testing.T) { | |
| @@ -62,8 +68,8 @@ func TestPassStore_SetGetDelete(t *testing.T) { | |||
| 62 | 68 | ||
| 63 | 69 | values, err := store.GetAll(domainName) | |
| 64 | 70 | require.NoError(t, err) | |
| 65 | - | assert.Equal(t, "alpha", strings.TrimSpace(values["API_KEY"])) | |
| 66 | - | assert.Equal(t, "bravo", strings.TrimSpace(values["DB_PASSWORD"])) | |
| 71 | + | assert.Equal(t, "alpha", values["API_KEY"]) | |
| 72 | + | assert.Equal(t, "bravo", values["DB_PASSWORD"]) | |
| 67 | 73 | ||
| 68 | 74 | err = store.Delete(domainName, "DB_PASSWORD") | |
| 69 | 75 | require.NoError(t, err) | |
| @@ -1,9 +1,9 @@ | |||||
| 1 | package domainsecrets | 1 | package domainsecrets | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "fmt" | 5 | "fmt" | ||
| 5 | "os/exec" | 6 | "os/exec" | ||
| 6 | - | "strings" | |||
| 7 | "testing" | 7 | "testing" | ||
| 8 | "time" | 8 | "time" | ||
| 9 | 9 | ||||
| @@ -13,16 +13,22 @@ import ( | |||||
| 13 | "github.com/bnema/gordon/internal/domain" | 13 | "github.com/bnema/gordon/internal/domain" | ||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | + | func passCmd(args ...string) error { | |||
| 17 | + | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |||
| 18 | + | defer cancel() | |||
| 19 | + | return exec.CommandContext(ctx, "pass", args...).Run() | |||
| 20 | + | } | |||
| 21 | + | ||||
| 16 | func requirePass(t *testing.T) { | 22 | func requirePass(t *testing.T) { | ||
| 17 | - | if err := exec.Command("pass", "version").Run(); err != nil { | 23 | + | if err := passCmd("version"); err != nil { |
| 18 | t.Skip("pass not available") | 24 | t.Skip("pass not available") | ||
| 19 | } | 25 | } | ||
| 20 | - | if err := exec.Command("pass", "ls").Run(); err != nil { | 26 | + | if err := passCmd("ls"); err != nil { |
| 21 | t.Skip("pass store not initialized") | 27 | t.Skip("pass store not initialized") | ||
| 22 | } | 28 | } | ||
| 23 | } | 29 | } | ||
| 24 | 30 | ||||
| 25 | - | func cleanupPassDomain(t *testing.T, domainName string, keys []string) { | 31 | + | func cleanupPassDomain(_ *testing.T, domainName string, keys []string) { |
| 26 | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | 32 | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | ||
| 27 | if err != nil { | 33 | if err != nil { | ||
| 28 | return | 34 | return | ||
| @@ -30,11 +36,11 @@ func cleanupPassDomain(t *testing.T, domainName string, keys []string) { | |||||
| 30 | 36 | ||||
| 31 | for _, key := range keys { | 37 | for _, key := range keys { | ||
| 32 | path := fmt.Sprintf("%s/%s/%s", passDomainSecretsPath, safeDomain, key) | 38 | path := fmt.Sprintf("%s/%s/%s", passDomainSecretsPath, safeDomain, key) | ||
| 33 | - | _ = exec.Command("pass", "rm", "-f", path).Run() | 39 | + | _ = passCmd("rm", "-f", path) |
| 34 | } | 40 | } | ||
| 35 | 41 | ||||
| 36 | manifestPath := fmt.Sprintf("%s/%s/.keys", passDomainSecretsPath, safeDomain) | 42 | manifestPath := fmt.Sprintf("%s/%s/.keys", passDomainSecretsPath, safeDomain) | ||
| 37 | - | _ = exec.Command("pass", "rm", "-f", manifestPath).Run() | 43 | + | _ = passCmd("rm", "-f", manifestPath) |
| 38 | } | 44 | } | ||
| 39 | 45 | ||||
| 40 | func TestPassStore_SetGetDelete(t *testing.T) { | 46 | func TestPassStore_SetGetDelete(t *testing.T) { | ||
| @@ -62,8 +68,8 @@ func TestPassStore_SetGetDelete(t *testing.T) { | |||||
| 62 | 68 | ||||
| 63 | values, err := store.GetAll(domainName) | 69 | values, err := store.GetAll(domainName) | ||
| 64 | require.NoError(t, err) | 70 | require.NoError(t, err) | ||
| 65 | - | assert.Equal(t, "alpha", strings.TrimSpace(values["API_KEY"])) | 71 | + | assert.Equal(t, "alpha", values["API_KEY"]) |
| 66 | - | assert.Equal(t, "bravo", strings.TrimSpace(values["DB_PASSWORD"])) | 72 | + | assert.Equal(t, "bravo", values["DB_PASSWORD"]) |
| 67 | 73 | ||||
| 68 | err = store.Delete(domainName, "DB_PASSWORD") | 74 | err = store.Delete(domainName, "DB_PASSWORD") | ||
| 69 | require.NoError(t, err) | 75 | require.NoError(t, err) | ||
M internal/app/migrate_env.go +8 -1
| @@ -46,7 +46,14 @@ func readEnvDir(envDir string) ([]os.DirEntry, error) { | |||
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | 48 | func isPlainEnvFile(name string) bool { | |
| 49 | - | return strings.HasSuffix(name, ".env") && !strings.HasSuffix(name, ".env.migrated") | |
| 49 | + | if !strings.HasSuffix(name, ".env") || strings.HasSuffix(name, ".env.migrated") { | |
| 50 | + | return false | |
| 51 | + | } | |
| 52 | + | // Skip attachment env files: gordon-<sanitized-domain>-<service>.env | |
| 53 | + | if strings.HasPrefix(name, "gordon-") { | |
| 54 | + | return false | |
| 55 | + | } | |
| 56 | + | return true | |
| 50 | 57 | } | |
| 51 | 58 | ||
| 52 | 59 | func migrateEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error { | |
| @@ -46,7 +46,14 @@ func readEnvDir(envDir string) ([]os.DirEntry, error) { | |||||
| 46 | } | 46 | } | ||
| 47 | 47 | ||||
| 48 | func isPlainEnvFile(name string) bool { | 48 | func isPlainEnvFile(name string) bool { | ||
| 49 | - | return strings.HasSuffix(name, ".env") && !strings.HasSuffix(name, ".env.migrated") | 49 | + | if !strings.HasSuffix(name, ".env") || strings.HasSuffix(name, ".env.migrated") { |
| 50 | + | return false | |||
| 51 | + | } | |||
| 52 | + | // Skip attachment env files: gordon-<sanitized-domain>-<service>.env | |||
| 53 | + | if strings.HasPrefix(name, "gordon-") { | |||
| 54 | + | return false | |||
| 55 | + | } | |||
| 56 | + | return true | |||
| 50 | } | 57 | } | ||
| 51 | 58 | ||||
| 52 | func migrateEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error { | 59 | func migrateEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error { | ||
M internal/domain/env.go +2 -0
| @@ -37,6 +37,8 @@ func SanitizeDomainForEnvFile(domainName string) (string, error) { | |||
| 37 | 37 | func ParseEnvData(data []byte) (map[string]string, error) { | |
| 38 | 38 | secrets := make(map[string]string) | |
| 39 | 39 | scanner := bufio.NewScanner(bytes.NewReader(data)) | |
| 40 | + | // Allow large env values (certs/keys) without hitting the default ~64KB limit. | |
| 41 | + | scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) | |
| 40 | 42 | for scanner.Scan() { | |
| 41 | 43 | line := strings.TrimSpace(scanner.Text()) | |
| 42 | 44 | if line == "" || strings.HasPrefix(line, "#") { | |
| @@ -37,6 +37,8 @@ func SanitizeDomainForEnvFile(domainName string) (string, error) { | |||||
| 37 | func ParseEnvData(data []byte) (map[string]string, error) { | 37 | func ParseEnvData(data []byte) (map[string]string, error) { | ||
| 38 | secrets := make(map[string]string) | 38 | secrets := make(map[string]string) | ||
| 39 | scanner := bufio.NewScanner(bytes.NewReader(data)) | 39 | scanner := bufio.NewScanner(bytes.NewReader(data)) | ||
| 40 | + | // Allow large env values (certs/keys) without hitting the default ~64KB limit. | |||
| 41 | + | scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) | |||
| 40 | for scanner.Scan() { | 42 | for scanner.Scan() { | ||
| 41 | line := strings.TrimSpace(scanner.Text()) | 43 | line := strings.TrimSpace(scanner.Text()) | ||
| 42 | if line == "" || strings.HasPrefix(line, "#") { | 44 | if line == "" || strings.HasPrefix(line, "#") { | ||
A internal/domain/env_test.go +149 -0
| @@ -0,0 +1,149 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestSanitizeDomainForEnvFile(t *testing.T) { | |
| 12 | + | tests := []struct { | |
| 13 | + | name string | |
| 14 | + | domain string | |
| 15 | + | want string | |
| 16 | + | wantErr bool | |
| 17 | + | }{ | |
| 18 | + | {name: "simple domain", domain: "example.com", want: "example_com"}, | |
| 19 | + | {name: "subdomain", domain: "app.example.com", want: "app_example_com"}, | |
| 20 | + | {name: "domain with port", domain: "example.com:8080", want: "example_com_8080"}, | |
| 21 | + | {name: "domain with path", domain: "example.com/path", want: "example_com_path"}, | |
| 22 | + | {name: "single char", domain: "a", want: "a"}, | |
| 23 | + | {name: "hyphenated", domain: "my-app.example.com", want: "my-app_example_com"}, | |
| 24 | + | {name: "empty", domain: "", wantErr: true}, | |
| 25 | + | {name: "path traversal", domain: "../etc/passwd", wantErr: true}, | |
| 26 | + | {name: "double dots", domain: "foo..bar", wantErr: true}, | |
| 27 | + | {name: "starts with dot", domain: ".hidden", wantErr: true}, | |
| 28 | + | {name: "ends with dot", domain: "trailing.", wantErr: true}, | |
| 29 | + | {name: "space", domain: "has space", wantErr: true}, | |
| 30 | + | {name: "special chars", domain: "bad$domain", wantErr: true}, | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | for _, tt := range tests { | |
| 34 | + | t.Run(tt.name, func(t *testing.T) { | |
| 35 | + | got, err := SanitizeDomainForEnvFile(tt.domain) | |
| 36 | + | if tt.wantErr { | |
| 37 | + | assert.Error(t, err) | |
| 38 | + | return | |
| 39 | + | } | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | assert.Equal(t, tt.want, got) | |
| 42 | + | }) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestParseEnvData(t *testing.T) { | |
| 47 | + | tests := []struct { | |
| 48 | + | name string | |
| 49 | + | data string | |
| 50 | + | want map[string]string | |
| 51 | + | wantErr bool | |
| 52 | + | }{ | |
| 53 | + | { | |
| 54 | + | name: "simple key-value", | |
| 55 | + | data: "FOO=bar\nBAZ=qux", | |
| 56 | + | want: map[string]string{"FOO": "bar", "BAZ": "qux"}, | |
| 57 | + | }, | |
| 58 | + | { | |
| 59 | + | name: "double-quoted value", | |
| 60 | + | data: `KEY="hello world"`, | |
| 61 | + | want: map[string]string{"KEY": "hello world"}, | |
| 62 | + | }, | |
| 63 | + | { | |
| 64 | + | name: "single-quoted value", | |
| 65 | + | data: `KEY='hello world'`, | |
| 66 | + | want: map[string]string{"KEY": "hello world"}, | |
| 67 | + | }, | |
| 68 | + | { | |
| 69 | + | name: "comment lines", | |
| 70 | + | data: "# this is a comment\nKEY=val", | |
| 71 | + | want: map[string]string{"KEY": "val"}, | |
| 72 | + | }, | |
| 73 | + | { | |
| 74 | + | name: "empty lines", | |
| 75 | + | data: "\n\nKEY=val\n\n", | |
| 76 | + | want: map[string]string{"KEY": "val"}, | |
| 77 | + | }, | |
| 78 | + | { | |
| 79 | + | name: "value with equals", | |
| 80 | + | data: "URL=postgres://host:5432/db?opt=1", | |
| 81 | + | want: map[string]string{"URL": "postgres://host:5432/db?opt=1"}, | |
| 82 | + | }, | |
| 83 | + | { | |
| 84 | + | name: "no value", | |
| 85 | + | data: "NOVALUE", | |
| 86 | + | want: map[string]string{}, | |
| 87 | + | }, | |
| 88 | + | { | |
| 89 | + | name: "empty value", | |
| 90 | + | data: "KEY=", | |
| 91 | + | want: map[string]string{"KEY": ""}, | |
| 92 | + | }, | |
| 93 | + | { | |
| 94 | + | name: "empty input", | |
| 95 | + | data: "", | |
| 96 | + | want: map[string]string{}, | |
| 97 | + | }, | |
| 98 | + | { | |
| 99 | + | name: "large value within buffer", | |
| 100 | + | data: "BIG=" + strings.Repeat("x", 100000), | |
| 101 | + | want: map[string]string{"BIG": strings.Repeat("x", 100000)}, | |
| 102 | + | }, | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | for _, tt := range tests { | |
| 106 | + | t.Run(tt.name, func(t *testing.T) { | |
| 107 | + | got, err := ParseEnvData([]byte(tt.data)) | |
| 108 | + | if tt.wantErr { | |
| 109 | + | assert.Error(t, err) | |
| 110 | + | return | |
| 111 | + | } | |
| 112 | + | require.NoError(t, err) | |
| 113 | + | assert.Equal(t, tt.want, got) | |
| 114 | + | }) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func TestValidateEnvKey(t *testing.T) { | |
| 119 | + | tests := []struct { | |
| 120 | + | name string | |
| 121 | + | key string | |
| 122 | + | wantErr bool | |
| 123 | + | }{ | |
| 124 | + | {name: "simple", key: "FOO", wantErr: false}, | |
| 125 | + | {name: "with underscore", key: "FOO_BAR", wantErr: false}, | |
| 126 | + | {name: "starts with underscore", key: "_PRIVATE", wantErr: false}, | |
| 127 | + | {name: "lowercase", key: "foo", wantErr: false}, | |
| 128 | + | {name: "mixed", key: "myApp_v2", wantErr: false}, | |
| 129 | + | {name: "empty", key: "", wantErr: true}, | |
| 130 | + | {name: "starts with number", key: "1BAD", wantErr: true}, | |
| 131 | + | {name: "contains dot", key: "FOO.BAR", wantErr: true}, | |
| 132 | + | {name: "contains slash", key: "FOO/BAR", wantErr: true}, | |
| 133 | + | {name: "contains backslash", key: `FOO\BAR`, wantErr: true}, | |
| 134 | + | {name: "path traversal", key: "..", wantErr: true}, | |
| 135 | + | {name: "contains hyphen", key: "FOO-BAR", wantErr: true}, | |
| 136 | + | {name: "contains space", key: "FOO BAR", wantErr: true}, | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | for _, tt := range tests { | |
| 140 | + | t.Run(tt.name, func(t *testing.T) { | |
| 141 | + | err := ValidateEnvKey(tt.key) | |
| 142 | + | if tt.wantErr { | |
| 143 | + | assert.Error(t, err) | |
| 144 | + | } else { | |
| 145 | + | assert.NoError(t, err) | |
| 146 | + | } | |
| 147 | + | }) | |
| 148 | + | } | |
| 149 | + | } | |
| @@ -0,0 +1,149 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestSanitizeDomainForEnvFile(t *testing.T) { | |||
| 12 | + | tests := []struct { | |||
| 13 | + | name string | |||
| 14 | + | domain string | |||
| 15 | + | want string | |||
| 16 | + | wantErr bool | |||
| 17 | + | }{ | |||
| 18 | + | {name: "simple domain", domain: "example.com", want: "example_com"}, | |||
| 19 | + | {name: "subdomain", domain: "app.example.com", want: "app_example_com"}, | |||
| 20 | + | {name: "domain with port", domain: "example.com:8080", want: "example_com_8080"}, | |||
| 21 | + | {name: "domain with path", domain: "example.com/path", want: "example_com_path"}, | |||
| 22 | + | {name: "single char", domain: "a", want: "a"}, | |||
| 23 | + | {name: "hyphenated", domain: "my-app.example.com", want: "my-app_example_com"}, | |||
| 24 | + | {name: "empty", domain: "", wantErr: true}, | |||
| 25 | + | {name: "path traversal", domain: "../etc/passwd", wantErr: true}, | |||
| 26 | + | {name: "double dots", domain: "foo..bar", wantErr: true}, | |||
| 27 | + | {name: "starts with dot", domain: ".hidden", wantErr: true}, | |||
| 28 | + | {name: "ends with dot", domain: "trailing.", wantErr: true}, | |||
| 29 | + | {name: "space", domain: "has space", wantErr: true}, | |||
| 30 | + | {name: "special chars", domain: "bad$domain", wantErr: true}, | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | for _, tt := range tests { | |||
| 34 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 35 | + | got, err := SanitizeDomainForEnvFile(tt.domain) | |||
| 36 | + | if tt.wantErr { | |||
| 37 | + | assert.Error(t, err) | |||
| 38 | + | return | |||
| 39 | + | } | |||
| 40 | + | require.NoError(t, err) | |||
| 41 | + | assert.Equal(t, tt.want, got) | |||
| 42 | + | }) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestParseEnvData(t *testing.T) { | |||
| 47 | + | tests := []struct { | |||
| 48 | + | name string | |||
| 49 | + | data string | |||
| 50 | + | want map[string]string | |||
| 51 | + | wantErr bool | |||
| 52 | + | }{ | |||
| 53 | + | { | |||
| 54 | + | name: "simple key-value", | |||
| 55 | + | data: "FOO=bar\nBAZ=qux", | |||
| 56 | + | want: map[string]string{"FOO": "bar", "BAZ": "qux"}, | |||
| 57 | + | }, | |||
| 58 | + | { | |||
| 59 | + | name: "double-quoted value", | |||
| 60 | + | data: `KEY="hello world"`, | |||
| 61 | + | want: map[string]string{"KEY": "hello world"}, | |||
| 62 | + | }, | |||
| 63 | + | { | |||
| 64 | + | name: "single-quoted value", | |||
| 65 | + | data: `KEY='hello world'`, | |||
| 66 | + | want: map[string]string{"KEY": "hello world"}, | |||
| 67 | + | }, | |||
| 68 | + | { | |||
| 69 | + | name: "comment lines", | |||
| 70 | + | data: "# this is a comment\nKEY=val", | |||
| 71 | + | want: map[string]string{"KEY": "val"}, | |||
| 72 | + | }, | |||
| 73 | + | { | |||
| 74 | + | name: "empty lines", | |||
| 75 | + | data: "\n\nKEY=val\n\n", | |||
| 76 | + | want: map[string]string{"KEY": "val"}, | |||
| 77 | + | }, | |||
| 78 | + | { | |||
| 79 | + | name: "value with equals", | |||
| 80 | + | data: "URL=postgres://host:5432/db?opt=1", | |||
| 81 | + | want: map[string]string{"URL": "postgres://host:5432/db?opt=1"}, | |||
| 82 | + | }, | |||
| 83 | + | { | |||
| 84 | + | name: "no value", | |||
| 85 | + | data: "NOVALUE", | |||
| 86 | + | want: map[string]string{}, | |||
| 87 | + | }, | |||
| 88 | + | { | |||
| 89 | + | name: "empty value", | |||
| 90 | + | data: "KEY=", | |||
| 91 | + | want: map[string]string{"KEY": ""}, | |||
| 92 | + | }, | |||
| 93 | + | { | |||
| 94 | + | name: "empty input", | |||
| 95 | + | data: "", | |||
| 96 | + | want: map[string]string{}, | |||
| 97 | + | }, | |||
| 98 | + | { | |||
| 99 | + | name: "large value within buffer", | |||
| 100 | + | data: "BIG=" + strings.Repeat("x", 100000), | |||
| 101 | + | want: map[string]string{"BIG": strings.Repeat("x", 100000)}, | |||
| 102 | + | }, | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | for _, tt := range tests { | |||
| 106 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 107 | + | got, err := ParseEnvData([]byte(tt.data)) | |||
| 108 | + | if tt.wantErr { | |||
| 109 | + | assert.Error(t, err) | |||
| 110 | + | return | |||
| 111 | + | } | |||
| 112 | + | require.NoError(t, err) | |||
| 113 | + | assert.Equal(t, tt.want, got) | |||
| 114 | + | }) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func TestValidateEnvKey(t *testing.T) { | |||
| 119 | + | tests := []struct { | |||
| 120 | + | name string | |||
| 121 | + | key string | |||
| 122 | + | wantErr bool | |||
| 123 | + | }{ | |||
| 124 | + | {name: "simple", key: "FOO", wantErr: false}, | |||
| 125 | + | {name: "with underscore", key: "FOO_BAR", wantErr: false}, | |||
| 126 | + | {name: "starts with underscore", key: "_PRIVATE", wantErr: false}, | |||
| 127 | + | {name: "lowercase", key: "foo", wantErr: false}, | |||
| 128 | + | {name: "mixed", key: "myApp_v2", wantErr: false}, | |||
| 129 | + | {name: "empty", key: "", wantErr: true}, | |||
| 130 | + | {name: "starts with number", key: "1BAD", wantErr: true}, | |||
| 131 | + | {name: "contains dot", key: "FOO.BAR", wantErr: true}, | |||
| 132 | + | {name: "contains slash", key: "FOO/BAR", wantErr: true}, | |||
| 133 | + | {name: "contains backslash", key: `FOO\BAR`, wantErr: true}, | |||
| 134 | + | {name: "path traversal", key: "..", wantErr: true}, | |||
| 135 | + | {name: "contains hyphen", key: "FOO-BAR", wantErr: true}, | |||
| 136 | + | {name: "contains space", key: "FOO BAR", wantErr: true}, | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | for _, tt := range tests { | |||
| 140 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 141 | + | err := ValidateEnvKey(tt.key) | |||
| 142 | + | if tt.wantErr { | |||
| 143 | + | assert.Error(t, err) | |||
| 144 | + | } else { | |||
| 145 | + | assert.NoError(t, err) | |||
| 146 | + | } | |||
| 147 | + | }) | |||
| 148 | + | } | |||
| 149 | + | } | |||