33package dockertest
44
55import (
6+ "bytes"
67 "fmt"
78 "net/http"
89 "os"
@@ -98,9 +99,18 @@ func TestImageShipsBothBinaries(t *testing.T) {
9899//
99100// `hooks init` prints a one-time admin token and a bootstrap signup code on
100101// stdout. Both are credentials, so we never put the raw output in t.Fatalf
101- // messages — CI logs are public on PRs. We assert against a sentinel string
102- // and report only that the assertion failed, never the content.
102+ // messages — CI logs are public on PRs.
103103func scaffoldDataDir (t * testing.T ) string {
104+ t .Helper ()
105+ dir , _ := scaffoldDataDirCapturingToken (t )
106+ return dir
107+ }
108+
109+ // scaffoldDataDirCapturingToken is scaffoldDataDir plus the one-time admin
110+ // token. Caller MUST treat the returned token as a secret — never put it
111+ // into t.Logf / t.Fatalf or any output that lands in CI logs. The redact
112+ // helper exists precisely so this is easy to do.
113+ func scaffoldDataDirCapturingToken (t * testing.T ) (string , string ) {
104114 t .Helper ()
105115 dir := t .TempDir ()
106116 if err := os .Chmod (dir , 0o777 ); err != nil {
@@ -113,10 +123,51 @@ func scaffoldDataDir(t *testing.T) string {
113123 if err != nil {
114124 t .Fatalf ("hooks init: %v (output redacted: contains one-time admin token)" , err )
115125 }
116- if ! strings .Contains (string (out ), "admin token (shown ONCE)" ) {
117- t .Fatal ("init did not print the admin-token line (output redacted)" )
126+ token := extractAdminToken (out )
127+ if token == "" {
128+ t .Fatal ("init did not print an admin-token line (output redacted)" )
118129 }
119- return dir
130+ return dir , token
131+ }
132+
133+ func extractAdminToken (out []byte ) string {
134+ const marker = "admin token (shown ONCE):"
135+ for _ , line := range strings .Split (string (out ), "\n " ) {
136+ // HasPrefix on the trimmed line — substring matching would silently
137+ // return any text that follows the token if `hooks init` is ever
138+ // changed to print extra context on the same line, breaking the
139+ // "token never leaks past this helper" invariant.
140+ trimmed := strings .TrimLeft (line , " \t " )
141+ if ! strings .HasPrefix (trimmed , marker ) {
142+ continue
143+ }
144+ return strings .TrimSpace (trimmed [len (marker ):])
145+ }
146+ return ""
147+ }
148+
149+ // redact strips occurrences of the secret from buf so the result is safe
150+ // to include in test logs. Used on `docker exec` output that may echo the
151+ // HOOKS_TOKEN env we passed in.
152+ func redact (buf []byte , secret string ) []byte {
153+ if secret == "" {
154+ return buf
155+ }
156+ return bytes .ReplaceAll (buf , []byte (secret ), []byte ("[REDACTED]" ))
157+ }
158+
159+ // tokenListContainsName checks for `name` as a whitespace-anchored field in
160+ // `hooksctl token list` output. Substring matching would over-accept a
161+ // future header or help banner that mentions the same word.
162+ func tokenListContainsName (out []byte , name string ) bool {
163+ for _ , line := range strings .Split (string (out ), "\n " ) {
164+ for _ , field := range strings .Fields (line ) {
165+ if field == name {
166+ return true
167+ }
168+ }
169+ }
170+ return false
120171}
121172
122173func TestImageInitScaffold (t * testing.T ) {
@@ -273,6 +324,174 @@ func TestImageRestartPreservesState(t *testing.T) {
273324 }
274325}
275326
327+ // shutdownDeadline mirrors the WithTimeout value in cmd/hooks/main.go's
328+ // signal-handler goroutine. Kept in sync by the SIGTERM test below — if the
329+ // binary's deadline ever changes, update this too.
330+ const shutdownDeadline = 30 * time .Second
331+
332+ // TestImageGracefulShutdownOnSIGTERM verifies that `docker stop` (which
333+ // sends SIGTERM, then SIGKILL after a grace period) lets the binary exit
334+ // cleanly via its signal.NotifyContext path rather than getting hard-killed.
335+ // A failed graceful shutdown shows up as exit code 137 (128 + SIGKILL) and
336+ // `docker stop` taking the full grace period; a successful one returns
337+ // quickly with exit code 0. The container is started without --rm so we
338+ // can read .State.ExitCode after stop; cleanup goes through cleanupContainer
339+ // rather than relying on the daemon to remove it.
340+ func TestImageGracefulShutdownOnSIGTERM (t * testing.T ) {
341+ skipIfNoDocker (t )
342+ dir := scaffoldDataDir (t )
343+
344+ name := fmt .Sprintf ("hooks-dockertest-sigterm-%d" , time .Now ().UnixNano ())
345+ t .Cleanup (func () { cleanupContainer (t , name ) })
346+
347+ if out , err := exec .Command ("docker" , "run" , "-d" ,
348+ "--name" , name ,
349+ "-v" , dir + ":/data" ,
350+ "-e" , "RENDER_WEBHOOK_SECRET=stub-for-tests" ,
351+ "-p" , "0:8080" ,
352+ imageTag ,
353+ ).CombinedOutput (); err != nil {
354+ t .Fatalf ("docker run: %v\n %s" , err , out )
355+ }
356+
357+ addr := "http://127.0.0.1:" + hostPort (t , name , "8080/tcp" )
358+ if err := waitForHealthz (addr , 60 * time .Second ); err != nil {
359+ t .Fatalf ("server: %v\n logs:\n %s" , err , dockerLogs (name ))
360+ }
361+
362+ // `docker stop -t` exceeds shutdownDeadline by 5s so a slow CI runner
363+ // doesn't SIGKILL a graceful-but-slow shutdown.
364+ stopGrace := shutdownDeadline + 5 * time .Second
365+ start := time .Now ()
366+ if out , err := exec .Command ("docker" , "stop" , "-t" ,
367+ fmt .Sprintf ("%d" , int (stopGrace .Seconds ())), name ).CombinedOutput (); err != nil {
368+ t .Fatalf ("docker stop: %v\n %s" , err , out )
369+ }
370+ elapsed := time .Since (start )
371+
372+ out , err := exec .Command ("docker" , "inspect" ,
373+ "--format" , "{{.State.ExitCode}}" ,
374+ name ,
375+ ).CombinedOutput ()
376+ if err != nil {
377+ t .Fatalf ("docker inspect: %v\n %s" , err , out )
378+ }
379+ code := strings .TrimSpace (string (out ))
380+ if code != "0" {
381+ t .Fatalf ("graceful shutdown failed: exit=%s after %v\n logs:\n %s" ,
382+ code , elapsed , dockerLogs (name ))
383+ }
384+ if elapsed > shutdownDeadline {
385+ t .Fatalf ("docker stop took %v, longer than the binary's %v shutdown deadline" ,
386+ elapsed , shutdownDeadline )
387+ }
388+ }
389+
390+ // TestImageHooksctlAgainstRunningServer boots the server in the container
391+ // and runs `hooksctl token list` from inside the same container against
392+ // 127.0.0.1:8080. Proves the shipped hooksctl can talk to the shipped hooks
393+ // over a real TCP loopback inside the image — a property unit tests can't
394+ // cover because they swap in httptest servers and a host-built hooksctl.
395+ //
396+ // The admin token is captured from `hooks init` and passed to docker exec
397+ // via -e HOOKS_TOKEN= so it never lands in argv (and never in the test
398+ // log; we redact before printing failure output).
399+ func TestImageHooksctlAgainstRunningServer (t * testing.T ) {
400+ skipIfNoDocker (t )
401+ dir , token := scaffoldDataDirCapturingToken (t )
402+
403+ name := fmt .Sprintf ("hooks-dockertest-ctl-%d" , time .Now ().UnixNano ())
404+ // Register cleanup before the run so a failure between the two lines
405+ // can't leak the container; `docker rm -f` on a not-yet-created name
406+ // is a harmless no-op (logged by cleanupContainer).
407+ t .Cleanup (func () { cleanupContainer (t , name ) })
408+ if out , err := exec .Command ("docker" , "run" , "-d" , "--rm" ,
409+ "--name" , name ,
410+ "-v" , dir + ":/data" ,
411+ "-e" , "RENDER_WEBHOOK_SECRET=stub-for-tests" ,
412+ "-p" , "0:8080" ,
413+ imageTag ,
414+ ).CombinedOutput (); err != nil {
415+ t .Fatalf ("docker run: %v\n %s" , err , out )
416+ }
417+
418+ addr := "http://127.0.0.1:" + hostPort (t , name , "8080/tcp" )
419+ if err := waitForHealthz (addr , 60 * time .Second ); err != nil {
420+ t .Fatalf ("server: %v\n logs:\n %s" , err , dockerLogs (name ))
421+ }
422+
423+ // HOOKS_SERVER targets the in-container listener port (8080, EXPOSEd by
424+ // the Dockerfile), not the random host-mapped port — exec runs inside
425+ // the container's network namespace.
426+ cmd := exec .Command ("docker" , "exec" ,
427+ "-e" , "HOOKS_TOKEN=" + token ,
428+ "-e" , "HOOKS_SERVER=http://127.0.0.1:8080" ,
429+ name ,
430+ "hooksctl" , "token" , "list" ,
431+ )
432+ out , err := cmd .CombinedOutput ()
433+ safe := redact (out , token )
434+ if err != nil {
435+ t .Fatalf ("hooksctl token list: %v\n %s\n logs:\n %s" , err , safe , dockerLogs (name ))
436+ }
437+ // `hooks init` mints the admin token under the default name "operator".
438+ // Anchor with whitespace boundaries so a future header rename or help
439+ // banner that happens to contain "operator" can't satisfy this.
440+ if ! tokenListContainsName (out , "operator" ) {
441+ t .Fatalf ("token list missing the operator-named admin token\n output:\n %s" , safe )
442+ }
443+ }
444+
445+ // TestImageInitFailsClearlyOn0o755HostDir documents what an operator hits
446+ // when they follow the README literally — `mkdir -p ./hooks-data` produces
447+ // a 0o755 directory owned by their host user. The container runs as UID
448+ // 65532 (non-root), so bind-mount writes inside /data hit EACCES. We don't
449+ // try to "fix" this in the image (chowning /data inside the container
450+ // would require running as root or an init script); we test that the
451+ // failure is loud (non-zero exit, "permission denied" in stderr).
452+ //
453+ // Skips on platforms that translate UIDs across the bind mount (Docker
454+ // Desktop with file sharing typically does this on macOS) — there the
455+ // scenario doesn't manifest, so there's nothing to assert. The probe runs
456+ // `touch /data/probe` from inside the container against a 0o755 host dir
457+ // and skips if the touch succeeds.
458+ func TestImageInitFailsClearlyOn0o755HostDir (t * testing.T ) {
459+ skipIfNoDocker (t )
460+ dir := t .TempDir ()
461+ // Force 0o755 to model the README path exactly (`mkdir -p ./hooks-data`
462+ // with default umask 022). t.TempDir defaults to 0o700 — both block a
463+ // non-owner UID, but 0o755 is the failure operators actually report.
464+ if err := os .Chmod (dir , 0o755 ); err != nil {
465+ t .Fatalf ("chmod tempdir: %v" , err )
466+ }
467+
468+ probe , err := exec .Command ("docker" , "run" , "--rm" ,
469+ "-v" , dir + ":/data" ,
470+ "--entrypoint" , "sh" ,
471+ imageTag , "-c" , "touch /data/probe" ,
472+ ).CombinedOutput ()
473+ if err == nil {
474+ t .Skipf ("docker bind mount allows non-owner writes on this host " +
475+ "(likely a UID-translating setup like Docker Desktop file sharing); " +
476+ "the README permissions edge case doesn't manifest here.\n probe output: %s" , probe )
477+ }
478+
479+ out , runErr := exec .Command ("docker" , "run" , "--rm" ,
480+ "-v" , dir + ":/data" ,
481+ imageTag , "init" ,
482+ ).CombinedOutput ()
483+ if runErr == nil {
484+ // Init succeeded → admin-token line was printed; never echo `out`.
485+ t .Fatal ("expected `hooks init` to fail on a 0o755 host dir, but it succeeded (output redacted: contains one-time admin token)" )
486+ }
487+ if ! bytes .Contains (bytes .ToLower (out ), []byte ("permission denied" )) {
488+ // init returned non-zero, so by cmd/hooks/main.go's order it didn't
489+ // reach the admin-token print site — `out` is safe to surface and
490+ // the diagnostic value is high (tells you what error did fire).
491+ t .Fatalf ("expected permission-denied error in init output, got:\n %s" , out )
492+ }
493+ }
494+
276495// waitForHealthz polls /healthz on the running container until it returns
277496// 200 or the deadline expires. Server-side errors (5xx) are preserved
278497// across iterations — if the server ever returned 500 then died, the
0 commit comments