check / check (push) Waiting to run
config set wrote every value as an unquoted YAML scalar, and config.Load reads the file through untyped YAML, so an access key 00112233 loaded as 38043 and a hostname 007 as 7. config set now looks the key up in config.Config by the fields' yaml tags. A string setting is tagged !!str, which the encoder quotes wherever YAML would read a number or a boolean. Other settings stay unquoted, so compression_level 9 is still a number. A value that is not valid UTF-8 stays untagged and is written as !!binary, which loads back unchanged. Judgement call: the type comes from reflection over config.Config. Model: opus-5-5
451 lines
11 KiB
Go
451 lines
11 KiB
Go
package cli //nolint:testpackage // exercises unexported yamlPathGet/yamlPathSet
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
"sneak.berlin/go/vaultik/internal/config"
|
|
"sneak.berlin/go/vaultik/internal/ui"
|
|
)
|
|
|
|
// TestDefaultConfigTemplateParses ensures the init template is valid YAML
|
|
// that unmarshals into the Config struct with the expected snapshots.
|
|
func TestDefaultConfigTemplateParses(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var cfg config.Config
|
|
|
|
err := yaml.Unmarshal([]byte(defaultConfigTemplate), &cfg)
|
|
if err != nil {
|
|
t.Fatalf("default config template is not valid YAML: %v", err)
|
|
}
|
|
|
|
// A placeholder recipient would fail config.Load, so the template
|
|
// leaves the list empty.
|
|
if len(cfg.AgeRecipients) != 0 {
|
|
t.Errorf("expected no age recipients, got %d", len(cfg.AgeRecipients))
|
|
}
|
|
|
|
home, ok := cfg.Snapshots["home"]
|
|
if !ok {
|
|
t.Fatal("expected 'home' snapshot in default config")
|
|
}
|
|
|
|
if len(home.Paths) == 0 {
|
|
t.Error("home snapshot should have at least one path")
|
|
}
|
|
|
|
if len(home.Exclude) == 0 {
|
|
t.Error("home snapshot should have exclude patterns")
|
|
}
|
|
|
|
apps, ok := cfg.Snapshots["apps"]
|
|
if !ok {
|
|
t.Fatal("expected 'apps' snapshot in default config")
|
|
}
|
|
|
|
if len(apps.Paths) != 1 || apps.Paths[0] != "/Applications" {
|
|
t.Errorf("apps snapshot should back up /Applications, got %v", apps.Paths)
|
|
}
|
|
|
|
if len(apps.Exclude) == 0 {
|
|
t.Error("apps snapshot should have exclude patterns")
|
|
}
|
|
}
|
|
|
|
// TestConfigSetRecipientOnFreshConfig follows the README quickstart: on the
|
|
// file `config init` writes, `config set age_recipients.0` and
|
|
// `config set storage_url` give a config that loads with that recipient.
|
|
func TestConfigSetRecipientOnFreshConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const recipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj"
|
|
|
|
path := filepath.Join(t.TempDir(), "config.yml")
|
|
|
|
err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
|
|
if err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
|
|
out := ui.NewWithColor(&bytes.Buffer{}, false)
|
|
|
|
err = writeConfigSet(out, path, "age_recipients.0", recipient)
|
|
if err != nil {
|
|
t.Fatalf("config set age_recipients.0: %v", err)
|
|
}
|
|
|
|
err = writeConfigSet(out, path, "storage_url", "file:///mnt/backups")
|
|
if err != nil {
|
|
t.Fatalf("config set storage_url: %v", err)
|
|
}
|
|
|
|
cfg, err := config.Load(path)
|
|
if err != nil {
|
|
t.Fatalf("config.Load: %v", err)
|
|
}
|
|
|
|
if len(cfg.AgeRecipients) != 1 || cfg.AgeRecipients[0] != recipient {
|
|
t.Errorf("age_recipients = %v, want [%s]", cfg.AgeRecipients, recipient)
|
|
}
|
|
}
|
|
|
|
// TestConfigSetStringLooksLikeNumber sets string settings to values that
|
|
// YAML reads as numbers or booleans when they are unquoted, and checks that
|
|
// config.Load returns each one unchanged.
|
|
func TestConfigSetStringLooksLikeNumber(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
key string
|
|
value string
|
|
field func(cfg *config.Config) string
|
|
}{
|
|
{"s3.access_key_id", "00112233",
|
|
func(cfg *config.Config) string { return cfg.S3.AccessKeyID }},
|
|
{"s3.secret_access_key", "12345678901234567890123456789012",
|
|
func(cfg *config.Config) string { return cfg.S3.SecretAccessKey }},
|
|
{"hostname", "007",
|
|
func(cfg *config.Config) string { return cfg.Hostname }},
|
|
{"s3.prefix", "1e5",
|
|
func(cfg *config.Config) string { return cfg.S3.Prefix }},
|
|
{"s3.bucket", "true",
|
|
func(cfg *config.Config) string { return cfg.S3.Bucket }},
|
|
{"s3.region", "FALSE",
|
|
func(cfg *config.Config) string { return cfg.S3.Region }},
|
|
{"snapshots.home.exclude.0", "1.10",
|
|
func(cfg *config.Config) string { return cfg.Snapshots["home"].Exclude[0] }},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.key+"="+tt.value, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg := loadAfterConfigSet(t, tt.key, tt.value)
|
|
|
|
got := tt.field(cfg)
|
|
if got != tt.value {
|
|
t.Errorf("%s = %q after config set %q", tt.key, got, tt.value)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestConfigSetNonUTF8Path checks that config set still accepts a value that
|
|
// is not valid UTF-8, such as a path with a Latin-1 file name, and that
|
|
// config.Load returns it unchanged.
|
|
func TestConfigSetNonUTF8Path(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const dir = "/srv/caf\xe9"
|
|
|
|
cfg := loadAfterConfigSet(t, "snapshots.home.paths.0", dir)
|
|
|
|
got := cfg.Snapshots["home"].Paths[0]
|
|
if got != dir {
|
|
t.Errorf("snapshots.home.paths.0 = %q, want %q", got, dir)
|
|
}
|
|
}
|
|
|
|
// TestConfigSetNumberStaysNumber checks that a number set for an integer
|
|
// setting is still read as a number, not as a quoted string.
|
|
func TestConfigSetNumberStaysNumber(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const level = 9
|
|
|
|
cfg := loadAfterConfigSet(t, "compression_level", strconv.Itoa(level))
|
|
|
|
if cfg.CompressionLevel != level {
|
|
t.Errorf("compression_level = %d, want %d", cfg.CompressionLevel, level)
|
|
}
|
|
}
|
|
|
|
// loadAfterConfigSet writes the file `config init` writes, sets storage_url
|
|
// to a local directory so that the file passes validation, applies
|
|
// `config set key value` and returns what config.Load reads back.
|
|
func loadAfterConfigSet(t *testing.T, key, value string) *config.Config {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(t.TempDir(), "config.yml")
|
|
|
|
err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
|
|
if err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
|
|
out := ui.NewWithColor(&bytes.Buffer{}, false)
|
|
|
|
err = writeConfigSet(out, path, "storage_url", "file:///mnt/backups")
|
|
if err != nil {
|
|
t.Fatalf("config set storage_url: %v", err)
|
|
}
|
|
|
|
err = writeConfigSet(out, path, key, value)
|
|
if err != nil {
|
|
t.Fatalf("config set %s: %v", key, err)
|
|
}
|
|
|
|
cfg, err := config.Load(path)
|
|
if err != nil {
|
|
t.Fatalf("config.Load: %v", err)
|
|
}
|
|
|
|
return cfg
|
|
}
|
|
|
|
const testYAML = `# top comment
|
|
compression_level: 3
|
|
age_recipients:
|
|
- age1aaa
|
|
s3:
|
|
bucket: oldbucket # inline comment
|
|
region: us-east-1
|
|
snapshots:
|
|
home:
|
|
paths:
|
|
- "~"
|
|
`
|
|
|
|
func parseTestYAML(t *testing.T) *yaml.Node {
|
|
t.Helper()
|
|
|
|
var root yaml.Node
|
|
|
|
err := yaml.Unmarshal([]byte(testYAML), &root)
|
|
if err != nil {
|
|
t.Fatalf("parsing test yaml: %v", err)
|
|
}
|
|
|
|
return &root
|
|
}
|
|
|
|
func TestYAMLPathGet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := parseTestYAML(t)
|
|
|
|
tests := []struct {
|
|
path string
|
|
want string
|
|
err bool
|
|
}{
|
|
{"compression_level", "3", false},
|
|
{"s3.bucket", "oldbucket", false},
|
|
{"s3.region", "us-east-1", false},
|
|
{"age_recipients.0", "age1aaa", false},
|
|
{"age_recipients.5", "", true},
|
|
{"age_recipients.notanumber", "", true},
|
|
{"s3.nonexistent", "", true},
|
|
{"nonexistent", "", true},
|
|
{"compression_level.sub", "", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.path, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
node, err := yamlPathGet(root, splitPath(tt.path))
|
|
if tt.err {
|
|
if err == nil {
|
|
t.Fatalf("expected error for %q", tt.path)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if node.Value != tt.want {
|
|
t.Errorf("get %q = %q, want %q", tt.path, node.Value, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestYAMLPathSet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := parseTestYAML(t)
|
|
|
|
// Overwrite existing nested value
|
|
err := yamlPathSet(root, splitPath("s3.bucket"), "newbucket")
|
|
if err != nil {
|
|
t.Fatalf("set s3.bucket: %v", err)
|
|
}
|
|
|
|
// Create new nested key with intermediate map
|
|
err = yamlPathSet(root, splitPath("s3.endpoint"), "s3.example.com")
|
|
if err != nil {
|
|
t.Fatalf("set s3.endpoint: %v", err)
|
|
}
|
|
|
|
err = yamlPathSet(root, splitPath("newmap.newkey"), "val")
|
|
if err != nil {
|
|
t.Fatalf("set newmap.newkey: %v", err)
|
|
}
|
|
|
|
// Overwrite a sequence element and append a new one
|
|
err = yamlPathSet(root, splitPath("age_recipients.0"), "age1bbb")
|
|
if err != nil {
|
|
t.Fatalf("set age_recipients.0: %v", err)
|
|
}
|
|
|
|
err = yamlPathSet(root, splitPath("age_recipients.1"), "age1ccc")
|
|
if err != nil {
|
|
t.Fatalf("append age_recipients.1: %v", err)
|
|
}
|
|
|
|
err = yamlPathSet(root, splitPath("age_recipients.5"), "age1ddd")
|
|
if err == nil {
|
|
t.Error("expected out-of-range append to fail")
|
|
}
|
|
|
|
// Round-trip and verify values + comment preservation
|
|
out, err := yaml.Marshal(root)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
|
|
text := string(out)
|
|
|
|
wants := []string{
|
|
"newbucket", "s3.example.com", "newkey: val",
|
|
"# top comment", "# inline comment", "age1bbb", "age1ccc",
|
|
}
|
|
for _, want := range wants {
|
|
if !contains(text, want) {
|
|
t.Errorf("round-tripped YAML missing %q:\n%s", want, text)
|
|
}
|
|
}
|
|
|
|
got, err := yamlPathGet(root, splitPath("s3.bucket"))
|
|
if err != nil {
|
|
t.Fatalf("get after set: %v", err)
|
|
}
|
|
|
|
if got.Value != "newbucket" {
|
|
t.Errorf("s3.bucket = %q after set, want newbucket", got.Value)
|
|
}
|
|
}
|
|
|
|
// TestConfigSetPreservesFormatting asserts the `config set` write path
|
|
// (marshalConfigYAML) round-trips a 2-space-indented file without reindenting
|
|
// it to yaml.Marshal's 4-space default, and keeps comments.
|
|
func TestConfigSetPreservesFormatting(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
root := parseTestYAML(t)
|
|
|
|
err := yamlPathSet(root, splitPath("s3.bucket"), "newbucket")
|
|
if err != nil {
|
|
t.Fatalf("set s3.bucket: %v", err)
|
|
}
|
|
|
|
out, err := marshalConfigYAML(root)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
|
|
text := string(out)
|
|
|
|
for _, want := range []string{"# top comment", "# inline comment"} {
|
|
if !contains(text, want) {
|
|
t.Errorf("round-tripped YAML dropped comment %q:\n%s", want, text)
|
|
}
|
|
}
|
|
|
|
// Nested map keys stay at 2-space indent; the bug reindented them to 4.
|
|
if !contains(text, "\n bucket: newbucket") {
|
|
t.Errorf("expected 2-space indent for s3.bucket, got:\n%s", text)
|
|
}
|
|
|
|
if contains(text, "\n bucket:") {
|
|
t.Errorf("s3.bucket reindented to 4 spaces:\n%s", text)
|
|
}
|
|
|
|
// Sequence items under a key also stay at 2 spaces.
|
|
if !contains(text, "\n - age1aaa") {
|
|
t.Errorf("expected 2-space indent for sequence item, got:\n%s", text)
|
|
}
|
|
}
|
|
|
|
// TestWriteConfigSetHidesSecret checks that setting a secret key prints
|
|
// only the key name, never the value, to the confirmation output.
|
|
func TestWriteConfigSetHidesSecret(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const secret = "SUPERSECRETVALUE"
|
|
|
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
|
|
|
err := os.WriteFile(path, []byte("version: 1\n"), 0o600)
|
|
if err != nil {
|
|
t.Fatalf("seed config: %v", err)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
|
|
err = writeConfigSet(ui.NewWithColor(&buf, false), path,
|
|
"s3.secret_access_key", secret)
|
|
if err != nil {
|
|
t.Fatalf("writeConfigSet: %v", err)
|
|
}
|
|
|
|
if strings.Contains(buf.String(), secret) {
|
|
t.Errorf("output echoed the secret value: %q", buf.String())
|
|
}
|
|
|
|
if !strings.Contains(buf.String(), "s3.secret_access_key") {
|
|
t.Errorf("output did not confirm the key name: %q", buf.String())
|
|
}
|
|
}
|
|
|
|
// TestWriteConfigSetTightensMode checks that a pre-existing group- or
|
|
// world-readable config is tightened to owner-only after a set, since
|
|
// os.WriteFile leaves an existing file's mode untouched.
|
|
func TestWriteConfigSetTightensMode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
|
|
|
// Seed a world-readable config; the loose mode is the condition under
|
|
// test, so gosec's G306 is expected here.
|
|
err := os.WriteFile(path, []byte("version: 1\n"), 0o644) //nolint:gosec // G306
|
|
if err != nil {
|
|
t.Fatalf("seed config: %v", err)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
|
|
err = writeConfigSet(ui.NewWithColor(&buf, false), path,
|
|
"compression_level", "9")
|
|
if err != nil {
|
|
t.Fatalf("writeConfigSet: %v", err)
|
|
}
|
|
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatalf("stat config: %v", err)
|
|
}
|
|
|
|
if info.Mode().Perm() != 0o600 {
|
|
t.Errorf("config mode = %04o, want 0600", info.Mode().Perm())
|
|
}
|
|
}
|
|
|
|
func splitPath(s string) []string {
|
|
return strings.Split(s, ".")
|
|
}
|
|
|
|
func contains(haystack, needle string) bool {
|
|
return strings.Contains(haystack, needle)
|
|
}
|