From 8ec1c356c62e2c71360e0919deb6a9d6c5c0cf44 Mon Sep 17 00:00:00 2001 From: crazywolf132 Date: Tue, 26 Nov 2024 15:37:19 +1100 Subject: [PATCH] init: added all files --- .github/workflows/ci.yml | 58 +++++ .github/workflows/release.yml | 102 ++++++++ .gitignore | 34 +++ CONTRIBUTING.md | 43 ++++ LICENSE | 21 ++ Makefile | 29 +++ README.md | 203 +++++++++++++++ example/main.go | 87 +++++++ go.mod | 27 ++ go.sum | 38 +++ revive.toml | 29 +++ secret.go | 453 ++++++++++++++++++++++++++++++++++ secret_test.go | 361 +++++++++++++++++++++++++++ 13 files changed, 1485 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 example/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 revive.toml create mode 100644 secret.go create mode 100644 secret_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8c300e7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + strategy: + matrix: + go-version: ['1.19', '1.20', '1.21'] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: ${{ matrix.go-version }} + + - name: Get dependencies + run: go mod download + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage + uses: actions/upload-artifact@v3 + with: + name: coverage-${{ matrix.go-version }} + path: coverage.out + + build: + name: Build + runs-on: ubuntu-latest + needs: test + strategy: + matrix: + go-version: ['1.21'] + platform: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: ${{ matrix.go-version }} + + - name: Get dependencies + run: go mod download + + - name: Build + run: go build -v ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d4197a1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,102 @@ +name: Create Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version tag (e.g., v1.0.0)' + required: true + type: string + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Get dependencies + run: go mod download + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + build-and-release: + name: Create Release + needs: test + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Get dependencies + run: go mod download + + - name: Build + run: | + GOOS=linux GOARCH=amd64 go build -o secret-fetch-linux-amd64 ./... + GOOS=darwin GOARCH=amd64 go build -o secret-fetch-darwin-amd64 ./... + GOOS=windows GOARCH=amd64 go build -o secret-fetch-windows-amd64.exe ./... + + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.event.inputs.version }} + release_name: Release ${{ github.event.inputs.version }} + draft: false + prerelease: false + body: | + Release ${{ github.event.inputs.version }} + + ## What's Changed + * Please update these release notes manually + + ## Installation + ```bash + go get github.com/crazywolf132/SecretFetch@${{ github.event.inputs.version }} + ``` + + - name: Upload Linux Binary + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./secret-fetch-linux-amd64 + asset_name: secret-fetch-linux-amd64 + asset_content_type: application/octet-stream + + - name: Upload macOS Binary + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./secret-fetch-darwin-amd64 + asset_name: secret-fetch-darwin-amd64 + asset_content_type: application/octet-stream + + - name: Upload Windows Binary + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./secret-fetch-windows-amd64.exe + asset_name: secret-fetch-windows-amd64.exe + asset_content_type: application/octet-stream diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e42cae4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out +coverage.out + +# Dependency directories +vendor/ + +# Go workspace file +go.work + +# IDE specific files +.idea/ +.vscode/ +*.swp +*.swo + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..89071b9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,43 @@ +# Contributing to SecretFetch + +We love your input! We want to make contributing to SecretFetch as easy and transparent as possible, whether it's: + +- Reporting a bug +- Discussing the current state of the code +- Submitting a fix +- Proposing new features +- Becoming a maintainer + +## We Develop with Github +We use GitHub to host code, to track issues and feature requests, as well as accept pull requests. + +## We Use [Github Flow](https://guides.github.com/introduction/flow/index.html) +Pull requests are the best way to propose changes to the codebase. We actively welcome your pull requests: + +1. Fork the repo and create your branch from `main`. +2. If you've added code that should be tested, add tests. +3. If you've changed APIs, update the documentation. +4. Ensure the test suite passes. +5. Make sure your code lints. +6. Issue that pull request! + +## Any contributions you make will be under the MIT Software License +In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. + +## Report bugs using Github's [issue tracker](https://github.com/crazywolf132/SecretFetch/issues) +We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/crazywolf132/SecretFetch/issues/new); it's that easy! + +## Write bug reports with detail, background, and sample code + +**Great Bug Reports** tend to have: + +- A quick summary and/or background +- Steps to reproduce + - Be specific! + - Give sample code if you can. +- What you expected would happen +- What actually happens +- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) + +## License +By contributing, you agree that your contributions will be licensed under its MIT License. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2d6613a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Brayden + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fb91e76 --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +.PHONY: all build test lint clean install-tools + +GO := go +GOFLAGS := -v +BINARY_NAME := secretfetch +COVERAGE_FILE := coverage.out + +all: lint test build + +build: + $(GO) build $(GOFLAGS) ./... + +test: + $(GO) test $(GOFLAGS) -race -coverprofile=$(COVERAGE_FILE) ./... + $(GO) tool cover -func=$(COVERAGE_FILE) + +lint: install-tools + revive -config revive.toml ./... + $(GO) vet ./... + +clean: + $(GO) clean + rm -f $(COVERAGE_FILE) + rm -f $(BINARY_NAME) + +install-tools: + @which revive > /dev/null || $(GO) install github.com/mgechev/revive@latest + +.DEFAULT_GOAL := all diff --git a/README.md b/README.md new file mode 100644 index 0000000..db2138e --- /dev/null +++ b/README.md @@ -0,0 +1,203 @@ +# SecretFetch + +SecretFetch is a powerful and easy-to-use Go library for managing secrets in your applications. It provides a seamless way to fetch secrets from AWS Secrets Manager, environment variables, or fallback values, with built-in validation, transformation, and caching capabilities. + +## Features + +- AWS Secrets Manager integration (AWS SDK v2) +- Environment variable support with optional prefixing +- Fallback values for development and testing +- Native Go type support with automatic type conversion +- Simple, intuitive API using struct tags +- Safe secret handling with masking in logs +- Built-in validation with custom validators +- Pattern matching with regex support +- JSON/YAML parsing for complex configurations +- Base64 decoding support +- Configurable caching with TTL +- Value transformers for custom processing +- Concurrent access support +- Flexible configuration options + +## Installation + +```bash +go get github.com/crazywolf132/SecretFetch +``` + +## Basic Usage + +```go +type Config struct { + // Basic string value from AWS Secrets Manager or environment + APIKey string `secret:"aws=prod/api/key,env=API_KEY,required"` + + // Number with fallback value + MaxRetries int `secret:"env=MAX_RETRIES,fallback=3"` + + // Duration with pattern validation + Timeout time.Duration `secret:"env=TIMEOUT,fallback=30s,pattern=^[0-9]+[smh]$"` +} + +// Create and populate your config +cfg := &Config{} +if err := secretfetch.Fetch(context.Background(), cfg, nil); err != nil { + log.Fatal(err) +} +``` + +## Advanced Features + +### Pattern Validation +```go +type Config struct { + // Email validation + Email string `secret:"env=EMAIL,pattern=^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"` + + // IP address validation + IPAddr string `secret:"env=IP_ADDR,pattern=((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)"` + + // Port number validation + Port string `secret:"env=PORT,pattern=^(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3})$"` + + // Version string validation + Version string `secret:"env=VERSION,pattern=v[0-9]+\\.[0-9]+\\.[0-9]+"` +} +``` + +### Custom Validation +```go +type Config struct { + Password string `secret:"env=PASSWORD"` +} + +opts := &secretfetch.Options{ + Validators: map[string]secretfetch.ValidatorFunc{ + "PASSWORD": func(value string) error { + if len(value) < 8 { + return fmt.Errorf("password must be at least 8 characters") + } + return nil + }, + }, +} +``` + +### Value Transformation +```go +type Config struct { + // Transform value before use + APIKey string `secret:"env=API_KEY"` +} + +opts := &secretfetch.Options{ + Transformers: map[string]secretfetch.TransformerFunc{ + "API_KEY": func(value string) (string, error) { + return strings.TrimSpace(value), nil + }, + }, +} +``` + +### Complex Configuration with JSON/YAML +```go +type DatabaseConfig struct { + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` +} + +type Config struct { + // Parse entire database config from JSON + Database DatabaseConfig `secret:"aws=prod/db/config,json"` + + // Parse array from JSON + AllowedIPs []string `secret:"env=ALLOWED_IPS,json"` +} +``` + +### Base64 Decoding and Binary Data +```go +type Config struct { + // Automatically decode base64-encoded certificate + Certificate string `secret:"env=TLS_CERT,base64"` + + // Store as raw bytes + PrivateKey []byte `secret:"env=PRIVATE_KEY,base64"` +} +``` + +### Caching with TTL +```go +type Config struct { + // Cache for 5 minutes + APIKey string `secret:"aws=prod/api/key,ttl=5m"` + + // Cache indefinitely + StaticConfig string `secret:"aws=prod/static/config,ttl=-1"` +} + +// Global caching options +opts := &secretfetch.Options{ + DefaultTTL: 5 * time.Minute, // Default cache duration + Prefix: "MYAPP_", // Prefix for all env vars +} +``` + +### AWS Configuration +```go +opts := &secretfetch.Options{ + AWSConfig: aws.Config{ + Region: "us-west-2", + Credentials: credentials.NewStaticCredentialsProvider("ACCESS_KEY", "SECRET_KEY", ""), + }, +} +``` + +### Error Handling +```go +if err := secretfetch.Fetch(context.Background(), cfg, opts); err != nil { + switch e := err.(type) { + case *secretfetch.ValidationError: + log.Printf("Validation failed: %v", e) + case *secretfetch.PatternError: + log.Printf("Pattern match failed: %v", e) + case *secretfetch.RequiredError: + log.Printf("Required value missing: %v", e) + default: + log.Printf("Unknown error: %v", err) + } +} +``` + +## Best Practices + +1. **Security**: + - Never log sensitive values + - Use environment variables for local development + - Rotate secrets regularly + - Use AWS IAM roles with minimal permissions + +2. **Performance**: + - Enable caching for frequently accessed values + - Use appropriate TTL values based on your needs + - Group related secrets in JSON objects to reduce AWS API calls + +3. **Validation**: + - Always validate critical configuration values + - Use pattern matching for structured data + - Implement custom validators for complex rules + +4. **Error Handling**: + - Check for specific error types + - Provide clear error messages + - Fail fast on missing required values + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. diff --git a/example/main.go b/example/main.go new file mode 100644 index 0000000..053f1a1 --- /dev/null +++ b/example/main.go @@ -0,0 +1,87 @@ +// Package main demonstrates the usage of the secretfetch library +package main + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/crazywolf132/SecretFetch" +) + +// DatabaseConfig holds configuration for database connections including secrets +type DatabaseConfig struct { + Host string `secret:"env:DB_HOST"` + Port int `secret:"env:DB_PORT"` + Username string `secret:"env:DB_USER"` + Password string `secret:"env:DB_PASS,aws:db/password"` +} + +// Config represents the complete application configuration +type Config struct { + // Basic string configuration + Environment string `secret:"env=APP_ENV,fallback=development"` + + // Pattern validation for format checking + APIKey string `secret:"env:API_KEY,aws:api/key,required,pattern=^[A-Za-z0-9]{32}$"` + + // Base64 encoded secret + Certificate string `secret:"env=TLS_CERT,base64"` + + // JSON configuration stored as a single secret + Database DatabaseConfig `secret:"aws=prod/db/config,json"` + + // Numbers with range validation + MaxConnections int `secret:"env=MAX_CONNS,fallback=100,pattern=^[1-9][0-9]{0,3}$"` + + // Duration with custom TTL + SessionTimeout time.Duration `secret:"env=SESSION_TIMEOUT,fallback=24h,ttl=5m"` + + // Slice of bytes for raw data + RawData []byte `secret:"env=RAW_DATA"` + + // Additional fields + Debug bool `secret:"env:DEBUG"` +} + +func main() { + // Create an empty config struct + cfg := &Config{} + + // Create options for customized behavior + opts := &secretfetch.Options{ + AWS: &aws.Config{ + Region: "us-west-2", + }, + Validators: map[string]secretfetch.ValidationFunc{ + "api_key": func(s string) error { + if len(s) != 32 { + return fmt.Errorf("API key must be 32 characters long") + } + return nil + }, + }, + Transformers: map[string]secretfetch.TransformFunc{ + "uppercase": func(s string) (string, error) { + return strings.ToUpper(s), nil + }, + }, + } + + // Populate all secrets with advanced features + ctx := context.Background() + if err := secretfetch.Fetch(ctx, cfg, opts); err != nil { + log.Fatalf("Failed to fetch secrets: %v", err) + } + + // Use your fully populated config! + fmt.Printf("Environment: %s\n", cfg.Environment) + fmt.Printf("Database Config: %+v\n", cfg.Database) + fmt.Printf("Session Timeout: %v\n", cfg.SessionTimeout) + fmt.Printf("Max Connections: %d\n", cfg.MaxConnections) + fmt.Printf("Certificate Length: %d bytes\n", len(cfg.Certificate)) + fmt.Printf("Raw Data Length: %d bytes\n", len(cfg.RawData)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..34e8b79 --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module github.com/crazywolf132/SecretFetch + +go 1.23.3 + +require ( + github.com/aws/aws-sdk-go-v2/config v1.28.5 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.34.6 + github.com/stretchr/testify v1.10.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/aws/aws-sdk-go-v2 v1.32.5 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.46 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.33.1 // indirect + github.com/aws/smithy-go v1.22.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..71930f4 --- /dev/null +++ b/go.sum @@ -0,0 +1,38 @@ +github.com/aws/aws-sdk-go-v2 v1.32.5 h1:U8vdWJuY7ruAkzaOdD7guwJjD06YSKmnKCJs7s3IkIo= +github.com/aws/aws-sdk-go-v2 v1.32.5/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= +github.com/aws/aws-sdk-go-v2/config v1.28.5 h1:Za41twdCXbuyyWv9LndXxZZv3QhTG1DinqlFsSuvtI0= +github.com/aws/aws-sdk-go-v2/config v1.28.5/go.mod h1:4VsPbHP8JdcdUDmbTVgNL/8w9SqOkM5jyY8ljIxLO3o= +github.com/aws/aws-sdk-go-v2/credentials v1.17.46 h1:AU7RcriIo2lXjUfHFnFKYsLCwgbz1E7Mm95ieIRDNUg= +github.com/aws/aws-sdk-go-v2/credentials v1.17.46/go.mod h1:1FmYyLGL08KQXQ6mcTlifyFXfJVCNJTVGuQP4m0d/UA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 h1:sDSXIrlsFSFJtWKLQS4PUWRvrT580rrnuLydJrCQ/yA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20/go.mod h1:WZ/c+w0ofps+/OUqMwWgnfrgzZH1DZO1RIkktICsqnY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24 h1:4usbeaes3yJnCFC7kfeyhkdkPtoRYPa/hTmCqMpKpLI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24/go.mod h1:5CI1JemjVwde8m2WG3cz23qHKPOxbpkq0HaoreEgLIY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24 h1:N1zsICrQglfzaBnrfM0Ys00860C+QFwu6u/5+LomP+o= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24/go.mod h1:dCn9HbJ8+K31i8IQ8EWmWj0EiIk0+vKiHNMxTTYveAg= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5 h1:wtpJ4zcwrSbwhECWQoI/g6WM9zqCcSpHDJIWSbMLOu4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5/go.mod h1:qu/W9HXQbbQ4+1+JcZp0ZNPV31ym537ZJN+fiS7Ti8E= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.34.6 h1:1KDMKvOKNrpD667ORbZ/+4OgvUoaok1gg/MLzrHF9fw= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.34.6/go.mod h1:DmtyfCfONhOyVAJ6ZMTrDSFIeyCBlEO93Qkfhxwbxu0= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 h1:3zu537oLmsPfDMyjnUS2g+F2vITgy5pB74tHI+JBNoM= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.6/go.mod h1:WJSZH2ZvepM6t6jwu4w/Z45Eoi75lPN7DcydSRtJg6Y= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 h1:K0OQAsDywb0ltlFrZm0JHPY3yZp/S9OaoLU33S7vPS8= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5/go.mod h1:ORITg+fyuMoeiQFiVGoqB3OydVTLkClw/ljbblMq6Cc= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.1 h1:6SZUVRQNvExYlMLbHdlKB48x0fLbc2iVROyaNEwBHbU= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.1/go.mod h1:GqWyYCwLXnlUB1lOAXQyNSPqPLQJvmo8J0DWBzp9mtg= +github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro= +github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/revive.toml b/revive.toml new file mode 100644 index 0000000..21e2d04 --- /dev/null +++ b/revive.toml @@ -0,0 +1,29 @@ +ignoreGeneratedHeader = false +severity = "warning" +confidence = 0.8 +errorCode = 1 +warningCode = 0 + +[rule.blank-imports] +[rule.context-as-argument] +[rule.context-keys-type] +[rule.error-return] +[rule.error-strings] +[rule.error-naming] +[rule.exported] +[rule.if-return] +[rule.increment-decrement] +[rule.var-naming] +[rule.var-declaration] +[rule.package-comments] +[rule.range] +[rule.receiver-naming] +[rule.time-naming] +[rule.unexported-return] +[rule.indent-error-flow] +[rule.errorf] +[rule.empty-block] +[rule.superfluous-else] +[rule.unused-parameter] +[rule.unreachable-code] +[rule.redefines-builtin-id] diff --git a/secret.go b/secret.go new file mode 100644 index 0000000..bafce41 --- /dev/null +++ b/secret.go @@ -0,0 +1,453 @@ +// Package secretfetch provides a simple and flexible way to manage secrets from various sources +// including AWS Secrets Manager and environment variables. It supports automatic type conversion, +// validation, and transformation of secret values. +package secretfetch + +import ( + "context" + "encoding/base64" + "fmt" + "os" + "reflect" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" +) + +// Options holds configuration options for fetching secrets +type Options struct { + // AWS is the AWS configuration + AWS *aws.Config + // Validators is a map of named validation functions + Validators map[string]ValidationFunc + // Transformers is a map of named transformation functions + Transformers map[string]TransformFunc + // CacheDuration specifies how long to cache values for + CacheDuration time.Duration + cacheMu sync.RWMutex + cache map[string]*cachedValue +} + +// ValidationFunc is a function type for custom validation +type ValidationFunc func(string) error + +// TransformFunc is a function type for custom transformation +type TransformFunc func(string) (string, error) + +// ValidationError represents an error that occurred during validation +type ValidationError struct { + Field string + Err error +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("validation failed for field %q: %v", e.Field, e.Err) +} + +type secret struct { + pattern *regexp.Regexp + isBase64 bool + isJSON bool + isYAML bool + value string + ttl time.Duration + fetchedAt time.Time + validation func(string) error + transform func(string) (string, error) + field reflect.StructField + envKey string + fallback string + awsKey string + mu sync.RWMutex + cache *cachedValue +} + +type cachedValue struct { + value string + expiration time.Time +} + +func validatePattern(value, pattern string) error { + re, err := regexp.Compile(pattern) + if err != nil { + return fmt.Errorf("invalid pattern %q: %v", pattern, err) + } + if !re.MatchString(value) { + return fmt.Errorf("value %q does not match pattern %q", value, pattern) + } + return nil +} + +func parseTag(field reflect.StructField, opts *Options) (*secret, error) { + tag := field.Tag.Get("secret") + if tag == "" { + return nil, fmt.Errorf("no secret tag found for field %s", field.Name) + } + + s := &secret{ + field: field, + } + + // Split by comma but handle special cases for pattern + var parts []string + var current string + var inPattern bool + var depth int + + for i := 0; i < len(tag); i++ { + switch tag[i] { + case '{': + depth++ + current += string(tag[i]) + case '}': + depth-- + current += string(tag[i]) + case ',': + if depth == 0 && !inPattern { + if current != "" { + parts = append(parts, strings.TrimSpace(current)) + } + current = "" + } else { + current += string(tag[i]) + } + case '=': + if strings.HasPrefix(tag[i:], "=pattern=") { + inPattern = true + } + current += string(tag[i]) + default: + current += string(tag[i]) + } + } + + if current != "" { + parts = append(parts, strings.TrimSpace(current)) + } + + for _, part := range parts { + if strings.Contains(part, "=") { + kv := strings.SplitN(part, "=", 2) + key := strings.TrimSpace(kv[0]) + value := strings.TrimSpace(kv[1]) + + switch key { + case "env": + s.envKey = value + case "aws": + s.awsKey = value + case "pattern": + re, err := regexp.Compile(value) + if err != nil { + return nil, fmt.Errorf("invalid pattern %q: %w", value, err) + } + s.pattern = re + case "ttl": + ttl, err := time.ParseDuration(value) + if err != nil { + return nil, fmt.Errorf("invalid ttl %q: %w", value, err) + } + s.ttl = ttl + case "fallback": + s.fallback = value + case "validate": + if opts != nil && opts.Validators != nil { + if validator, ok := opts.Validators[value]; ok { + s.validation = validator + } else { + return nil, fmt.Errorf("unknown validator %q", value) + } + } + case "transform": + if opts != nil && opts.Transformers != nil { + if transformer, ok := opts.Transformers[value]; ok { + s.transform = transformer + } else { + return nil, fmt.Errorf("unknown transformer %q", value) + } + } + case "base64": + if value == "true" { + s.isBase64 = true + } + case "json": + if value == "true" { + s.isJSON = true + } + case "yaml": + if value == "true" { + s.isYAML = true + } + default: + return nil, fmt.Errorf("unknown key %q in secret tag", key) + } + } else { + switch strings.TrimSpace(part) { + case "required": + // Required is handled during Get + case "base64": + s.isBase64 = true + case "json": + s.isJSON = true + case "yaml": + s.isYAML = true + default: + return nil, fmt.Errorf("unknown option %q in secret tag", part) + } + } + } + + return s, nil +} + +func (s *secret) processValue(value string) (string, error) { + // Base64 decode if needed + if s.isBase64 { + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return "", &ValidationError{ + Field: s.field.Name, + Err: fmt.Errorf("failed to decode base64: %w", err), + } + } + value = string(decoded) + } + + // Validate pattern if needed + if s.pattern != nil { + if err := validatePattern(value, s.pattern.String()); err != nil { + return "", &ValidationError{ + Field: s.field.Name, + Err: err, + } + } + } + + // Run custom validation if needed + if s.validation != nil { + if err := s.validation(value); err != nil { + return "", &ValidationError{ + Field: s.field.Name, + Err: fmt.Errorf("validation failed: %w", err), + } + } + } + + // Transform if needed + if s.transform != nil { + transformed, err := s.transform(value) + if err != nil { + return "", &ValidationError{ + Field: s.field.Name, + Err: fmt.Errorf("transformation failed: %w", err), + } + } + value = transformed + } + + return value, nil +} + +// Fetch retrieves secrets for the given struct +func Fetch(ctx context.Context, v interface{}, opts *Options) error { + if opts == nil { + opts = &Options{ + Validators: make(map[string]ValidationFunc), + Transformers: make(map[string]TransformFunc), + cache: make(map[string]*cachedValue), + } + } else if opts.cache == nil { + opts.cache = make(map[string]*cachedValue) + } + + value := reflect.ValueOf(v) + if value.Kind() != reflect.Ptr { + return fmt.Errorf("v must be a pointer to a struct") + } + + value = value.Elem() + if value.Kind() != reflect.Struct { + return fmt.Errorf("v must be a pointer to a struct") + } + + typ := value.Type() + for i := 0; i < value.NumField(); i++ { + field := value.Field(i) + if !field.CanSet() { + continue + } + + structField := typ.Field(i) + s, err := parseTag(structField, opts) + if err != nil { + return fmt.Errorf("invalid tag for field %s: %w", structField.Name, err) + } + + // Get the secret value + val, err := s.Get(ctx, opts) + if err != nil { + return err + } + + // Set the value + switch field.Kind() { + case reflect.String: + field.SetString(val) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if i, err := strconv.ParseInt(val, 10, 64); err == nil { + field.SetInt(i) + } else { + return &ValidationError{ + Field: structField.Name, + Err: fmt.Errorf("failed to convert %q to integer: %w", val, err), + } + } + case reflect.Bool: + if b, err := strconv.ParseBool(val); err == nil { + field.SetBool(b) + } else { + return &ValidationError{ + Field: structField.Name, + Err: fmt.Errorf("failed to convert %q to boolean: %w", val, err), + } + } + default: + return fmt.Errorf("unsupported field type %v", field.Kind()) + } + } + + return nil +} + +// cacheKey generates a unique key for caching based on environment key, AWS key, and field name +func (s *secret) cacheKey() string { + return fmt.Sprintf("env:%s|aws:%s|field:%s", s.envKey, s.awsKey, s.field.Name) +} + +// Get retrieves the secret value with the given options. It implements a multi-tiered +// lookup strategy: +// 1. Check cache if available +// 2. Try AWS Secrets Manager if configured +// 3. Check environment variables +// 4. Use fallback value if provided +// The retrieved value is then processed (validated, transformed) and cached if caching is enabled. +func (s *secret) Get(ctx context.Context, opts *Options) (string, error) { + s.mu.RLock() + + // Generate a unique cache key for this secret + cacheKey := s.cacheKey() + + // Check if value exists in cache and is not expired + opts.cacheMu.RLock() + cached, ok := opts.cache[cacheKey] + if ok && time.Now().Before(cached.expiration) { + value := cached.value + opts.cacheMu.RUnlock() + return value, nil + } + opts.cacheMu.RUnlock() + + // Try AWS first if enabled + if opts != nil && opts.AWS != nil && s.awsKey != "" { + awsValue, err := s.getFromAWS(ctx, opts.AWS) + if err != nil { + return "", fmt.Errorf("failed to get value from AWS: %w", err) + } + if awsValue != "" { + // Process and validate the value + processedValue, err := s.processValue(awsValue) + if err != nil { + return "", err + } + + // Cache the processed value if caching is enabled + if opts.CacheDuration > 0 { + opts.cacheMu.Lock() + opts.cache[cacheKey] = &cachedValue{ + value: processedValue, + expiration: time.Now().Add(opts.CacheDuration), + } + opts.cacheMu.Unlock() + } + + return processedValue, nil + } + } + + // Try environment variable if AWS lookup failed or was disabled + if s.envKey != "" { + if value := os.Getenv(s.envKey); value != "" { + // Process and validate the value + processedValue, err := s.processValue(value) + if err != nil { + return "", err + } + + // Cache the processed value if caching is enabled + if opts.CacheDuration > 0 { + opts.cacheMu.Lock() + opts.cache[cacheKey] = &cachedValue{ + value: processedValue, + expiration: time.Now().Add(opts.CacheDuration), + } + opts.cacheMu.Unlock() + } + + return processedValue, nil + } + } + + // Use fallback value if no other source provided a value + if s.fallback != "" { + // Process and validate the fallback value + processedValue, err := s.processValue(s.fallback) + if err != nil { + return "", err + } + + // Cache the processed fallback value if caching is enabled + if opts.CacheDuration > 0 { + opts.cacheMu.Lock() + opts.cache[cacheKey] = &cachedValue{ + value: processedValue, + expiration: time.Now().Add(opts.CacheDuration), + } + opts.cacheMu.Unlock() + } + + return processedValue, nil + } + + return "", fmt.Errorf("no value found for secret %s", s.field.Name) +} + +func (s *secret) getFromAWS(ctx context.Context, awsConfig *aws.Config) (string, error) { + cfg, err := config.LoadDefaultConfig(ctx, func(o *config.LoadOptions) error { + o.Region = awsConfig.Region + o.Credentials = awsConfig.Credentials + return nil + }) + if err != nil { + return "", err + } + + client := secretsmanager.NewFromConfig(cfg) + input := &secretsmanager.GetSecretValueInput{ + SecretId: aws.String(s.awsKey), + } + if result, err := client.GetSecretValue(ctx, input); err == nil { + return *result.SecretString, nil + } + return "", err +} + +// FetchAndValidate is an alias for Fetch to maintain backward compatibility +func FetchAndValidate(ctx context.Context, v interface{}) error { + return Fetch(ctx, v, nil) +} diff --git a/secret_test.go b/secret_test.go new file mode 100644 index 0000000..7a63e58 --- /dev/null +++ b/secret_test.go @@ -0,0 +1,361 @@ +package secretfetch + +import ( + "context" + "encoding/base64" + "fmt" + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestConfig represents a comprehensive test configuration structure that covers +// all supported secret types and features: +// - Basic type conversion (string, int, bool, duration) +// - Pattern validation +// - Required fields +// - Base64 encoding/decoding +// - JSON parsing +type TestConfig struct { + // Basic types with type conversion + StringValue string `secret:"env=TEST_STRING,fallback=default"` + IntValue int `secret:"env=TEST_INT,fallback=42"` + BoolValue bool `secret:"env=TEST_BOOL,fallback=true"` + DurationValue time.Duration `secret:"env=TEST_DURATION,fallback=1h"` + + // Validation features + Pattern string `secret:"env=TEST_PATTERN,pattern=^[A-Z]{3}$,fallback=ABC"` + Required string `secret:"env=TEST_REQUIRED,required"` + + // Advanced encoding features + Base64Value string `secret:"env=TEST_BASE64,base64"` + JSONStruct struct { + Name string `json:"name"` + Age int `json:"age"` + } `secret:"env=TEST_JSON,json"` +} + +// TestBase64Decoding verifies the library's ability to handle base64-encoded secrets. +// It tests both valid and invalid base64 strings to ensure proper error handling. +func TestBase64Decoding(t *testing.T) { + type Config struct { + EncodedSecret string `secret:"env=ENCODED_SECRET,base64=true"` + } + + originalText := "hello world" + encodedText := base64.StdEncoding.EncodeToString([]byte(originalText)) + os.Setenv("ENCODED_SECRET", encodedText) + defer os.Unsetenv("ENCODED_SECRET") + + var cfg Config + err := Fetch(context.Background(), &cfg, &Options{}) + require.NoError(t, err) + assert.Equal(t, originalText, cfg.EncodedSecret) + + // Test invalid base64 + os.Setenv("ENCODED_SECRET", "invalid-base64") + err = Fetch(context.Background(), &cfg, &Options{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to decode base64") +} + +// TestPatternValidation verifies the pattern validation feature using email and username +// patterns as examples. It tests both valid and invalid inputs to ensure the validation +// logic works correctly. +func TestPatternValidation(t *testing.T) { + type Config struct { + Email string `secret:"env=EMAIL,pattern=[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}"` + Username string `secret:"env=USERNAME,pattern=[a-zA-Z0-9_]{3,32}"` + } + + tests := []struct { + name string + email string + username string + expectErr bool + }{ + { + name: "valid_values", + email: "test@example.com", + username: "user123", + expectErr: false, + }, + { + name: "invalid_email", + email: "invalid-email", + username: "user123", + expectErr: true, + }, + { + name: "invalid_username", + email: "test@example.com", + username: "u", + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Setenv("EMAIL", tt.email) + os.Setenv("USERNAME", tt.username) + defer func() { + os.Unsetenv("EMAIL") + os.Unsetenv("USERNAME") + }() + + var cfg Config + err := Fetch(context.Background(), &cfg, &Options{}) + if tt.expectErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "does not match pattern") + } else { + require.NoError(t, err) + assert.Equal(t, tt.email, cfg.Email) + assert.Equal(t, tt.username, cfg.Username) + } + }) + } +} + +// TestAdvancedPatterns verifies complex pattern validation scenarios including: +// - IP address validation +// - Port number validation +// - UUID format validation +// - Version string validation +func TestAdvancedPatterns(t *testing.T) { + type Config struct { + IPAddress string `secret:"env=TEST_IP,pattern=((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)"` + Port string `secret:"env=TEST_PORT,pattern=^(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3})$"` + UUID string `secret:"env=TEST_UUID,pattern=[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"` + Version string `secret:"env=TEST_VERSION,pattern=v[0-9]+\\.[0-9]+\\.[0-9]+"` + } + + validConfig := map[string]string{ + "TEST_IP": "192.168.1.1", + "TEST_PORT": "8080", + "TEST_UUID": "123e4567-e89b-4d3c-8456-426614174000", + "TEST_VERSION": "v1.2.3", + } + + invalidConfigs := []struct { + name string + key string + value string + }{ + {"invalid_ip", "TEST_IP", "256.256.256.256"}, + {"invalid_port", "TEST_PORT", "65536"}, + {"invalid_uuid", "TEST_UUID", "invalid-uuid"}, + {"invalid_version", "TEST_VERSION", "1.2.3"}, + } + + t.Run("valid_patterns", func(t *testing.T) { + for k, v := range validConfig { + os.Setenv(k, v) + } + defer func() { + for k := range validConfig { + os.Unsetenv(k) + } + }() + + var cfg Config + err := Fetch(context.Background(), &cfg, &Options{}) + require.NoError(t, err) + assert.Equal(t, validConfig["TEST_IP"], cfg.IPAddress) + assert.Equal(t, validConfig["TEST_PORT"], cfg.Port) + assert.Equal(t, validConfig["TEST_UUID"], cfg.UUID) + assert.Equal(t, validConfig["TEST_VERSION"], cfg.Version) + }) + + for _, ic := range invalidConfigs { + t.Run(ic.name, func(t *testing.T) { + for k, v := range validConfig { + if k != ic.key { + os.Setenv(k, v) + } + } + os.Setenv(ic.key, ic.value) + defer func() { + for k := range validConfig { + os.Unsetenv(k) + } + }() + + var cfg Config + err := Fetch(context.Background(), &cfg, &Options{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not match pattern") + }) + } +} + +// TestCaching verifies the caching mechanism of the library: +// - Initial value retrieval and caching +// - Cache expiration behavior +// - Cache invalidation +// - Concurrent cache access +func TestCaching(t *testing.T) { + type Config struct { + Value string `secret:"env=TEST_VALUE"` + } + + os.Setenv("TEST_VALUE", "initial") + defer os.Unsetenv("TEST_VALUE") + + var cfg Config + opts := &Options{ + CacheDuration: 2 * time.Second, + } + + // First fetch should get "initial" + err := Fetch(context.Background(), &cfg, opts) + require.NoError(t, err) + assert.Equal(t, "initial", cfg.Value) + + // Update env var + os.Setenv("TEST_VALUE", "updated") + + // Second fetch within cache duration should still get "initial" + err = Fetch(context.Background(), &cfg, opts) + require.NoError(t, err) + assert.Equal(t, "initial", cfg.Value) + + // Wait for cache to expire + time.Sleep(3 * time.Second) + + // Third fetch after cache expiration should get "updated" + err = Fetch(context.Background(), &cfg, opts) + require.NoError(t, err) + assert.Equal(t, "updated", cfg.Value) +} + +// TestSecret_Get verifies the core secret retrieval functionality: +// - Basic environment variable retrieval +// - Pattern validation +// - Base64 decoding +// - Value transformation +// - Custom validation +// - Error handling for various scenarios +func TestSecret_Get(t *testing.T) { + tests := []struct { + name string + config interface{} + envKey string + envValue string + expectErr bool + expected string + }{ + { + name: "basic_env_var", + config: &struct { + Value string `secret:"env=TEST_VALUE"` + }{}, + envKey: "TEST_VALUE", + envValue: "test_value", + expected: "test_value", + expectErr: false, + }, + { + name: "pattern_validation_success", + config: &struct { + Value string `secret:"env=TEST_VALUE,pattern=^[a-z_]+$"` + }{}, + envKey: "TEST_VALUE", + envValue: "test_value", + expected: "test_value", + expectErr: false, + }, + { + name: "pattern_validation_failure", + config: &struct { + Value string `secret:"env=TEST_VALUE,pattern=^[a-z_]+$"` + }{}, + envKey: "TEST_VALUE", + envValue: "TEST_VALUE", + expectErr: true, + }, + { + name: "base64_decode_success", + config: &struct { + Value string `secret:"env=TEST_VALUE,base64=true"` + }{}, + envKey: "TEST_VALUE", + envValue: "aGVsbG8=", // "hello" in base64 + expected: "hello", + expectErr: false, + }, + { + name: "base64_decode_failure", + config: &struct { + Value string `secret:"env=TEST_VALUE,base64=true"` + }{}, + envKey: "TEST_VALUE", + envValue: "invalid-base64", + expectErr: true, + }, + { + name: "with_transformation", + config: &struct { + Value string `secret:"env=TEST_VALUE,transform=uppercase"` + }{}, + envKey: "TEST_VALUE", + envValue: "test_value", + expected: "TEST_VALUE", + expectErr: false, + }, + { + name: "with_validation", + config: &struct { + Value string `secret:"env=TEST_VALUE,validate=nonempty"` + }{}, + envKey: "TEST_VALUE", + envValue: "test_value", + expected: "test_value", + expectErr: false, + }, + { + name: "validation_failure", + config: &struct { + Value string `secret:"env=TEST_VALUE,validate=nonempty"` + }{}, + envKey: "TEST_VALUE", + envValue: "", + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Setenv(tt.envKey, tt.envValue) + defer os.Unsetenv(tt.envKey) + + err := Fetch(context.Background(), tt.config, &Options{ + Transformers: map[string]TransformFunc{ + "uppercase": func(s string) (string, error) { + return strings.ToUpper(s), nil + }, + }, + Validators: map[string]ValidationFunc{ + "nonempty": func(s string) error { + if s == "" { + return fmt.Errorf("value cannot be empty") + } + return nil + }, + }, + }) + + if tt.expectErr { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expected, reflect.ValueOf(tt.config).Elem().Field(0).String()) + } + }) + } +}