internal/config/config_load_test.go
Rama main · réplica de lectura del repositorio original.
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v3"
)
const uploadTestPlaintextSecret = "upload-plaintext-secret"
func writeUploadTestConfig(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.yaml")
if errWrite := os.WriteFile(path, []byte(content), 0o600); errWrite != nil {
t.Fatalf("write config: %v", errWrite)
}
return path
}
func readUploadTestConfig(t *testing.T, path string) string {
t.Helper()
data, errRead := os.ReadFile(path)
if errRead != nil {
t.Fatalf("read config: %v", errRead)
}
return string(data)
}
func uploadTestBcrypt(t *testing.T) string {
t.Helper()
hashed, errHash := bcrypt.GenerateFromPassword([]byte(uploadTestPlaintextSecret), bcrypt.MinCost)
if errHash != nil {
t.Fatalf("bcrypt.GenerateFromPassword() error = %v", errHash)
}
return string(hashed)
}
// uploadTestSecret returns the secret-key stored under section and whether it exists.
func uploadTestSecret(t *testing.T, data, section string) (string, bool) {
t.Helper()
var doc map[string]any
if errParse := yaml.Unmarshal([]byte(data), &doc); errParse != nil {
t.Fatalf("parse config: %v", errParse)
}
management, _ := doc[section].(map[string]any)
secret, found := management["secret-key"].(string)
return secret, found
}
func assertUploadTestHashed(t *testing.T, data, section string) {
t.Helper()
secret, _ := uploadTestSecret(t, data, section)
if errCompare := bcrypt.CompareHashAndPassword([]byte(secret), []byte(uploadTestPlaintextSecret)); errCompare != nil {
t.Fatalf("%s.secret-key %q is not the bcrypt hash of the plaintext secret:\n%s", section, secret, data)
}
}
func TestPrepareConfigFileForUploadHashesPlaintextSecret(t *testing.T) {
for _, tc := range []struct {
name string
section string
other string
}{
{name: "legacy layout", section: "remote-management", other: "management"},
{name: "v8 layout", section: "management", other: "remote-management"},
} {
t.Run(tc.name, func(t *testing.T) {
path := writeUploadTestConfig(t, "# operator note\nport: 8317\n"+tc.section+":\n secret-key: "+uploadTestPlaintextSecret+"\n")
if errPrepare := PrepareConfigFileForUpload(path); errPrepare != nil {
t.Fatalf("PrepareConfigFileForUpload() error = %v", errPrepare)
}
saved := readUploadTestConfig(t, path)
if strings.Contains(saved, uploadTestPlaintextSecret) {
t.Fatalf("config still holds the plaintext secret:\n%s", saved)
}
if !strings.Contains(saved, "# operator note") || !strings.Contains(saved, "port: 8317") {
t.Fatalf("hashing dropped unrelated content:\n%s", saved)
}
assertUploadTestHashed(t, saved, tc.section)
if _, found := uploadTestSecret(t, saved, tc.other); found {
t.Fatalf("hashing wrote a secret under %s:\n%s", tc.other, saved)
}
})
}
}
// Keys only name settings, so a secret spelled like a key must not block the upload.
func TestPrepareConfigFileForUploadIgnoresKeysSpelledLikeSecret(t *testing.T) {
hashed := uploadTestBcrypt(t)
for _, tc := range []struct {
name string
content string
check func(t *testing.T, saved string)
}{
{
name: "v8 secret spelled like its section",
content: "management:\n secret-key: management\n",
check: func(t *testing.T, saved string) {
secret, _ := uploadTestSecret(t, saved, "management")
if errCompare := bcrypt.CompareHashAndPassword([]byte(secret), []byte("management")); errCompare != nil {
t.Fatalf("management.secret-key %q is not the bcrypt hash of the secret:\n%s", secret, saved)
}
},
},
{
name: "overridden legacy secret spelled like the v8 section",
content: "remote-management:\n secret-key: management\nmanagement:\n secret-key: '" + hashed + "'\n",
check: func(t *testing.T, saved string) {
if secret, _ := uploadTestSecret(t, saved, "management"); secret != hashed {
t.Fatalf("management.secret-key = %q, want the existing hash kept:\n%s", secret, saved)
}
if _, found := uploadTestSecret(t, saved, "remote-management"); found {
t.Fatalf("conflicting legacy secret kept:\n%s", saved)
}
},
},
} {
t.Run(tc.name, func(t *testing.T) {
path := writeUploadTestConfig(t, tc.content)
if errPrepare := PrepareConfigFileForUpload(path); errPrepare != nil {
t.Fatalf("PrepareConfigFileForUpload() error = %v", errPrepare)
}
tc.check(t, readUploadTestConfig(t, path))
})
}
}
// A YAML null elsewhere is not the string secret "null", so it must not block the upload.
func TestPrepareConfigFileForUploadIgnoresYAMLNullBesideStringNullSecret(t *testing.T) {
path := writeUploadTestConfig(t, "proxy-url: null\nmanagement:\n secret-key: 'null'\n")
if errPrepare := PrepareConfigFileForUpload(path); errPrepare != nil {
t.Fatalf("PrepareConfigFileForUpload() error = %v", errPrepare)
}
saved := readUploadTestConfig(t, path)
secret, _ := uploadTestSecret(t, saved, "management")
if errCompare := bcrypt.CompareHashAndPassword([]byte(secret), []byte("null")); errCompare != nil {
t.Fatalf("management.secret-key %q is not the bcrypt hash of the string secret:\n%s", secret, saved)
}
}
// A partially migrated config can keep a legacy plaintext secret next to the v8 key.
// The startup load drops that conflicting legacy field, so it must not be uploaded.
func TestPrepareConfigFileForUploadDropsConflictingLegacySecret(t *testing.T) {
hashed := uploadTestBcrypt(t)
for _, tc := range []struct {
name string
v8Secret string
}{
{name: "duplicated plaintext", v8Secret: uploadTestPlaintextSecret},
{name: "stale legacy beside hashed v8", v8Secret: "'" + hashed + "'"},
} {
t.Run(tc.name, func(t *testing.T) {
path := writeUploadTestConfig(t, "remote-management:\n secret-key: "+uploadTestPlaintextSecret+"\nmanagement:\n secret-key: "+tc.v8Secret+"\n")
if errPrepare := PrepareConfigFileForUpload(path); errPrepare != nil {
t.Fatalf("PrepareConfigFileForUpload() error = %v", errPrepare)
}
saved := readUploadTestConfig(t, path)
if strings.Contains(saved, uploadTestPlaintextSecret) {
t.Fatalf("config still holds the plaintext secret:\n%s", saved)
}
assertUploadTestHashed(t, saved, "management")
if _, found := uploadTestSecret(t, saved, "remote-management"); found {
t.Fatalf("conflicting legacy secret kept:\n%s", saved)
}
})
}
}
func TestPrepareConfigFileForUploadLeavesSettledConfigUntouched(t *testing.T) {
hashed := uploadTestBcrypt(t)
shippedTemplate, errTemplate := os.ReadFile(filepath.Join("..", "..", "config.example.yaml"))
if errTemplate != nil {
t.Fatalf("read config.example.yaml: %v", errTemplate)
}
for name, content := range map[string]string{
"hashed legacy secret": "# operator note\nremote-management:\n secret-key: '" + hashed + "'\n",
"hashed v8 secret": "# operator note\nmanagement:\n secret-key: '" + hashed + "'\n",
"no secret": "# operator note\nport: 8317\n",
"null v8 secret": "# operator note\nmanagement:\n secret-key: null\n",
"tilde legacy secret": "# operator note\nremote-management:\n secret-key: ~\n",
"empty file": "",
"shipped template": string(shippedTemplate),
} {
t.Run(name, func(t *testing.T) {
path := writeUploadTestConfig(t, content)
if errPrepare := PrepareConfigFileForUpload(path); errPrepare != nil {
t.Fatalf("PrepareConfigFileForUpload() error = %v", errPrepare)
}
if saved := readUploadTestConfig(t, path); saved != content {
t.Fatalf("config rewritten to %q, want it untouched", saved)
}
})
}
}
// A config whose secret cannot be hashed into the file still holds the plaintext, so
// callers must hear about it and not upload the file.
func TestPrepareConfigFileForUploadReportsSecretItCannotHash(t *testing.T) {
for _, tc := range []struct {
name string
content string
readOnly bool
}{
{name: "unparsable", content: "remote-management:\n secret-key: " + uploadTestPlaintextSecret + "\nport: [8317\n"},
{name: "secret too long for bcrypt", content: "remote-management:\n secret-key: " + strings.Repeat("s", 73) + "\n"},
{name: "read-only file", content: "remote-management:\n secret-key: " + uploadTestPlaintextSecret + "\n", readOnly: true},
} {
t.Run(tc.name, func(t *testing.T) {
path := writeUploadTestConfig(t, tc.content)
if tc.readOnly {
if errChmod := os.Chmod(path, 0o400); errChmod != nil {
t.Fatalf("chmod config: %v", errChmod)
}
t.Cleanup(func() {
if errChmod := os.Chmod(path, 0o600); errChmod != nil {
t.Errorf("restore config permissions: %v", errChmod)
}
})
if file, errOpen := os.OpenFile(path, os.O_WRONLY, 0); errOpen == nil {
if errClose := file.Close(); errClose != nil {
t.Errorf("close config: %v", errClose)
}
t.Skip("read-only file stays writable for this user")
}
}
if errPrepare := PrepareConfigFileForUpload(path); errPrepare == nil {
t.Fatal("PrepareConfigFileForUpload() reported success for a secret it could not hash into the file")
}
if saved := readUploadTestConfig(t, path); saved != tc.content {
t.Fatalf("config rewritten to %q, want it untouched", saved)
}
})
}
}
// Hashing replaces only the effective key. A YAML anchor keeps the plaintext at its
// source, and rewriting the source could change other settings sharing it, so the file
// must be refused and left as it was; otherwise a later call would find the key hashed
// and accept the file with the plaintext still at the anchor.
func TestPrepareConfigFileForUploadRefusesPlaintextKeptByReferences(t *testing.T) {
anchoredLegacy := "defaults: &legacy\n secret-key: " + uploadTestPlaintextSecret + "\nremote-management: *legacy\nmanagement:\n secret-key: "
for name, content := range map[string]string{
"mapping anchor": "defaults: &management\n secret-key: " + uploadTestPlaintextSecret + "\nmanagement: *management\n",
"scalar anchor": "password: &password " + uploadTestPlaintextSecret + "\nmanagement:\n secret-key: *password\n",
"merge key": "defaults: &management\n secret-key: " + uploadTestPlaintextSecret + "\nmanagement:\n <<: *management\n allow-remote: true\n",
"shared anchor": "management: &management\n secret-key: " + uploadTestPlaintextSecret + "\nother: *management\n",
// The overridden legacy secret is dropped from remote-management, but its anchor
// still holds the plaintext.
"anchored legacy beside plaintext v8": anchoredLegacy + "current-plaintext-secret\n",
"anchored legacy beside hashed v8": anchoredLegacy + "'" + uploadTestBcrypt(t) + "'\n",
} {
t.Run(name, func(t *testing.T) {
path := writeUploadTestConfig(t, content)
for attempt := 1; attempt <= 2; attempt++ {
if errPrepare := PrepareConfigFileForUpload(path); errPrepare == nil {
t.Fatalf("attempt %d: PrepareConfigFileForUpload() reported success, but the file still holds the plaintext secret:\n%s", attempt, readUploadTestConfig(t, path))
}
}
if saved := readUploadTestConfig(t, path); saved != content {
t.Fatalf("refused config rewritten to %q, want it untouched", saved)
}
})
}
}