ajhahn.de
← eeco
Go 225 lines
package workflow

import (
	"os"
	"path/filepath"
	"testing"
	"time"
)

// writeSentinel creates a fresh authorization sentinel for kind under dir.
func writeSentinel(t *testing.T, dir, kind string) string {
	t.Helper()
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatal(err)
	}
	p := filepath.Join(dir, "git-"+kind+"-authorized")
	if err := os.WriteFile(p, nil, 0o600); err != nil {
		t.Fatal(err)
	}
	return p
}

func TestScanGitWriteGuard_UnauthorizedCommitDenies(t *testing.T) {
	det := newGuardDetector(t)
	res := ScanGitWriteGuard(det, `git commit -m "fix: x"`, t.TempDir(), t.TempDir(), ".eeco")
	if res.Decision != decisionDeny {
		t.Fatalf("unauthorized commit: Decision=%q, want deny", res.Decision)
	}
	if len(res.Consumed) != 0 {
		t.Errorf("a deny must not consume a sentinel, got %v", res.Consumed)
	}
}

func TestScanGitWriteGuard_AuthorizedCommitAllowsAndConsumes(t *testing.T) {
	det := newGuardDetector(t)
	orig := stagedDiff
	defer func() { stagedDiff = orig }()
	stagedDiff = func(string) string { return "" }

	state := t.TempDir()
	writeSentinel(t, state, "commit")
	res := ScanGitWriteGuard(det, `git commit -m "fix: a real change"`, t.TempDir(), state, ".eeco")
	if res.Decision != decisionAllow {
		t.Fatalf("authorized clean commit: Decision=%q reason=%q, want allow", res.Decision, res.Reason)
	}
	if len(res.Consumed) != 1 || res.Consumed[0] != "commit" {
		t.Errorf("Consumed=%v, want [commit]", res.Consumed)
	}
}

func TestScanGitWriteGuard_StaleSentinelDeniesAndClears(t *testing.T) {
	det := newGuardDetector(t)
	state := t.TempDir()
	p := writeSentinel(t, state, "commit")
	old := time.Now().Add(-30 * time.Minute)
	if err := os.Chtimes(p, old, old); err != nil {
		t.Fatal(err)
	}
	res := ScanGitWriteGuard(det, `git commit -m x`, t.TempDir(), state, ".eeco")
	if res.Decision != decisionDeny {
		t.Fatalf("stale sentinel: Decision=%q, want deny", res.Decision)
	}
	if _, err := os.Stat(p); !os.IsNotExist(err) {
		t.Errorf("stale sentinel should have been cleared, stat err=%v", err)
	}
}

func TestScanGitWriteGuard_AuthorizedCommitWithAttributionDeniesPreserved(t *testing.T) {
	det := newGuardDetector(t)
	orig := stagedDiff
	defer func() { stagedDiff = orig }()
	stagedDiff = func(string) string { return "" }

	state := t.TempDir()
	p := writeSentinel(t, state, "commit")
	cmd := `git commit -m "fix: x" -m "` + coTrailer() + `"`
	res := ScanGitWriteGuard(det, cmd, t.TempDir(), state, ".eeco")
	if res.Decision != decisionDeny {
		t.Fatalf("authorized commit carrying a trailer: Decision=%q, want deny", res.Decision)
	}
	if len(res.Consumed) != 0 {
		t.Errorf("gate-deny must preserve the sentinel, Consumed=%v", res.Consumed)
	}
	if _, err := os.Stat(p); err != nil {
		t.Errorf("sentinel must survive a gate-deny, stat err=%v", err)
	}
}

func TestScanGitWriteGuard_AuthorizedCommitWorkspaceLeakDenies(t *testing.T) {
	det := newGuardDetector(t)
	orig := stagedDiff
	defer func() { stagedDiff = orig }()
	// Use a neutral workspace name in the fixture (not the repo's real ".eeco")
	// so the leak literal in this test source does not trip the repo's own
	// leak-guard, mirroring the other gate tests.
	stagedDiff = func(string) string {
		return "diff --git a/x b/x\n+see ws/state/queue.md for details\n"
	}
	state := t.TempDir()
	writeSentinel(t, state, "commit")
	res := ScanGitWriteGuard(det, `git commit -m "fix"`, t.TempDir(), state, "ws")
	if res.Decision != decisionDeny {
		t.Fatalf("staged workspace-path leak: Decision=%q, want deny", res.Decision)
	}
}

func TestScanGitWriteGuard_TagMutationGated(t *testing.T) {
	det := newGuardDetector(t)
	state := t.TempDir()

	// Unauthorized mutation denies.
	if res := ScanGitWriteGuard(det, `git tag -a v1 -m x`, t.TempDir(), state, ".eeco"); res.Decision != decisionDeny {
		t.Errorf("unauthorized tag mutation: Decision=%q, want deny", res.Decision)
	}
	// Authorized mutation allows + consumes.
	writeSentinel(t, state, "tag")
	res := ScanGitWriteGuard(det, `git tag v1`, t.TempDir(), state, ".eeco")
	if res.Decision != decisionAllow {
		t.Fatalf("authorized tag create: Decision=%q, want allow", res.Decision)
	}
	if len(res.Consumed) != 1 || res.Consumed[0] != "tag" {
		t.Errorf("Consumed=%v, want [tag]", res.Consumed)
	}
}

func TestScanGitWriteGuard_ReadOnlyTagAndGitPass(t *testing.T) {
	det := newGuardDetector(t)
	for _, cmd := range []string{
		`git tag`,
		`git tag -l`,
		`git tag -n5`,
		`git status`,
		`git log --oneline`,
		`echo "git commit -m bad"`,
		`ls -la && pwd`,
	} {
		res := ScanGitWriteGuard(det, cmd, t.TempDir(), t.TempDir(), ".eeco")
		if res.Decision != decisionAllow {
			t.Errorf("%q: Decision=%q, want allow", cmd, res.Decision)
		}
	}
}

func TestScanGitWriteGuard_FailClosedOnParseError(t *testing.T) {
	det := newGuardDetector(t)
	// An unterminated single quote cannot be tokenized cleanly; the raw text
	// shows `git commit`, so the guard fails CLOSED and denies.
	cmd := `git commit -m 'unterminated`
	res := ScanGitWriteGuard(det, cmd, t.TempDir(), t.TempDir(), ".eeco")
	if res.Decision != decisionDeny {
		t.Fatalf("parse-error with git commit substring: Decision=%q, want deny (fail-closed)", res.Decision)
	}
}

func TestScanGitWriteGuard_WrapperBackstopDenies(t *testing.T) {
	det := newGuardDetector(t)
	for _, cmd := range []string{
		`bash -c "git commit -m x"`,
		`sh -c 'git tag v9'`,
		`eval "git commit -m y"`,
	} {
		res := ScanGitWriteGuard(det, cmd, t.TempDir(), t.TempDir(), ".eeco")
		if res.Decision != decisionDeny {
			t.Errorf("wrapped write %q: Decision=%q, want deny", cmd, res.Decision)
		}
	}
}

func TestScanGitWriteGuard_ChainedUnauthorizedCommitDenies(t *testing.T) {
	det := newGuardDetector(t)
	res := ScanGitWriteGuard(det, `git add . && git commit -m subject`, t.TempDir(), t.TempDir(), ".eeco")
	if res.Decision != decisionDeny {
		t.Errorf("chained unauthorized commit: Decision=%q, want deny", res.Decision)
	}
}

func TestClassifyGitWrite(t *testing.T) {
	cases := []struct {
		words []string
		verb  string
		mut   bool
	}{
		{[]string{"git", "commit", "-m", "x"}, "commit", false},
		{[]string{"git", "-C", "/r", "commit"}, "commit", false},
		{[]string{"GIT_AUTHOR_NAME=bot", "git", "commit"}, "commit", false},
		{[]string{"git", "tag"}, "tag", false},
		{[]string{"git", "tag", "-l"}, "tag", false},
		{[]string{"git", "tag", "v1"}, "tag", true},
		{[]string{"git", "tag", "-a", "v1", "-m", "x"}, "tag", true},
		{[]string{"git", "tag", "-d", "v1"}, "tag", true},
		{[]string{"git", "status"}, "status", false},
		{[]string{"git", "--", "commit"}, "", false},
		{[]string{"echo", "git", "commit"}, "", false},
	}
	for _, c := range cases {
		verb, mut := classifyGitWrite(c.words)
		if verb != c.verb || mut != c.mut {
			t.Errorf("classifyGitWrite(%v) = (%q,%v), want (%q,%v)", c.words, verb, mut, c.verb, c.mut)
		}
	}
}

func TestCommandParseOK(t *testing.T) {
	ok := []string{
		`git commit -m "fix"`,
		`git commit -m 'fix'`,
		`git commit -m "a \"quoted\" word"`,
		`git status`,
	}
	for _, c := range ok {
		if !commandParseOK(c) {
			t.Errorf("commandParseOK(%q) = false, want true", c)
		}
	}
	bad := []string{
		`git commit -m 'unterminated`,
		`git commit -m "open`,
	}
	for _, c := range bad {
		if commandParseOK(c) {
			t.Errorf("commandParseOK(%q) = true, want false", c)
		}
	}
}