Skip to content

Commit 9a0f3f0

Browse files
Merge pull request #4 from OneBusAway/pr4
test(docker): close SIGTERM, hooksctl-vs-server, 0o755 coverage gaps from PR #2
2 parents 74431bd + 9215a11 commit 9a0f3f0

1 file changed

Lines changed: 224 additions & 5 deletions

File tree

dockertest/docker_test.go

Lines changed: 224 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package dockertest
44

55
import (
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.
103103
func 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

122173
func 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\nlogs:\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\nlogs:\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\nlogs:\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\nlogs:\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\noutput:\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.\nprobe 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

Comments
 (0)