package config //nolint:testpackage // exercises unexported source constants import ( "errors" "os" "path/filepath" "strings" "testing" "sneak.berlin/go/vaultik/internal/chunker" "sneak.berlin/go/vaultik/internal/log" ) 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) } }) } } // loadReadableConfig writes configYAML to a file that others can read, // loads it, and returns what the logger wrote to stderr meanwhile. The // logger writes to the os.Stderr it finds when it is initialized, so // os.Stderr is pointed at a file first. Not parallel-safe: os.Stderr and // the logger are process-global. func loadReadableConfig(t *testing.T, configYAML string) string { t.Helper() dir := t.TempDir() configPath := filepath.Join(dir, "config.yml") stderrPath := filepath.Join(dir, "stderr") err := os.WriteFile(configPath, []byte(configYAML), 0o600) if err != nil { t.Fatalf("writing config: %v", err) } //nolint:gosec // G302: the test needs a config file others can read err = os.Chmod(configPath, 0o644) if err != nil { t.Fatalf("chmod config: %v", err) } stderrFile, err := os.Create(stderrPath) //nolint:gosec // G304: test temp path if err != nil { t.Fatalf("creating stderr file: %v", err) } previous := os.Stderr os.Stderr = stderrFile log.Initialize(log.Config{}) _, loadErr := Load(configPath) os.Stderr = previous log.Initialize(log.Config{}) _ = stderrFile.Close() if loadErr != nil { t.Fatalf("Load() error = %v", loadErr) } captured, err := os.ReadFile(stderrPath) //nolint:gosec // G304: test temp path if err != nil { t.Fatalf("reading stderr file: %v", err) } return string(captured) } // TestLoadWarnsReadableConfigWithoutS3Credentials checks that a config // file others can read, holding no S3 credentials, is warned about // without a claim that it holds them. // //nolint:paralleltest // loadReadableConfig replaces os.Stderr func TestLoadWarnsReadableConfigWithoutS3Credentials(t *testing.T) { stderr := loadReadableConfig(t, ` storage_url: file:///var/backups/vaultik snapshots: home: paths: - /home `) if !strings.Contains(stderr, "Config file is readable by others") { t.Errorf("expected a warning that the file is readable by others, got %q", stderr) } if strings.Contains(stderr, "S3 credentials") { t.Errorf("warning names S3 credentials the file does not set: %q", stderr) } } // TestLoadWarnsReadableConfigWithS3Credentials checks that a config file // others can read and that sets S3 credentials, as values or as ${ENV:...} // references, is warned about as one that may contain them. // //nolint:paralleltest // loadReadableConfig replaces os.Stderr func TestLoadWarnsReadableConfigWithS3Credentials(t *testing.T) { t.Setenv("VAULTIK_TEST_ACCESS_KEY_ID", "test-access-key") t.Setenv("VAULTIK_TEST_SECRET_ACCESS_KEY", "test-secret-key") configs := map[string]string{ "values": ` storage_url: s3://bucket/prefix?endpoint=s3.example.com s3: access_key_id: test-access-key secret_access_key: test-secret-key snapshots: home: paths: - /home `, "references": ` storage_url: s3://bucket/prefix?endpoint=s3.example.com s3: access_key_id: ${ENV:VAULTIK_TEST_ACCESS_KEY_ID} secret_access_key: ${ENV:VAULTIK_TEST_SECRET_ACCESS_KEY} snapshots: home: paths: - /home `, } for name, configYAML := range configs { t.Run(name, func(t *testing.T) { stderr := loadReadableConfig(t, configYAML) if !strings.Contains(stderr, "Config file is readable by others and may contain S3 credentials") { t.Errorf("expected a warning naming the S3 credentials, got %q", stderr) } }) } }