package secrets import ( "crypto/rand" "encoding/json" "errors" "io" "os" "path/filepath" "testing" "gitea.dcglab.co.uk/steve/restic-manager/internal/crypto" ) func freshKey(t *testing.T) []byte { t.Helper() k := make([]byte, crypto.KeyLen) if _, err := io.ReadFull(rand.Reader, k); err != nil { t.Fatalf("rand: %v", err) } return k } func TestRoundTrip(t *testing.T) { t.Parallel() dir := t.TempDir() path := filepath.Join(dir, "secrets.enc") st, err := New(path, freshKey(t)) if err != nil { t.Fatalf("new: %v", err) } // Empty file → zero-value Repo, no error. got, err := st.Load() if err != nil { t.Fatalf("load empty: %v", err) } if !got.Empty() { t.Errorf("first load should be empty, got %+v", got) } want := Repo{URL: "rest:https://r/host", Username: "user", Password: "pw"} if err := st.Save(want); err != nil { t.Fatalf("save: %v", err) } got, err = st.Load() if err != nil { t.Fatalf("load: %v", err) } if got != want { t.Errorf("round-trip mismatch: got %+v want %+v", got, want) } // File mode must be 0600. info, _ := os.Stat(path) if info.Mode().Perm() != 0o600 { t.Errorf("file mode = %o, want 0600", info.Mode().Perm()) } } func TestRejectsWrongKey(t *testing.T) { t.Parallel() dir := t.TempDir() path := filepath.Join(dir, "secrets.enc") good, err := New(path, freshKey(t)) if err != nil { t.Fatalf("new good: %v", err) } if err := good.Save(Repo{URL: "x", Password: "y"}); err != nil { t.Fatalf("save: %v", err) } bad, err := New(path, freshKey(t)) if err != nil { t.Fatalf("new bad: %v", err) } if _, err := bad.Load(); err == nil { t.Error("load with wrong key must fail; got nil") } } func TestSaveIsAtomic(t *testing.T) { t.Parallel() // Sanity check: after Save(), there are no leftover .tmp files. dir := t.TempDir() path := filepath.Join(dir, "secrets.enc") st, err := New(path, freshKey(t)) if err != nil { t.Fatalf("new: %v", err) } if err := st.Save(Repo{URL: "x", Password: "y"}); err != nil { t.Fatalf("save: %v", err) } entries, _ := os.ReadDir(dir) if len(entries) != 1 { var names []string for _, e := range entries { names = append(names, e.Name()) } t.Errorf("dir should hold one file post-save, got %v", names) } } func TestSecretsLoadAdminEmpty(t *testing.T) { t.Parallel() // No file yet: LoadAdmin must return ErrNoAdmin, not a hard error. dir := t.TempDir() path := filepath.Join(dir, "secrets.enc") st, err := New(path, freshKey(t)) if err != nil { t.Fatalf("new: %v", err) } _, err = st.LoadAdmin() if !errors.Is(err, ErrNoAdmin) { t.Errorf("expected ErrNoAdmin, got %v", err) } } func TestSecretsAdminSlotIndependent(t *testing.T) { t.Parallel() dir := t.TempDir() path := filepath.Join(dir, "secrets.enc") st, err := New(path, freshKey(t)) if err != nil { t.Fatalf("new: %v", err) } repo := Repo{URL: "rest:https://repo/host", Username: "user", Password: "pw"} admin := Repo{URL: "rest:https://repo/host", Username: "admin", Password: "adminpw"} if err := st.Save(repo); err != nil { t.Fatalf("save repo: %v", err) } if err := st.SaveAdmin(admin); err != nil { t.Fatalf("save admin: %v", err) } // Load returns the repo slot unchanged. gotRepo, err := st.Load() if err != nil { t.Fatalf("load: %v", err) } if gotRepo != repo { t.Errorf("repo slot mismatch: got %+v want %+v", gotRepo, repo) } // LoadAdmin returns the admin slot. gotAdmin, err := st.LoadAdmin() if err != nil { t.Fatalf("load admin: %v", err) } if gotAdmin != admin { t.Errorf("admin slot mismatch: got %+v want %+v", gotAdmin, admin) } // SaveAdmin a second time replaces admin only; repo unchanged. admin2 := Repo{URL: "rest:https://repo/host", Username: "admin2", Password: "pw2"} if err := st.SaveAdmin(admin2); err != nil { t.Fatalf("save admin2: %v", err) } gotRepo2, err := st.Load() if err != nil { t.Fatalf("load after admin2 save: %v", err) } if gotRepo2 != repo { t.Errorf("repo slot changed unexpectedly: got %+v want %+v", gotRepo2, repo) } gotAdmin2, err := st.LoadAdmin() if err != nil { t.Fatalf("load admin2: %v", err) } if gotAdmin2 != admin2 { t.Errorf("admin2 slot mismatch: got %+v want %+v", gotAdmin2, admin2) } } func TestSecretsSaveRefusesCorruptFile(t *testing.T) { t.Parallel() dir := t.TempDir() path := filepath.Join(dir, "secrets.enc") st, err := New(path, freshKey(t)) if err != nil { t.Fatalf("new: %v", err) } // Lay down a valid file first. if err := st.Save(Repo{URL: "rest:https://r/host", Password: "pw"}); err != nil { t.Fatalf("initial save: %v", err) } // Corrupt the file. garbage := []byte("not encrypted") if err := os.WriteFile(path, garbage, 0o600); err != nil { t.Fatalf("write garbage: %v", err) } // Save must refuse to overwrite: decrypt will fail. saveErr := st.Save(Repo{URL: "rest:https://r/host", Password: "new"}) if saveErr == nil { t.Fatal("Save over corrupt file must return an error; got nil") } // File must NOT have been replaced — still contains the garbage bytes. got, err := os.ReadFile(path) if err != nil { t.Fatalf("re-read: %v", err) } if string(got) != string(garbage) { t.Errorf("corrupt file was overwritten; file size now %d (was %d)", len(got), len(garbage)) } } func TestSecretsSaveAdminRefusesCorruptFile(t *testing.T) { t.Parallel() dir := t.TempDir() path := filepath.Join(dir, "secrets.enc") st, err := New(path, freshKey(t)) if err != nil { t.Fatalf("new: %v", err) } // Lay down a valid file first. if err := st.SaveAdmin(Repo{URL: "rest:https://r/host", Password: "adminpw"}); err != nil { t.Fatalf("initial save admin: %v", err) } // Corrupt the file. garbage := []byte("not encrypted admin") if err := os.WriteFile(path, garbage, 0o600); err != nil { t.Fatalf("write garbage: %v", err) } // SaveAdmin must refuse to overwrite: decrypt will fail. saveErr := st.SaveAdmin(Repo{URL: "rest:https://r/host", Password: "new"}) if saveErr == nil { t.Fatal("SaveAdmin over corrupt file must return an error; got nil") } // File must NOT have been replaced. got, err := os.ReadFile(path) if err != nil { t.Fatalf("re-read: %v", err) } if string(got) != string(garbage) { t.Errorf("corrupt file was overwritten; file size now %d (was %d)", len(got), len(garbage)) } } func TestSecretsLegacyFlatBlobMigrates(t *testing.T) { t.Parallel() dir := t.TempDir() path := filepath.Join(dir, "secrets.enc") key := freshKey(t) // Write a legacy flat Repo blob directly — bypassing bundle wrapping. legacy := Repo{URL: "rest:https://legacy/host", Username: "legacyuser", Password: "legacypw"} plain, err := json.Marshal(legacy) if err != nil { t.Fatalf("marshal legacy: %v", err) } a, err := crypto.NewAEAD(key) if err != nil { t.Fatalf("aead: %v", err) } ct, err := a.Encrypt(plain, []byte(additionalData)) if err != nil { t.Fatalf("encrypt legacy: %v", err) } if err := os.WriteFile(path, []byte(ct), 0o600); err != nil { t.Fatalf("write legacy file: %v", err) } // Open via secrets.New + Load — must return the legacy Repo. st, err := New(path, key) if err != nil { t.Fatalf("new: %v", err) } got, err := st.Load() if err != nil { t.Fatalf("load legacy: %v", err) } if got != legacy { t.Errorf("legacy decode mismatch: got %+v want %+v", got, legacy) } // SaveAdmin should write both slots; re-opening must have both. admin := Repo{URL: "rest:https://legacy/host", Username: "admin", Password: "adminpw"} if err := st.SaveAdmin(admin); err != nil { t.Fatalf("save admin after legacy: %v", err) } st2, err := New(path, key) if err != nil { t.Fatalf("reopen: %v", err) } gotRepo, err := st2.Load() if err != nil { t.Fatalf("load repo after migration: %v", err) } if gotRepo != legacy { t.Errorf("repo after migration: got %+v want %+v", gotRepo, legacy) } gotAdmin, err := st2.LoadAdmin() if err != nil { t.Fatalf("load admin after migration: %v", err) } if gotAdmin != admin { t.Errorf("admin after migration: got %+v want %+v", gotAdmin, admin) } }