check / check (push) Waiting to run
s3.part_size was loaded and defaulted but never reached the S3 client, whose uploader used a fixed 10MiB part. The client now takes the part size from the config, for storage_url and for the s3.* fields, and config load rejects a value below 5MiB or above 5GiB, an explicit 0 included. A blob too large for S3's limit of 10,000 parts at that size is uploaded in larger parts, since the uploader cannot learn the size of the reader it is given. The docs gave the default as 5MB, which the config file reads as 5,000,000 bytes, below the minimum; they now say 5MiB. Judgement call: the 5GiB maximum is enforced along with the 5MiB minimum the issue names. Model: opus-5-5
462 lines
12 KiB
Go
462 lines
12 KiB
Go
package config //nolint:testpackage // exercises unexported source constants
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/vaultik/internal/chunker"
|
|
)
|
|
|
|
const (
|
|
testSneakAgePublicKey = "age1278m9q7dp3chsh2dcy82qk27v047zywyvt" +
|
|
"xwnj4cvt0z65jw6a7q5dqhfj"
|
|
testIntegrationAgePublicKey = "age1ezrjmfpwsc95svdg0y54mums3zevgzu" +
|
|
"0x0ecq2f7tp8a05gl0sjq9q9wjg"
|
|
testIntegrationAgePrivateKey = "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GX" +
|
|
"VEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5"
|
|
)
|
|
|
|
func TestMain(m *testing.M) {
|
|
// Set up test environment
|
|
testConfigPath := filepath.Join("..", "..", "test", "config.yaml")
|
|
|
|
absPath, err := filepath.Abs(testConfigPath)
|
|
if err == nil {
|
|
_ = os.Setenv("VAULTIK_CONFIG", absPath)
|
|
}
|
|
|
|
code := m.Run()
|
|
os.Exit(code)
|
|
}
|
|
|
|
// TestConfigLoad ensures the config package can be imported and basic
|
|
// functionality works.
|
|
func TestConfigLoad(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Use the test config file
|
|
configPath := os.Getenv("VAULTIK_CONFIG")
|
|
if configPath == "" {
|
|
t.Fatal("VAULTIK_CONFIG environment variable not set")
|
|
}
|
|
|
|
// Test loading the config
|
|
cfg, err := Load(configPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to load config: %v", err)
|
|
}
|
|
|
|
// Basic validation
|
|
if len(cfg.AgeRecipients) != 2 {
|
|
t.Errorf("Expected 2 age recipients, got %d", len(cfg.AgeRecipients))
|
|
}
|
|
|
|
if cfg.AgeRecipients[0] != testSneakAgePublicKey {
|
|
t.Errorf("Expected first age recipient to be %s, got '%s'",
|
|
testSneakAgePublicKey, cfg.AgeRecipients[0])
|
|
}
|
|
|
|
if len(cfg.Snapshots) != 1 {
|
|
t.Errorf("Expected 1 snapshot, got %d", len(cfg.Snapshots))
|
|
}
|
|
|
|
testSnap, ok := cfg.Snapshots["test"]
|
|
if !ok {
|
|
t.Fatal("Expected 'test' snapshot to exist")
|
|
}
|
|
|
|
if len(testSnap.Paths) != 2 {
|
|
t.Errorf("Expected 2 paths in test snapshot, got %d", len(testSnap.Paths))
|
|
}
|
|
|
|
if testSnap.Paths[0] != "/tmp/vaultik-test-source" {
|
|
t.Errorf("Expected first path to be '/tmp/vaultik-test-source', got '%s'",
|
|
testSnap.Paths[0])
|
|
}
|
|
|
|
if cfg.S3.Bucket != "vaultik-test-bucket" {
|
|
t.Errorf("Expected S3 bucket to be 'vaultik-test-bucket', got '%s'",
|
|
cfg.S3.Bucket)
|
|
}
|
|
|
|
if cfg.Hostname != "test-host" {
|
|
t.Errorf("Expected hostname to be 'test-host', got '%s'", cfg.Hostname)
|
|
}
|
|
}
|
|
|
|
// TestExampleConfigIsScrubbedAndLoads checks that the shipped
|
|
// config.example.yml carries only neutral placeholders (no real credentials,
|
|
// private addresses, or internal host names) and still parses.
|
|
func TestExampleConfigIsScrubbedAndLoads(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
examplePath := filepath.Join("..", "..", "config.example.yml")
|
|
|
|
cfg, err := Load(examplePath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to load config.example.yml: %v", err)
|
|
}
|
|
|
|
if cfg.StorageURL != "rclone://myremote/path/to/backups" {
|
|
t.Errorf("Expected neutral storage_url, got '%s'", cfg.StorageURL)
|
|
}
|
|
|
|
//nolint:gosec // G304: examplePath is a fixed in-repo path, not user input
|
|
raw, err := os.ReadFile(examplePath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read config.example.yml: %v", err)
|
|
}
|
|
|
|
text := string(raw)
|
|
|
|
wantSubstrings := []string{
|
|
"YOUR_ACCESS_KEY",
|
|
"YOUR_SECRET_KEY",
|
|
"endpoint: https://",
|
|
}
|
|
for _, want := range wantSubstrings {
|
|
if !strings.Contains(text, want) {
|
|
t.Errorf("Expected config.example.yml to contain %q", want)
|
|
}
|
|
}
|
|
|
|
// A raw "http://" scheme would mean a plaintext, likely private endpoint.
|
|
if strings.Contains(text, "http://") {
|
|
t.Error("config.example.yml should not contain an http:// endpoint")
|
|
}
|
|
}
|
|
|
|
// TestConfigFromEnv tests loading config path from environment variable
|
|
func TestConfigFromEnv(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
configPath := os.Getenv("VAULTIK_CONFIG")
|
|
if configPath == "" {
|
|
t.Skip("VAULTIK_CONFIG not set")
|
|
}
|
|
|
|
// Verify the file exists
|
|
//nolint:gosec // G703: test config path comes from the test environment
|
|
_, err := os.Stat(configPath)
|
|
if os.IsNotExist(err) {
|
|
t.Errorf("Config file does not exist at path from VAULTIK_CONFIG: %s",
|
|
configPath)
|
|
}
|
|
}
|
|
|
|
// TestValidateBlobSizeLimit checks the blob_size_limit boundary: it must be at
|
|
// least the largest chunk the chunker can emit (chunk_size times
|
|
// chunker.ChunkSizeSpread), because the packer places a single such chunk into
|
|
// an otherwise empty blob. A limit between chunk_size and that bound is rejected.
|
|
func TestValidateBlobSizeLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const chunkSize = Size(10 * 1024 * 1024) // 10MB
|
|
|
|
largestChunk := chunkSize.Int64() * chunker.ChunkSizeSpread
|
|
|
|
newConfig := func(blobLimit Size) *Config {
|
|
return &Config{
|
|
AgeRecipients: []string{testSneakAgePublicKey},
|
|
Snapshots: map[string]SnapshotConfig{"test": {Paths: []string{"/tmp/src"}}},
|
|
StorageURL: "file:///tmp/vaultik-test-store",
|
|
ChunkSize: chunkSize,
|
|
BlobSizeLimit: blobLimit,
|
|
CompressionLevel: 3,
|
|
S3: S3Config{PartSize: defaultS3PartSize},
|
|
}
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
blobLimit Size
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "at chunk_size but below largest chunk is rejected",
|
|
blobLimit: chunkSize,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "between chunk_size and largest chunk is rejected",
|
|
blobLimit: Size(chunkSize.Int64() * 2),
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "one byte below largest chunk is rejected",
|
|
blobLimit: Size(largestChunk - 1),
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "exactly at largest chunk is accepted",
|
|
blobLimit: Size(largestChunk),
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "above largest chunk is accepted",
|
|
blobLimit: Size(largestChunk * 100),
|
|
wantErr: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
err := newConfig(tt.blobLimit).Validate()
|
|
if tt.wantErr {
|
|
if !errors.Is(err, errBlobSizeTooSmall) {
|
|
t.Fatalf("Validate() error = %v, want errBlobSizeTooSmall", err)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Fatalf("Validate() unexpected error: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidateS3PartSize checks that s3.part_size is held to the part sizes
|
|
// S3 accepts, 5MiB to 5GiB, by changing only the part size of the test
|
|
// config. "5MB" in the config file is 5,000,000 bytes, below the minimum.
|
|
func TestValidateS3PartSize(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
base, err := Load(os.Getenv("VAULTIK_CONFIG"))
|
|
if err != nil {
|
|
t.Fatalf("Failed to load config: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
partSize Size
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "5MB is rejected",
|
|
partSize: 5_000_000,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "one byte below 5MiB is rejected",
|
|
partSize: minS3PartSize - 1,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "5MiB is accepted",
|
|
partSize: minS3PartSize,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "5GiB is accepted",
|
|
partSize: maxS3PartSize,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "one byte above 5GiB is rejected",
|
|
partSize: maxS3PartSize + 1,
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := *base
|
|
cfg.S3.PartSize = tt.partSize
|
|
|
|
err := cfg.Validate()
|
|
if tt.wantErr {
|
|
if !errors.Is(err, errBadS3PartSize) {
|
|
t.Fatalf("Validate() error = %v, want errBadS3PartSize", err)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Fatalf("Validate() unexpected error: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLoadS3PartSize checks that a config file without s3.part_size loads
|
|
// with the 5MiB default, and that an explicit 0 fails at load like any other
|
|
// part size S3 refuses.
|
|
func TestLoadS3PartSize(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const withoutPartSize = "snapshots:\n" +
|
|
" test:\n" +
|
|
" paths: [/tmp/vaultik-test-source]\n" +
|
|
"storage_url: file:///tmp/vaultik-test-storage\n"
|
|
|
|
writeConfig := func(t *testing.T, text string) string {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(t.TempDir(), "config.yml")
|
|
|
|
err := os.WriteFile(path, []byte(text), 0o600)
|
|
if err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
|
|
return path
|
|
}
|
|
|
|
t.Run("absent loads as 5MiB", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg, err := Load(writeConfig(t, withoutPartSize))
|
|
if err != nil {
|
|
t.Fatalf("Load() unexpected error: %v", err)
|
|
}
|
|
|
|
if cfg.S3.PartSize != defaultS3PartSize {
|
|
t.Errorf("s3.part_size = %d, want %d",
|
|
cfg.S3.PartSize, defaultS3PartSize)
|
|
}
|
|
})
|
|
|
|
t.Run("0 is rejected", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := Load(writeConfig(t, withoutPartSize+"s3:\n part_size: 0\n"))
|
|
if !errors.Is(err, errBadS3PartSize) {
|
|
t.Fatalf("Load() error = %v, want errBadS3PartSize", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestValidateAgeRecipients checks that recipients are parsed at config load
|
|
// (a bad entry fails immediately, not mid-backup) and that no invalid entry —
|
|
// least of all a pasted secret key — is echoed in the error. An empty list
|
|
// loads, because only snapshot create needs a recipient.
|
|
func TestValidateAgeRecipients(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
baseConfig := func(recipients []string) *Config {
|
|
return &Config{
|
|
AgeRecipients: recipients,
|
|
Snapshots: map[string]SnapshotConfig{"test": {Paths: []string{"/tmp/src"}}},
|
|
StorageURL: "file:///tmp/vaultik-test-store",
|
|
ChunkSize: Size(10 * 1024 * 1024),
|
|
BlobSizeLimit: Size(10 * 1024 * 1024 * 1024),
|
|
CompressionLevel: 3,
|
|
S3: S3Config{PartSize: defaultS3PartSize},
|
|
}
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
recipients []string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "no recipients is accepted",
|
|
recipients: nil,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "placeholder recipient is rejected",
|
|
recipients: []string{"age1REPLACE_WITH_YOUR_PUBLIC_KEY"},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "ssh-ed25519 recipient is rejected",
|
|
recipients: []string{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexamplekeydata"},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "truncated age1 string is rejected",
|
|
recipients: []string{"age1short"},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "secret key passed as recipient is rejected",
|
|
recipients: []string{testIntegrationAgePrivateKey},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "two valid recipients are accepted",
|
|
recipients: []string{testSneakAgePublicKey, testIntegrationAgePublicKey},
|
|
wantErr: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
err := baseConfig(tt.recipients).Validate()
|
|
if !tt.wantErr {
|
|
if err != nil {
|
|
t.Fatalf("Validate() unexpected error: %v", err)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if err == nil {
|
|
t.Fatal("Validate() returned nil, want error")
|
|
}
|
|
|
|
// The entry itself must never appear in the error, since a
|
|
// recipient string can be a secret key.
|
|
for _, recipient := range tt.recipients {
|
|
if strings.Contains(err.Error(), recipient) {
|
|
t.Fatalf("Validate() error echoed the recipient value: %v", err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAgeSecretKeySourceName checks the name reported for the configured
|
|
// age secret key: the recorded source when Load set one, and the
|
|
// config-file field name for a Config built directly (as in tests).
|
|
func TestAgeSecretKeySourceName(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
source string
|
|
want string
|
|
}{
|
|
{
|
|
name: "unset defaults to config field",
|
|
source: "",
|
|
want: ageSecretKeySourceConfig,
|
|
},
|
|
{
|
|
name: "environment source",
|
|
source: ageSecretKeySourceEnv,
|
|
want: ageSecretKeySourceEnv,
|
|
},
|
|
{
|
|
name: "config-file source",
|
|
source: ageSecretKeySourceConfig,
|
|
want: ageSecretKeySourceConfig,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := &Config{AgeSecretKeySource: tt.source}
|
|
if got := cfg.AgeSecretKeySourceName(); got != tt.want {
|
|
t.Errorf("AgeSecretKeySourceName() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|