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)
}
}
}