package hooks import ( "bytes" "errors" "os" "path/filepath" "reflect" "strings" "testing" "github.com/ajhahnde/eeco/internal/config" ) // Trust-boundary suite H1.6, invariant (b): every hook type stays reversible. // enable→disable restores the user's pre-install on-disk reality, and the // ledger round-trips. The single named guard is driven off intent (Names), // not off whatever each hook's author happened to test, so a future sixth // hook cannot silently slip the reversibility net. // hookCase pins one hook type's enable/disable round-trip: the cfg fixture // that satisfies its not-configured guard, the on-disk artifact whose // pre-install reality must be restored, and the namespace marker that must be // present after enable and gone after disable. type hookCase struct { name string cfg func(*testing.T) *config.Config enable func(*config.Config) (string, error) disable func(*config.Config) (string, error) artifact func(*config.Config) string marker string } func gitHookPath(c *config.Config, name string) string { return filepath.Join(c.RepoRoot, ".git", "hooks", name) } func boundaryHookCases() []hookCase { return []hookCase{ { name: PreCommit, cfg: func(t *testing.T) *config.Config { return newCfg(t, "") }, enable: EnablePreCommit, disable: DisablePreCommit, artifact: func(c *config.Config) string { return gitHookPath(c, "pre-commit") }, marker: preCommitMarker, }, { name: PostMerge, cfg: func(t *testing.T) *config.Config { return newCfg(t, "") }, enable: EnablePostMerge, disable: DisablePostMerge, artifact: func(c *config.Config) string { return gitHookPath(c, "post-merge") }, marker: postMergeMarker, }, { name: CommitMsg, cfg: func(t *testing.T) *config.Config { return newCfg(t, "") }, enable: EnableCommitMsg, disable: DisableCommitMsg, artifact: func(c *config.Config) string { return gitHookPath(c, "commit-msg") }, marker: commitMsgMarker, }, { name: SessionStart, cfg: func(t *testing.T) *config.Config { return sessionCfg(t, "CLAUDE.md") }, enable: EnableSessionStart, disable: DisableSessionStart, artifact: func(c *config.Config) string { return filepath.Join(c.RepoRoot, "CLAUDE.md") }, marker: sessionStartMarker, }, { name: CommitGuard, cfg: func(t *testing.T) *config.Config { return newCfg(t, filepath.Join(t.TempDir(), "settings.json")) }, enable: EnableCommitGuard, disable: DisableCommitGuard, artifact: func(c *config.Config) string { return c.SessionSettingsPath }, marker: commitGuardToken, }, } } func TestBoundary_AllHooksReversible(t *testing.T) { cases := boundaryHookCases() if len(cases) != len(Names) { t.Fatalf("reversibility cases = %d, hooks.Names = %d — a new hook in Names lacks a reversibility case", len(cases), len(Names)) } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { cfg := tc.cfg(t) art := tc.artifact(cfg) // Pre-install reality: every artifact in this matrix is absent. if _, err := os.Stat(art); !errors.Is(err, os.ErrNotExist) { t.Fatalf("precondition: artifact %s should be absent, stat err=%v", art, err) } if _, err := tc.enable(cfg); err != nil { t.Fatalf("enable: %v", err) } b, err := os.ReadFile(art) if err != nil { t.Fatalf("artifact missing after enable: %v", err) } if !strings.Contains(string(b), tc.marker) { t.Fatalf("enabled artifact lacks the eeco marker %q:\n%s", tc.marker, b) } if _, err := tc.disable(cfg); err != nil { t.Fatalf("disable: %v", err) } // Restored pre-install reality: the artifact is absent again, OR a // now-empty managed file that no longer carries the eeco marker // (the JSON channel leaves a stripped {} behind by design). rb, rerr := os.ReadFile(art) if errors.Is(rerr, os.ErrNotExist) { return } if rerr != nil { t.Fatalf("re-read artifact after disable: %v", rerr) } if strings.Contains(string(rb), tc.marker) { t.Errorf("disable did not restore pre-install state — marker %q still present:\n%s", tc.marker, rb) } }) } } // TestBoundary_HookLedgerRoundTrips pins the reversibility record itself: a // fully-populated ledger (all 5 records, session-start carrying a fileRecord) // survives save→load→save byte-identically and parses back equal. func TestBoundary_HookLedgerRoundTrips(t *testing.T) { cfg := newCfg(t, "") at := "2026-05-31T00:00:00Z" want := ledger{ PreCommit: record{Installed: true, Path: "/h/pre-commit", SHA256: "aa", At: at}, PostMerge: record{Installed: true, Path: "/h/post-merge", SHA256: "bb", At: at}, SessionStart: record{Installed: true, Path: "/s/settings.json", Backup: "/b/sess.json", At: at, Files: []fileRecord{{Path: "/r/CLAUDE.md", SHA256: "cc", Created: true}}}, CommitMsg: record{Installed: true, Path: "/h/commit-msg", SHA256: "dd", At: at}, CommitGuard: record{Installed: true, Path: "/s/settings.json", Backup: "/b/guard.json", At: at}, } if err := saveLedger(cfg, want); err != nil { t.Fatalf("saveLedger: %v", err) } got, err := loadLedger(cfg) if err != nil { t.Fatalf("loadLedger: %v", err) } if !reflect.DeepEqual(got, want) { t.Fatalf("ledger round-trip mismatch:\n got %+v\nwant %+v", got, want) } first := mustRead(t, ledgerPath(cfg)) if err := saveLedger(cfg, got); err != nil { t.Fatalf("re-save: %v", err) } second := mustRead(t, ledgerPath(cfg)) if !bytes.Equal(first, second) { t.Errorf("ledger bytes not stable across save→load→save:\nfirst:\n%s\nsecond:\n%s", first, second) } }