package plugins import ( "errors" "os" "path/filepath" "strings" "testing" ) func TestCopilotInstallRejectsInvalidUserSourcesBeforeRuntimeWrites(t *testing.T) { tests := map[string]string{ "malformed JSON": "{", "array root": "[]\n", "missing version": `{"hooks":{}}`, "unsupported version": `{"version":2,"hooks":{}}`, "null hooks": `{"version":1,"hooks":null}`, "null event": `{"version":1,"hooks":{"preToolUse":null}}`, "null handler": `{"version":1,"hooks":{"preToolUse":[null]}}`, "invalid disable flag": `{"version":1,"disableAllHooks":"false",` + `"hooks":{}}`, } for name, source := range tests { t.Run(name, func(t *testing.T) { fixture := prepareCopilotFixture(t, copilotFixtureOptions{ userRaw: copilotString(source), }) if err := fixture.plugin.Install(fixture.config); err == nil { t.Fatal("install accepted an invalid GitHub Copilot user source") } assertCopilotFileEquals(t, fixture.configPath, source) assertCopilotRuntimeAbsent(t, fixture) }) } } func TestCopilotInstallRejectsDisabledUserSource(t *testing.T) { source := `{"version":1,"disableAllHooks":true,"hooks":{}}` fixture := prepareCopilotFixture(t, copilotFixtureOptions{ userRaw: copilotString(source), }) err := fixture.plugin.Install(fixture.config) if err == nil || !strings.Contains(err.Error(), "disableAllHooks") { t.Fatalf("install error = %v", err) } assertCopilotFileEquals(t, fixture.configPath, source) assertCopilotRuntimeAbsent(t, fixture) } func TestCopilotInstallRejectsInvalidProjectSourceBeforeWrites(t *testing.T) { source := `{"version":1,"hooks":{"preToolUse":[null]}}` fixture := prepareCopilotFixture(t, copilotFixtureOptions{ legacyRaw: copilotString(source), }) if err := fixture.plugin.Install(fixture.config); err == nil { t.Fatal("install accepted an invalid GitHub Copilot project source") } assertCopilotFileEquals(t, fixture.legacyPath, source) if _, err := os.Stat(fixture.configPath); !errors.Is(err, os.ErrNotExist) { t.Fatalf("user hook source exists after rejection: %v", err) } assertCopilotRuntimeAbsent(t, fixture) } func TestCopilotInstallRejectsMissingOrUnmanagedGuardBeforeWrites(t *testing.T) { t.Run("missing", func(t *testing.T) { fixture := prepareCopilotFixture(t, copilotFixtureOptions{skipGuard: true}) if err := fixture.plugin.Install(fixture.config); err == nil { t.Fatal("install accepted a missing guard runtime") } assertCopilotRuntimeAbsent(t, fixture) }) t.Run("unmanaged path", func(t *testing.T) { fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) unmanaged := filepath.Join(fixture.homeDir, "unmanaged-guard.js") if err := os.WriteFile(unmanaged, []byte("process.exit(2);\n"), 0700); err != nil { t.Fatalf("write unmanaged guard: %v", err) } fixture.config.GuardScriptPath = unmanaged if err := fixture.plugin.Install(fixture.config); err == nil { t.Fatal("install accepted an unmanaged guard runtime") } assertCopilotRuntimeAbsent(t, fixture) }) } func TestCopilotInstallRejectsSymlinkFiles(t *testing.T) { t.Run("guard runtime", func(t *testing.T) { fixture := prepareCopilotFixture(t, copilotFixtureOptions{skipGuard: true}) target := filepath.Join(fixture.homeDir, "guard-target.js") if err := os.WriteFile(target, []byte("process.exit(2);\n"), 0700); err != nil { t.Fatalf("write guard target: %v", err) } if err := os.Symlink(target, fixture.guardPath); err != nil { t.Skipf("create guard symlink: %v", err) } if err := fixture.plugin.Install(fixture.config); err == nil { t.Fatal("install accepted a symlink guard runtime") } assertCopilotRuntimeAbsent(t, fixture) }) t.Run("user source", func(t *testing.T) { fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) target := filepath.Join(fixture.homeDir, "copilot-target.json") writeCopilotObject(t, target, map[string]any{"version": float64(1), "hooks": map[string]any{}}) if err := os.MkdirAll(filepath.Dir(fixture.configPath), 0700); err != nil { t.Fatalf("create user hooks directory: %v", err) } if err := os.Symlink(target, fixture.configPath); err != nil { t.Skipf("create source symlink: %v", err) } if err := fixture.plugin.Install(fixture.config); err == nil { t.Fatal("install accepted a symlink hook source") } assertCopilotRuntimeAbsent(t, fixture) }) } func TestCopilotInstallRequiresNodeBeforeWrites(t *testing.T) { fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) t.Setenv("PATH", "") err := fixture.plugin.Install(fixture.config) if err == nil || !strings.Contains(err.Error(), "Node.js") { t.Fatalf("install error = %v", err) } assertCopilotRuntimeAbsent(t, fixture) } func TestCopilotStatusReportsMalformedRuntimeConfig(t *testing.T) { fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) installCopilotFixture(t, fixture) if err := os.WriteFile(fixture.runtimeConfig, []byte("{"), 0600); err != nil { t.Fatalf("write malformed runtime config: %v", err) } status, err := fixture.plugin.Status() if err == nil || status.Installed { t.Fatalf("Copilot status = %#v, %v", status, err) } } func TestCopilotStatusRejectsSymlinkRuntimeFiles(t *testing.T) { for _, name := range []string{"config", "guard", "audit"} { t.Run(name, func(t *testing.T) { fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) installCopilotFixture(t, fixture) path := map[string]string{ "config": fixture.runtimeConfig, "guard": fixture.guardPath, "audit": fixture.hookPath, }[name] content, err := os.ReadFile(path) if err != nil { t.Fatalf("read %s runtime: %v", name, err) } target := filepath.Join(fixture.homeDir, name+"-runtime-target") if err := os.WriteFile(target, content, 0600); err != nil { t.Fatalf("write %s runtime target: %v", name, err) } if err := os.Remove(path); err != nil { t.Fatalf("remove %s runtime: %v", name, err) } if err := os.Symlink(target, path); err != nil { t.Skipf("create %s runtime symlink: %v", name, err) } status, err := fixture.plugin.Status() if err == nil || status.Installed { t.Fatalf("Copilot status = %#v, %v", status, err) } }) } } func TestCopilotInstallRollsBackEveryFileOnCommitFailure(t *testing.T) { fixture := prepareCopilotFixture(t, copilotFixtureOptions{ userRaw: copilotJSON(map[string]any{ "version": float64(1), "hooks": map[string]any{ "notification": []any{map[string]any{"type": "command", "command": "keep-user"}}, }, }), }) writeCopilotObject(t, fixture.legacyPath, legacyCopilotConfig(fixture, map[string]any{ "notification": []any{map[string]any{"type": "command", "command": "keep-project"}}, })) userBefore, err := os.ReadFile(fixture.configPath) if err != nil { t.Fatalf("read user source: %v", err) } projectBefore, err := os.ReadFile(fixture.legacyPath) if err != nil { t.Fatalf("read project source: %v", err) } commits := 0 failed := false fixture.plugin.rename = func(source, destination string) error { if !failed && strings.HasSuffix(source, ".tmp") { commits++ if commits == 5 { failed = true return errors.New("injected Copilot commit failure") } } return os.Rename(source, destination) } err = fixture.plugin.Install(fixture.config) if err == nil || !strings.Contains(err.Error(), "injected Copilot commit failure") { t.Fatalf("install error = %v", err) } assertCopilotFileBytes(t, fixture.configPath, userBefore) assertCopilotFileBytes(t, fixture.legacyPath, projectBefore) assertCopilotRuntimeAbsent(t, fixture) assertNoCopilotTransactionArtifacts(t, fixture.homeDir, fixture.projectDir) } func TestCopilotInstallDetectsConcurrentSourceChangeAndRollsBack(t *testing.T) { fixture := prepareCopilotFixture(t, copilotFixtureOptions{ userRaw: copilotJSON(map[string]any{ "version": float64(1), "hooks": map[string]any{}, }), }) concurrent := []byte(`{"version":1,"hooks":{"notification":[{"type":"command","command":"concurrent"}]}}` + "\n") mutated := false fixture.plugin.rename = func(source, destination string) error { if !mutated && strings.HasSuffix(source, ".tmp") { mutated = true if err := os.WriteFile(fixture.configPath, concurrent, 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("install error = %v", err) } assertCopilotFileBytes(t, fixture.configPath, concurrent) assertCopilotRuntimeAbsent(t, fixture) assertNoCopilotTransactionArtifacts(t, fixture.homeDir, fixture.projectDir) } func assertCopilotRuntimeAbsent(t *testing.T, fixture *copilotFixture) { t.Helper() for _, path := range []string{fixture.runtimeConfig, fixture.privateKey, fixture.hookPath} { if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { t.Fatalf("runtime file exists at %s: %v", path, err) } } } func assertCopilotFileEquals(t *testing.T, path, expected string) { t.Helper() assertCopilotFileBytes(t, path, []byte(expected)) } func assertCopilotFileBytes(t *testing.T, path string, expected []byte) { t.Helper() actual, err := os.ReadFile(path) if err != nil || string(actual) != string(expected) { t.Fatalf("file %s = %q, %v; want %q", path, actual, err, expected) } } func assertNoCopilotTransactionArtifacts(t *testing.T, roots ...string) { t.Helper() for _, root := range roots { err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { if err != nil { return err } if strings.HasSuffix(entry.Name(), ".tmp") || strings.HasSuffix(entry.Name(), ".rollback") { t.Errorf("transaction artifact remains at %s", path) } return nil }) if err != nil { t.Fatalf("walk transaction root %s: %v", root, err) } } }