Ctrl K
ComunidadGuías…

internal/config/config_load_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)
			}
		})
	}
}