diff --git a/internal/daemon/fcproc/fcproc_test.go b/internal/daemon/fcproc/fcproc_test.go new file mode 100644 index 0000000..34464e8 --- /dev/null +++ b/internal/daemon/fcproc/fcproc_test.go @@ -0,0 +1,192 @@ +package fcproc + +import ( + "context" + "errors" + "log/slog" + "os" + "path/filepath" + "testing" + "time" +) + +// scriptedRunner is a minimal Runner that records every call and +// plays back a pre-scripted sequence of (name, args, out, err) +// steps. Failing to match or running past the script fails the +// test. Mirrors the pattern from internal/daemon/snapshot_test.go +// but lives here because fcproc is a leaf package — it can't import +// its parent's test helpers. +type scriptedRunner struct { + t *testing.T + runs []scriptedCall + sudos []scriptedCall +} + +type scriptedCall struct { + matchName string // empty for RunSudo (sudo has no distinct name arg) + matchArgs []string // nil means "don't care" + out []byte + err error +} + +func (r *scriptedRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) { + r.t.Helper() + if len(r.runs) == 0 { + r.t.Fatalf("unexpected Run(%q, %v)", name, args) + } + step := r.runs[0] + r.runs = r.runs[1:] + if step.matchName != "" && step.matchName != name { + r.t.Fatalf("Run name = %q, want %q", name, step.matchName) + } + return step.out, step.err +} + +func (r *scriptedRunner) RunSudo(_ context.Context, args ...string) ([]byte, error) { + r.t.Helper() + if len(r.sudos) == 0 { + r.t.Fatalf("unexpected RunSudo(%v)", args) + } + step := r.sudos[0] + r.sudos = r.sudos[1:] + return step.out, step.err +} + +// TestWaitForPathReturnsDeadlineExceededWhenSocketNeverAppears pins +// the timeout branch of waitForPath. If this drifts, every callsite +// that wraps it (EnsureSocketAccess on the firecracker API + +// vsock sockets) loses its bounded wait. +func TestWaitForPathReturnsDeadlineExceededWhenSocketNeverAppears(t *testing.T) { + missing := filepath.Join(t.TempDir(), "never-created.sock") + start := time.Now() + err := waitForPath(context.Background(), missing, 150*time.Millisecond, "api socket") + elapsed := time.Since(start) + + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("err = %v, want wrapped context.DeadlineExceeded", err) + } + if !contains(err.Error(), "api socket") { + t.Fatalf("err = %v, want label 'api socket' in message", err) + } + // Timeout should fire close to the configured budget, not zero + // (tight-loop regression) and not way over (missing select + // regression). The 100ms poll tick plus the initial stat makes + // the lower bound noisy; check we at least waited a tick. + if elapsed < 90*time.Millisecond { + t.Fatalf("returned after %s; waitForPath exited before its timeout budget", elapsed) + } +} + +// TestWaitForPathReturnsOnceSocketAppears pins the happy path: +// when the file materialises mid-wait, the function returns nil +// without having to walk to its deadline. +func TestWaitForPathReturnsOnceSocketAppears(t *testing.T) { + socketPath := filepath.Join(t.TempDir(), "will-appear.sock") + go func() { + time.Sleep(50 * time.Millisecond) + _ = os.WriteFile(socketPath, []byte{}, 0o600) + }() + if err := waitForPath(context.Background(), socketPath, 2*time.Second, "api socket"); err != nil { + t.Fatalf("waitForPath: %v", err) + } +} + +// TestWaitForPathRespectsContextCancellation pins the ctx.Done() +// branch — a canceled request must not be blocked by the poll +// interval. +func TestWaitForPathRespectsContextCancellation(t *testing.T) { + missing := filepath.Join(t.TempDir(), "never.sock") + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(30 * time.Millisecond) + cancel() + }() + err := waitForPath(ctx, missing, 5*time.Second, "api socket") + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled when ctx is cancelled mid-wait", err) + } +} + +// TestEnsureSocketAccessChownFailureBubbles verifies a sudo chown +// error surfaces untouched. The daemon's cleanup path relies on +// this — if chown fails, the socket is still root-owned and can't +// be used by the invoking user, so we absolutely must not pretend +// success. +func TestEnsureSocketAccessChownFailureBubbles(t *testing.T) { + socketPath := filepath.Join(t.TempDir(), "present.sock") + if err := os.WriteFile(socketPath, []byte{}, 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + chownErr := errors.New("sudo chown failed") + runner := &scriptedRunner{ + t: t, + sudos: []scriptedCall{{err: chownErr}}, + } + mgr := New(runner, Config{}, slog.Default()) + + err := mgr.EnsureSocketAccess(context.Background(), socketPath, "api socket") + if !errors.Is(err, chownErr) { + t.Fatalf("err = %v, want chown error", err) + } + // chmod must not have been attempted. + if len(runner.sudos) != 0 { + t.Fatalf("chmod was attempted after chown failed: %d sudo calls left", len(runner.sudos)) + } +} + +// TestEnsureSocketAccessChmodFailureBubbles verifies the chmod step +// (the belt-and-braces tighten to 0600 after chown) also surfaces +// errors cleanly. +func TestEnsureSocketAccessChmodFailureBubbles(t *testing.T) { + socketPath := filepath.Join(t.TempDir(), "present.sock") + if err := os.WriteFile(socketPath, []byte{}, 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + chmodErr := errors.New("sudo chmod failed") + runner := &scriptedRunner{ + t: t, + sudos: []scriptedCall{ + {}, // chown succeeds + {err: chmodErr}, // chmod fails + }, + } + mgr := New(runner, Config{}, slog.Default()) + + err := mgr.EnsureSocketAccess(context.Background(), socketPath, "api socket") + if !errors.Is(err, chmodErr) { + t.Fatalf("err = %v, want chmod error", err) + } +} + +// TestEnsureSocketAccessTimesOutBeforeTouchingRunner pins the +// ordering contract: if waitForPath never sees the socket, the +// sudo commands must not run. Running chown/chmod against a +// non-existent path would just noise the logs. +func TestEnsureSocketAccessTimesOutBeforeTouchingRunner(t *testing.T) { + missing := filepath.Join(t.TempDir(), "never.sock") + runner := &scriptedRunner{t: t} // no scripted calls — any runner invocation fails the test + mgr := New(runner, Config{}, slog.Default()) + + // EnsureSocketAccess's waitForPath has a hardcoded 5s timeout, + // and we can't inject a shorter one without widening the API. + // Use a short context instead — cancellation short-circuits + // waitForPath via the ctx.Done() branch. + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer cancel() + + err := mgr.EnsureSocketAccess(ctx, missing, "api socket") + if err == nil { + t.Fatal("EnsureSocketAccess: want error when socket never appears") + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +}