Files
magpie/internal/backup/backup_test.go
T
Lin Huazhong d8ebf2b893 test: one list of the agent folders a sandbox clears, so no sandbox misses an agent (yetone, on #522: A shared list of env vars for the other sandboxes would be a welcome follow-up) — every sandbox kept its own list of the variables that point at an agent's folder, and each had drifted: the gateway's cleared 5 of the 32 magpie reads, the provider package's 2, the tui's 10, the library's 20. A sandbox clearing 2 meets whatever Grok, Kimi, Hermes, MiniMax, Qoder or ZCode the developer has installed, which is how #522 came about — Qoder's two were in the library's list and in none of the four that failed. internal/agentenv is now the one list, and the 21 sandboxes that fake an environment with no agent in it clear it, each keeping beside it the variables that are not an agent's: APPDATA and LOCALAPPDATA, which a sandbox sets to a folder of its own rather than clearing, VISUAL and EDITOR. A name in the list is one magpie reads, and TestVarsAreRead walks the sources to say so, which is how two names a sandbox had been clearing turned out to be read by nothing at all — GEMINI_CLI_HOME and OPENCODE_CONFIG in internal/backup's, while OPENCODE_CONFIG_DIR, the one internal/agent reads (agents.go:545), was not in it. Where a sandbox set an agent's folder to a fixture of its own, the clearing now comes first, since a list of 32 wipes what a list of 5 left standing. Not converted: the omp tests' four variables, which are the ones omp's own lookup consults (a Pi fork) rather than every agent's, and the warm-up's Claude Code traffic flags, which are not folders — CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC stays out of the list for that reason too, its one test setting it on purpose. On Windows 11 the whole suite answers as it did before the change: 31 packages pass, and the same 15 tests in internal/library, internal/gateway and internal/agent fail for what they failed for before (symlinks this machine has no privilege for, and TestSuffixModesReachAgentFiles). go vet ./..., go build -tags nogui (windows, GOOS=linux, GOOS=darwin), and go vet -tags nogui for linux and darwin over every package touched pass. (#543)
2026-10-02 17:22:16 +08:00

325 lines
11 KiB
Go

package backup
import (
"errors"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
"github.com/yetone/magpie/internal/agentenv"
"github.com/yetone/magpie/internal/library"
"github.com/yetone/magpie/internal/profile"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/settings"
)
// home gives the test a machine of its own: no agents, no magpie files.
func home(t *testing.T) {
t.Helper()
h := t.TempDir()
t.Setenv("HOME", h)
t.Setenv("USERPROFILE", h)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(h, ".cache"))
t.Setenv("PATH", h)
for _, v := range agentenv.Vars {
t.Setenv(v, "")
}
t.Setenv("APPDATA", "")
t.Setenv("LOCALAPPDATA", "")
}
func setUp(t *testing.T) {
t.Helper()
icon, err := provider.StoreIcon([]byte("\x89PNG\r\n\x1a\n0000000000000000"))
if err != nil {
t.Fatal(err)
}
for _, p := range []provider.Provider{
{ID: "acme", Name: "Acme", Chat: "https://acme.example.com/v1", Key: "sk-acme", Icon: icon,
BalanceToken: "balance-acme",
Keys: []provider.KeyAccount{{Name: "second", Key: "sk-acme-2"}},
Headers: map[string]string{"X-Team": "a", "X-Api-Key": "hdr-secret"}},
{ID: "beta", Name: "Beta", Chat: "https://beta.example.com/v1", Key: "sk-beta", BalanceToken: "balance-beta"},
} {
if err := provider.Save(p); err != nil {
t.Fatal(err)
}
}
if err := settings.Save(settings.Settings{Theme: "dark", Lang: "zh"}); err != nil {
t.Fatal(err)
}
if err := profile.Save("work", profile.Profile{Fields: map[string]string{"claude.model": "acme/m1"}}); err != nil {
t.Fatal(err)
}
}
func TestRoundTrip(t *testing.T) {
home(t)
setUp(t)
b, err := Collect(true, "test")
if err != nil {
t.Fatal(err)
}
data, err := Seal(b, "correct horse")
if err != nil {
t.Fatal(err)
}
for _, secret := range []string{"sk-acme", "balance-acme", "hdr-secret", "acme.example.com", "work"} {
if strings.Contains(string(data), secret) {
t.Fatalf("%q readable in the file", secret)
}
}
if _, err := Open(data, "wrong"); !errors.Is(err, ErrPassphrase) {
t.Fatalf("wrong passphrase: %v", err)
}
home(t) // another machine
got, err := Open(data, "correct horse")
if err != nil {
t.Fatal(err)
}
r, err := Restore(got, All)
if err != nil {
t.Fatal(err)
}
if r.Added != 2 || !r.Settings || r.Profiles != 1 || len(r.NeedKey) != 0 {
t.Fatalf("result: %+v", r)
}
p, err := provider.Find("acme")
if err != nil || p.Key != "sk-acme" || len(p.Keys) != 1 || p.BalanceToken != "balance-acme" || p.Headers["X-Api-Key"] != "hdr-secret" {
t.Fatalf("acme: %+v %v", p, err)
}
name, _ := strings.CutPrefix(p.Icon, "file:")
if _, err := os.Stat(provider.IconFile(name)); err != nil {
t.Fatalf("icon: %v", err)
}
if s := settings.Load(); s.Theme != "dark" || s.Lang != "zh" {
t.Fatalf("settings: %+v", s)
}
if ps, _ := profile.Load(); ps["work"].Fields["claude.model"] != "acme/m1" {
t.Fatalf("profiles: %+v", ps)
}
}
// Without keys, nothing secret leaves; restored over a machine that has the
// provider, its keys there stay, and one new there is named as needing a key.
func TestNoKeys(t *testing.T) {
home(t)
setUp(t)
b, err := Collect(false, "test")
if err != nil {
t.Fatal(err)
}
for _, p := range b.Providers {
if p.Key != "" || len(p.Keys) != 0 || p.BalanceToken != "" {
t.Fatalf("key in a keyless backup: %+v", p)
}
if _, ok := p.Headers["X-Api-Key"]; ok {
t.Fatalf("auth header in a keyless backup: %+v", p.Headers)
}
}
if p, _ := provider.Find("acme"); p.Key != "sk-acme" || p.BalanceToken != "balance-acme" || p.Headers["X-Api-Key"] != "hdr-secret" {
t.Fatalf("export changed the stored credentials: %+v", p)
}
data, err := Seal(b, "pw")
if err != nil {
t.Fatal(err)
}
home(t)
if err := provider.Save(provider.Provider{ID: "acme", Name: "Acme old", Chat: "https://old.example.com/v1", Key: "sk-here", BalanceToken: "balance-here"}); err != nil {
t.Fatal(err)
}
got, err := Open(data, "pw")
if err != nil {
t.Fatal(err)
}
r, err := Restore(got, Parts{Providers: true})
if err != nil {
t.Fatal(err)
}
if r.Added != 1 || r.Replaced != 1 || !slices.Equal(r.NeedKey, []string{"Beta"}) || r.Settings || r.Profiles != 0 {
t.Fatalf("result: %+v", r)
}
if p, _ := provider.Find("acme"); p.Key != "sk-here" || p.BalanceToken != "balance-here" || p.Chat != "https://acme.example.com/v1" || p.Headers["X-Team"] != "a" {
t.Fatalf("acme: %+v", p)
}
if p, _ := provider.Find("beta"); p.BalanceToken != "" {
t.Fatalf("balance token reached a new machine: %+v", p)
}
}
func TestOTelBackupHeaders(t *testing.T) {
home(t)
config := settings.OTel{Enabled: true, Metrics: true, Endpoint: "https://collector.example.com", Headers: map[string]string{"Authorization": "Basic test-secret", "X-Custom": "custom-secret"}}
if err := settings.Save(settings.Settings{OTel: config}); err != nil {
t.Fatal(err)
}
for _, keys := range []bool{false, true} {
b, err := Collect(keys, "test")
if err != nil {
t.Fatal(err)
}
data, err := Seal(b, "pw")
if err != nil {
t.Fatal(err)
}
got, err := Open(data, "pw")
if err != nil {
t.Fatal(err)
}
if got.Settings == nil {
t.Fatal("settings missing from backup")
}
o := got.Settings.OTel
if o.Enabled != config.Enabled || o.Metrics != config.Metrics || o.Endpoint != config.Endpoint {
t.Fatalf("non-secret OTLP preferences changed: %+v", o)
}
if !keys && len(o.Headers) != 0 {
t.Fatalf("OTLP headers in keyless backup: %+v", o.Headers)
}
if keys && (len(o.Headers) != 2 || o.Headers["Authorization"] != config.Headers["Authorization"] || o.Headers["X-Custom"] != config.Headers["X-Custom"]) {
t.Fatal("full backup lost OTLP headers")
}
}
if got := settings.Load().OTel.Headers; got["Authorization"] != config.Headers["Authorization"] || got["X-Custom"] != config.Headers["X-Custom"] {
t.Fatal("keyless backup changed saved credentials")
}
}
func TestOTelRestoreHeaders(t *testing.T) {
for _, tc := range []struct {
name, endpoint string
keys bool
want map[string]string
}{
{"keyless-same-endpoint", "https://collector.example.com/otel", false, map[string]string{"Authorization": "Basic local", "X-Custom": "local-secret"}},
{"keyless-trailing-slash", "https://collector.example.com/otel/", false, map[string]string{"Authorization": "Basic local", "X-Custom": "local-secret"}},
{"keyless-different-host", "https://other.example.com/otel", false, nil},
{"keyless-different-path", "https://collector.example.com/other", false, nil},
{"full-same-endpoint", "https://collector.example.com/otel", true, map[string]string{"Authorization": "Basic incoming"}},
{"full-different-endpoint", "https://other.example.com/otel", true, map[string]string{"Authorization": "Basic incoming"}},
} {
t.Run(tc.name, func(t *testing.T) {
home(t)
local := settings.OTel{Enabled: true, Endpoint: "https://collector.example.com/otel", Headers: map[string]string{"Authorization": "Basic local", "X-Custom": "local-secret"}}
if err := settings.Save(settings.Settings{OTel: local}); err != nil {
t.Fatal(err)
}
incoming := settings.Settings{OTel: settings.OTel{Enabled: true, Metrics: true, Endpoint: tc.endpoint}}
if tc.keys {
incoming.OTel.Headers = map[string]string{"Authorization": "Basic incoming"}
}
r, err := Restore(Bundle{Version: 1, Keys: tc.keys, Settings: &incoming}, Parts{Settings: true})
if err != nil || !r.Settings {
t.Fatalf("restore: %+v, %v", r, err)
}
got := settings.Load().OTel
if !reflect.DeepEqual(got.Headers, tc.want) {
t.Fatalf("restored headers: %+v, want %+v", got.Headers, tc.want)
}
if !got.Enabled || !got.Metrics || got.Endpoint != strings.TrimRight(tc.endpoint, "/") {
t.Fatalf("restored OTLP settings: %+v", got)
}
})
}
}
// The library goes too: its sets, servers and skills' files; without keys
// a server's secret-looking values stay behind, and the ones on the
// machine restored to stay. A backup from before the library leaves it.
func TestLibrary(t *testing.T) {
home(t)
text := "Be brief."
if _, err := library.SaveInstructions(library.InstructionsChange{Shared: &text}); err != nil {
t.Fatal(err)
}
srv := library.Server{Name: "gh", Transport: "http", URL: "https://mcp.example.com",
Headers: map[string]string{"Authorization": "Bearer lib-secret", "X-Org": "acme"}, Agents: []string{}}
if _, err := library.SaveServer("", srv); err != nil {
t.Fatal(err)
}
dir := filepath.Join(os.Getenv("HOME"), "skills", "notes")
os.MkdirAll(dir, 0o755)
os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: notes\ndescription: Notes\n---\n"), 0o644)
if _, err := library.InstallSkills(dir, []string{""}, nil); err != nil {
t.Fatal(err)
}
full, err := Collect(true, "test")
if err != nil || full.Library == nil || full.Library.MCP[0].Headers["Authorization"] != "Bearer lib-secret" {
t.Fatalf("with keys: %+v %v", full.Library, err)
}
b, _ := Collect(false, "test")
if h := b.Library.MCP[0].Headers; h["Authorization"] != "" || h["X-Org"] != "acme" {
t.Fatalf("without keys: %v", h)
}
data, _ := Seal(b, "pw")
older, _ := Seal(Bundle{Version: 1, Providers: []provider.Provider{}}, "pw")
home(t) // another machine, with the server and its key already
srv.Headers = map[string]string{"Authorization": "Bearer here", "X-Org": "old"}
if _, err := library.SaveServer("", srv); err != nil {
t.Fatal(err)
}
got, _ := Open(older, "pw")
if r, err := Restore(got, All); err != nil || r.Library {
t.Fatalf("an older backup: %+v %v", r, err)
}
got, err = Open(data, "pw")
if err != nil {
t.Fatal(err)
}
if r, err := Restore(got, Parts{Library: false, Settings: true}); err != nil || r.Library {
t.Fatalf("library not picked: %+v %v", r, err)
}
if l, _ := library.Collect(); l.Texts["default"] != "" {
t.Fatal("the library came in unpicked")
}
r, err := Restore(got, All)
if err != nil || !r.Library {
t.Fatalf("restore: %+v %v", r, err)
}
l, _ := library.Collect()
if l.Texts["default"] != "Be brief." || len(l.Skills) != 1 || l.Skills[0].Name != "notes" || len(l.Skills[0].Files) != 1 {
t.Fatalf("library: %+v", l)
}
if h := l.MCP[0].Headers; h["Authorization"] != "Bearer here" || h["X-Org"] != "acme" {
t.Fatalf("server: %v", h)
}
}
func TestNotABackup(t *testing.T) {
for _, data := range []string{"", "{}", `{"format":"magpie-backup","version":9,"kdf":"x"}`} {
if _, err := Open([]byte(data), "pw"); err == nil || errors.Is(err, ErrPassphrase) {
t.Fatalf("%q: %v", data, err)
}
}
if _, err := Seal(Bundle{}, ""); err == nil {
t.Fatal("sealed with no passphrase")
}
}
func TestTampered(t *testing.T) {
data, err := Seal(Bundle{Version: 1}, "pw")
if err != nil {
t.Fatal(err)
}
// lowering the work of the key derivation is noticed
bad := strings.Replace(string(data), `"iterations": 600000`, `"iterations": 100000`, 1)
if _, err := Open([]byte(bad), "pw"); !errors.Is(err, ErrPassphrase) {
t.Fatalf("tampered header: %v", err)
}
}
func TestProfileKeys(t *testing.T) {
got := profileKeys(map[string]string{"codex.effort": "", "claude.model": "", "codex.provider": "", "codex.model": ""})
want := []string{"codex.provider", "claude.model", "codex.model", "codex.effort"}
if !slices.Equal(got, want) {
t.Fatalf("%v", got)
}
}