Files
steve 27086783da P1-33: agent-side encrypted secrets store + push-on-update
New internal/agent/secrets package: AEAD blob at
/var/lib/restic-manager/secrets.enc, atomic write (os.CreateTemp +
Sync + Rename), 0600. Key lives in agent.yaml as base64
(SecretsKey) — same trust boundary as the bearer token, minted on
first start via EnsureSecretsKey.

cmd/agent: dispatcher reads creds fresh from secrets.Load() on
each job rather than from in-memory config. config.update merges
the push with what's on disk and persists, so a daemon restart
keeps the latest values. Legacy plaintext repo_url/repo_password
in agent.yaml are silently migrated into secrets.enc on next start
and stripped from the YAML on the following save.

Tests: round-trip + wrong-key rejection + atomic-write
post-condition for secrets; key idempotence + legacy-field
parse/clear for config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:41:28 +01:00

97 lines
2.4 KiB
Go

package config
import (
"os"
"path/filepath"
"testing"
"gitea.dcglab.co.uk/steve/restic-manager/internal/crypto"
)
func TestEnsureSecretsKeyMintsOnce(t *testing.T) {
t.Parallel()
c := &Config{}
if err := c.EnsureSecretsKey(); err != nil {
t.Fatalf("mint: %v", err)
}
first := c.SecretsKey
if first == "" {
t.Fatal("EnsureSecretsKey: SecretsKey still empty")
}
// Second call must be a no-op (idempotent).
if err := c.EnsureSecretsKey(); err != nil {
t.Fatalf("second mint: %v", err)
}
if c.SecretsKey != first {
t.Errorf("EnsureSecretsKey is not idempotent: %q → %q", first, c.SecretsKey)
}
// SecretsKeyBytes returns 32 raw bytes.
b, err := c.SecretsKeyBytes()
if err != nil {
t.Fatalf("bytes: %v", err)
}
if len(b) != crypto.KeyLen {
t.Errorf("decoded key len = %d, want %d", len(b), crypto.KeyLen)
}
}
func TestSecretsKeyBytesRejectsBadInput(t *testing.T) {
t.Parallel()
cases := map[string]Config{
"empty": {SecretsKey: ""},
"not_base64": {SecretsKey: "!!!"},
"wrong_len": {SecretsKey: "Zm9v"}, // "foo", 3 bytes
}
for name, c := range cases {
c := c
t.Run(name, func(t *testing.T) {
if _, err := c.SecretsKeyBytes(); err == nil {
t.Errorf("expected error for %s", name)
}
})
}
}
func TestLoadAcceptsLegacyRepoFields(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "agent.yaml")
yaml := []byte(`server_url: "https://srv"
host_id: "h1"
agent_token: "tok"
repo_url: "rest:https://repo/h1"
repo_password: "secret"
`)
if err := os.WriteFile(path, yaml, 0o600); err != nil {
t.Fatalf("write: %v", err)
}
c, err := Load(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if c.LegacyRepoURL != "rest:https://repo/h1" || c.LegacyRepoPassword != "secret" {
t.Errorf("legacy fields not parsed: %+v", c)
}
// And on Save the legacy fields should round-trip out to the file
// only when set (empty values use omitempty).
c.LegacyRepoURL = ""
c.LegacyRepoPassword = ""
if err := c.Save(); err != nil {
t.Fatalf("save: %v", err)
}
body, _ := os.ReadFile(path)
if contains := string(body); contains == "" || stringContains(contains, "repo_password:") {
t.Errorf("repo_password should be gone after legacy clear, got:\n%s", contains)
}
}
func stringContains(haystack, needle string) bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}