Files
Elydora-Go-SDK/cmd/elydora/plugins/droid_recovery_test.go
T

264 lines
9.5 KiB
Go

package plugins
import (
"encoding/json"
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
)
func TestDroidUninstallPreservesMixedGroupsAndLookalikes(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture)
root := readDroidTestObject(t, fixture.configPath)
group := droidManagedGroup(t, root["PreToolUse"], fixture.guardPath)
command := droidManagedHandler(t, []any{group}, fixture.guardPath)["command"].(string)
group["hooks"] = append(requireDroidArray(t, group["hooks"]), map[string]any{
"type": "command", "command": "user-command",
})
group["owner"] = "user"
root["PreToolUse"] = append(requireDroidArray(t, root["PreToolUse"]),
map[string]any{
"matcher": "*",
"hooks": []any{map[string]any{
"type": "command", "command": strings.Replace(command, droidGuardScript, droidGuardScript+".backup", 1), "timeout": 10,
}},
},
map[string]any{
"matcher": "*",
"hooks": []any{map[string]any{
"type": "command", "command": strings.Replace(command, "agent-1", "agent-10", 1), "timeout": 10,
}},
},
)
writeDroidTestObject(t, fixture.configPath, root)
if err := fixture.plugin.Uninstall(droidTestAgentID); err != nil {
t.Fatalf("uninstall Factory Droid hooks: %v", err)
}
remainingRaw := readDroidTestFile(t, fixture.configPath)
for _, marker := range []string{"user-command", droidGuardScript + ".backup", "agent-10"} {
if !strings.Contains(remainingRaw, marker) {
t.Fatalf("uninstall removed lookalike %q", marker)
}
}
remaining := readDroidTestObject(t, fixture.configPath)
if len(requireDroidArray(t, remaining["PostToolUse"])) != 0 {
t.Fatalf("PostToolUse after uninstall = %#v", remaining["PostToolUse"])
}
}
func TestDroidInstallRejectsMalformedSourcesBeforeWrites(t *testing.T) {
tests := []struct {
name string
hooks *string
legacy *string
settings *string
pattern string
}{
{"malformed hooks", droidString("{ malformed"), nil, nil, "parse Factory Droid hooks"},
{"non-object hooks", droidJSON([]any{}), nil, nil, "JSON object"},
{"duplicate event", droidString(`{ "PreToolUse": [], "PreToolUse": [] }`), nil, nil, "duplicate"},
{"nested duplicate", droidString(`{ "PreToolUse": [{ "hooks": [], "hooks": [] }] }`), nil, nil, "duplicate"},
{"null event", droidString(`{ "PreToolUse": null }`), nil, nil, "must be an array"},
{"null group", droidString(`{ "PreToolUse": [null] }`), nil, nil, "must be an object"},
{"invalid matcher", droidString(`{ "PreToolUse": [{ "matcher": "[", "hooks": [] }] }`), nil, nil, "regular expression"},
{"invalid handler", droidString(`{ "PreToolUse": [{ "hooks": [{ "type": "command", "command": 1 }] }] }`), nil, nil, "must be a string"},
{"invalid flag", droidString(`{ "hooksDisabled": "yes" }`), nil, nil, "must be a boolean"},
{"unsupported root field", droidString(`{ "theme": "dark" }`), nil, nil, "unsupported field"},
{"malformed legacy", nil, droidString("{ malformed"), nil, "legacy hooks"},
{"malformed settings", droidString(`{ "PreToolUse": [] }`), nil, droidString("{ malformed"), "settings"},
{"null settings hooks", nil, nil, droidString(`{ "hooks": null }`), "JSON object"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
hooks: test.hooks, legacyHooks: test.legacy, settings: test.settings,
})
originals := existingDroidSources(t, fixture)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), test.pattern) {
t.Fatalf("install error = %v, want substring %q", err, test.pattern)
}
for path, original := range originals {
if readDroidTestFile(t, path) != original {
t.Fatalf("failed install changed %s", path)
}
}
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
requireMissingDroidFile(t, path)
}
})
}
}
func TestDroidInstallValidatesManagedPathsBeforeWrites(t *testing.T) {
tests := []struct {
name string
mutate func(*droidFixture)
pattern string
}{
{
name: "missing guard",
mutate: func(fixture *droidFixture) {
if err := os.Remove(fixture.guardPath); err != nil {
t.Fatalf("remove guard: %v", err)
}
},
pattern: "guard runtime is missing",
},
{
name: "invalid agent id",
mutate: func(fixture *droidFixture) {
fixture.config.AgentID = "../agent"
},
pattern: "single non-empty path segment",
},
{
name: "guard outside managed directory",
mutate: func(fixture *droidFixture) {
fixture.config.GuardScriptPath = filepath.Join(fixture.homeDir, "guard.js")
},
pattern: "managed agent directory",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
test.mutate(fixture)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), test.pattern) {
t.Fatalf("install error = %v, want substring %q", err, test.pattern)
}
for _, path := range []string{fixture.configPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
requireMissingDroidFile(t, path)
}
})
}
}
func TestDroidTransactionRollsBackAllFilesAndCleansStaging(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
hooks: droidJSON(map[string]any{"PreToolUse": []any{}}),
settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}),
})
originalRoot := readDroidTestFile(t, fixture.configPath)
originalSettings := readDroidTestFile(t, fixture.settingsPath)
failed := false
fixture.plugin.rename = func(source, destination string) error {
if !failed && sameDroidPath(destination, fixture.settingsPath) {
failed = true
return errors.New("simulated settings commit failure")
}
return os.Rename(source, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "Write Factory Droid installation") || !failed {
t.Fatalf("transaction error = %v, failed = %v", err, failed)
}
if readDroidTestFile(t, fixture.configPath) != originalRoot ||
readDroidTestFile(t, fixture.settingsPath) != originalSettings {
t.Fatal("transaction rollback did not restore hook sources")
}
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
requireMissingDroidFile(t, path)
}
requireNoDroidStagingFiles(t, fixture.homeDir)
}
func TestDroidTransactionDetectsExternalChangesAndPreservesThem(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{
hooks: droidJSON(map[string]any{"PreToolUse": []any{}}),
settings: droidJSON(map[string]any{"hooks": map[string]any{"PostToolUse": []any{}}}),
})
external := "{\n \"external\": true,\n \"hooks\": { \"PostToolUse\": [] }\n}\n"
mutated := false
fixture.plugin.rename = func(source, destination string) error {
if !mutated {
mutated = true
if err := os.WriteFile(fixture.settingsPath, []byte(external), 0600); err != nil {
return err
}
}
return os.Rename(source, destination)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
t.Fatalf("external-change error = %v", err)
}
if readDroidTestFile(t, fixture.settingsPath) != external {
t.Fatal("transaction rollback overwrote an external settings change")
}
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
requireMissingDroidFile(t, path)
}
requireNoDroidStagingFiles(t, fixture.homeDir)
}
func TestDroidRecoveryCommandsDoNotRequireNodeResolution(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture)
t.Setenv("PATH", "")
status, err := fixture.plugin.Status()
if err != nil || !status.Installed {
t.Fatalf("status without PATH = %#v, %v", status, err)
}
if err := fixture.plugin.Uninstall(droidTestAgentID); err != nil {
t.Fatalf("uninstall without PATH: %v", err)
}
requireMissingDroidFile(t, fixture.configPath)
}
func TestDroidStatusSurfacesMalformedRuntimeConfig(t *testing.T) {
fixture := prepareDroidFixture(t, droidFixtureOptions{})
installDroidFixture(t, fixture)
if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil {
t.Fatalf("corrupt runtime config: %v", err)
}
if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "parse Elydora runtime config") {
t.Fatalf("status error = %v", err)
}
}
func writeDroidTestObject(t *testing.T, path string, value map[string]any) {
t.Helper()
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
t.Fatalf("encode %s: %v", path, err)
}
if err := os.WriteFile(path, append(encoded, '\n'), 0600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func existingDroidSources(t *testing.T, fixture *droidFixture) map[string]string {
t.Helper()
result := map[string]string{}
for _, path := range []string{fixture.configPath, fixture.legacyPath, fixture.settingsPath} {
if raw, err := os.ReadFile(path); err == nil {
result[path] = string(raw)
} else if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("read source %s: %v", path, err)
}
}
return result
}
func requireNoDroidStagingFiles(t *testing.T, root string) {
t.Helper()
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if strings.HasSuffix(entry.Name(), ".tmp") || strings.HasSuffix(entry.Name(), ".rollback") {
t.Errorf("staging file remains: %s", path)
}
return nil
})
if err != nil {
t.Fatalf("walk staging files: %v", err)
}
}