Skip to content

Make main() and the security gates testable (closes #13) - #39

Merged
dolph merged 1 commit into
mainfrom
claude/issue-13-main-testability
May 22, 2026
Merged

Make main() and the security gates testable (closes #13)#39
dolph merged 1 commit into
mainfrom
claude/issue-13-main-testability

Conversation

@dolph

@dolph dolph commented Apr 27, 2026

Copy link
Copy Markdown
Owner

Closes #13.

Summary

Refactors main() and two of the security gates so the gate sequencing and the bit-mask logic are exercisable in-process. Coverage 42.7% → 62.4% (+19.7pts). No production behavior change — same gates fire in the same order with the same user-visible messages.

This addresses the issue's two remaining acceptance criteria after PRs #29 and #38 took the args-handling piece:

  • main refactored for testability (closes the "0% on main" gap).
  • Each security.go gate has positive and negative tests (closes the "0% on the security gates" gap).

Run's coverage is still tracked separately by #14 (the integration-test issue) — this PR injects runFn so the test for runMain can assert "was Run called?" without actually invoking it.

Refactor shape

security.go

func isExecutableWritable() bool {
    p, err := os.Executable()
    if err != nil { /* unchanged */ }
    return isPathUnsafelyWritable(p)
}

// New: testable against any chmod'd temp file.
func isPathUnsafelyWritable(path string) bool { ... }

// New: testable with uid 0/1/1000 directly.
func uidIsRoot(uid int) bool { return uid == 0 }

func isRunningAsRoot() bool { return uidIsRoot(os.Getuid()) }

ussher.go

type runDeps struct {
    executableWritable func() bool
    runningAsRoot      func() bool
    initLog            func()
    validUser          func(name string) bool
    loadConfig         func(username string, c *Config)
    runFn              func(*Config)
}

func defaultDeps() runDeps { /* production wiring */ }

func runMain(args []string, d runDeps) error {
    // same gate ordering as old main(), returns errors instead of log.Fatal
}

func main() {
    if err := runMain(os.Args, defaultDeps()); err != nil {
        log.Fatal(err)
    }
}

User-visible error messages — "Refusing to run due to permissions issue on the ussher executable", "Refusing to run as root", "User not found" — are preserved verbatim so the existing README troubleshooting headings still match.

The fmt.Println diagnostics inside isPathUnsafelyWritable now include the path being checked ("/usr/local/bin/ussher is group writable" instead of "ussher binary is group writable"). Marginal log-quality improvement; still going to stdout, which is the wrong channel — that's #36, deliberately not in this PR.

Tests added

File Test What it covers
ussher_test.go TestRunMain 6 table-driven cases: missing args, --version short-circuits before any gate (asserted by setting all gates to "would fail" and verifying nothing fired), each gate's failure path, happy path.
ussher_test.go TestDefaultDeps_AllSet Sanity that the production wiring leaves nothing nil.
security_test.go TestUidIsRoot uid 0 → true; 1, 500, 1000, -1 → false.
security_test.go TestIsPathUnsafelyWritable 8 modes (0700, 0750, 0755, 0770, 0775, 0707, 0757, 0777) plus a missing-path failsafe case. Uses t.TempDir() and os.Chmod to defeat umask.
security_test.go TestIsFileWorldWritable Previously-untested function: non-world-writable, world-writable, missing file.

captureRunMain builds a runDeps that records initLog / loadConfig / runFn invocations so each test can assert "did we short-circuit before getting here?".

Test plan

  • go test -race -cover ./... green.
  • Coverage 42.7% → 62.4%.
  • ./build.sh green.
  • Production smoke: ./ussher (no args), ./ussher --version, ./ussher Nonexistent all behave as before — same exit codes, same user-visible messages.
  • CI shellcheck + build jobs both green on the PR.

Out of scope

https://claude.ai/code/session_013HnepY8MhhxrJJjE5ysW47


Generated by Claude Code

Closes #13.

main() previously did all gate sequencing inline and log.Fatal'd on
each failure, which makes it impossible to exercise in-process. Two
pieces of the test gap were tied to that:

(1) main() was 0% covered. Coverage came from leaf utilities, not
    from the orchestration. A regression in the gate ordering (or
    in the new validateArgs from #38) couldn't be caught by `go
    test`.

(2) The security gates themselves were 0% covered. isExecutableWritable
    looked at /proc/self/exe; isRunningAsRoot called os.Getuid()
    directly; isFileWorldWritable was defined but never called or
    tested. None could be driven by a test without elaborate
    chmod-the-test-binary-then-re-exec trickery.

Refactor:

- security.go: isPathUnsafelyWritable(path) extracted from
  isExecutableWritable; the latter now just resolves os.Executable()
  and delegates. Same goes for uidIsRoot(uid), called by
  isRunningAsRoot. Production callers see no behavior change; tests
  can chmod arbitrary temp files and pass them to the predicate, or
  pass uid 0/1/1000 directly to uidIsRoot.

- ussher.go: runDeps struct holds the side-effecting hooks
  (executableWritable, runningAsRoot, initLog, validUser, loadConfig,
  runFn). defaultDeps() wires production. runMain(args, deps) error
  carries the same gate ordering as the old main but returns an
  error instead of calling log.Fatal. main() is now four lines:
  call runMain(os.Args, defaultDeps()), log.Fatal on error.

- User-visible error messages preserved verbatim ("Refusing to run
  due to permissions issue on the ussher executable", "Refusing to
  run as root", "User not found") so the existing README
  troubleshooting headings still match what operators see in their
  log files.

- The fmt.Println diagnostics inside isPathUnsafelyWritable now
  include the path being checked so a future error log says
  "/usr/local/bin/ussher is group writable" instead of the
  hard-coded "ussher binary is group writable". (fmt.Println still
  goes to stdout, which is the wrong channel for sshd-consumed
  output - that's #36, deliberately not in this PR.)

Tests added:

- ussher_test.go grows TestRunMain (table-driven, six cases covering
  missing args / version short-circuit / each gate's failure path /
  happy path) and TestDefaultDeps_AllSet (sanity check that the
  production wiring leaves nothing nil). The captureRunMain helper
  builds a deps that records initLog/loadConfig/runFn invocations so
  tests can assert "did we short-circuit before we got here?".

- security_test.go grows TestUidIsRoot, TestIsPathUnsafelyWritable
  (table-driven across 0700/0750/0755/0770/0775/0707/0757/0777 plus
  a missing-path failsafe case), and TestIsFileWorldWritable
  (already-public function that wasn't previously tested).

Coverage 42.7% -> 62.4% (+19.7pts). All tests pass under -race.

#14 (integration test for Run) is still distinct - this PR doesn't
exercise the goroutine fan-out itself, only the path that decides
whether Run gets called. #14 lands the runFn coverage; this PR lands
the main()/gate coverage.

CHANGELOG bullet under [Unreleased] / Changed (refactor, no
adopter-visible behavior change).
@Jah-yee

Jah-yee commented May 11, 2026

Copy link
Copy Markdown

Hi! Just checking in on this PR — it's been open for 14 days without a review. Could you please take a look? Happy to make any changes if needed. Thanks! 🙏

@Jah-yee

Jah-yee commented May 11, 2026

Copy link
Copy Markdown

Hi! Just following up on this PR — it's been another day without a review. Happy to address any feedback or make changes. Thanks for your time! 🙏

@Jah-yee

Jah-yee commented May 11, 2026

Copy link
Copy Markdown

Friendly ping @dolph 👋 This PR has been waiting ~14 days for review. The fix adds an 8-line config file safety check using the existing isFileWorldWritable helper — purely additive, no breaking changes. Is there anything I can adjust to get this merged? 🙏

@dolph
dolph merged commit 38844ab into main May 22, 2026
4 checks passed
@dolph
dolph deleted the claude/issue-13-main-testability branch May 22, 2026 11:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0% test coverage on Run, main, and the security startup gates

3 participants