ajhahn.de
← eeco
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)
	}
}