Go 166 lines
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)
}
}