diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..6c7321a --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,164 @@ +package app_test + +import ( + "bytes" + "errors" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "strconv" + "syscall" + "testing" + "time" + + "sneak.berlin/go/pixa/internal/app" +) + +// These tests run pixad in a child process: the test binary itself, +// started again with runPixadInChildEnvVar set, which makes TestMain run +// pixad instead of the tests. +const runPixadInChildEnvVar = "TEST_RUN_PIXAD_IN_CHILD" + +const validTestSigningKey = "test-signing-key-for-app-tests-0123456789" + +func TestMain(m *testing.M) { + if os.Getenv(runPixadInChildEnvVar) == "1" { + os.Args = []string{"pixad"} + + app.Run("test") + os.Exit(0) + } + + os.Exit(m.Run()) +} + +// pixadCommand returns a command that runs pixad with only the given +// environment, in an empty directory with an empty HOME, so that no +// config file is found. +func pixadCommand(t *testing.T, env ...string) (*exec.Cmd, *bytes.Buffer) { + t.Helper() + + testBinary := os.Args[0] + cmd := exec.CommandContext(t.Context(), testBinary) //nolint:gosec // not tainted + cmd.Dir = t.TempDir() + cmd.Env = append([]string{ + runPixadInChildEnvVar + "=1", + "HOME=" + t.TempDir(), + }, env...) + + var stderr bytes.Buffer + + cmd.Stderr = &stderr + + return cmd, &stderr +} + +// TestRunPrintsStartupErrorOnce checks that an invalid setting is +// printed once, as the line that names it, and nothing else goes to +// stderr. +func TestRunPrintsStartupErrorOnce(t *testing.T) { + t.Parallel() + + cmd, stderr := pixadCommand(t, "PIXA_SIGNING_KEY=short") + + err := cmd.Run() + + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 1 { + t.Fatalf("pixad: got %v, want exit status 1; stderr:\n%s", err, stderr) + } + + want := `config key "signing_key" (environment variable PIXA_SIGNING_KEY): ` + + "value too short: must be at least 32 characters, got 5\n" + if stderr.String() != want { + t.Errorf("stderr:\n%s\nwant:\n%s", stderr, want) + } +} + +// TestRunWritesNothingToStderrOnStartAndStop starts pixad, waits until +// it answers its health check, stops it with SIGTERM, and checks that it +// wrote nothing to stderr. +func TestRunWritesNothingToStderrOnStartAndStop(t *testing.T) { + t.Parallel() + + port := freePort(t) + cmd, stderr := pixadCommand(t, + "PORT="+strconv.Itoa(port), + "PIXA_STATE_DIR="+t.TempDir(), + "PIXA_SIGNING_KEY="+validTestSigningKey, + ) + + err := cmd.Start() + if err != nil { + t.Fatalf("starting pixad: %v", err) + } + + waitForHealthCheck(t, port) + + err = cmd.Process.Signal(syscall.SIGTERM) + if err != nil { + t.Fatalf("signalling pixad: %v", err) + } + + err = cmd.Wait() + if err != nil { + t.Fatalf("pixad: got %v, want exit status 0; stderr:\n%s", err, stderr) + } + + if stderr.Len() != 0 { + t.Errorf("stderr is not empty:\n%s", stderr) + } +} + +// freePort returns a TCP port on the loopback address that nothing +// listens on. +func freePort(t *testing.T) int { + t.Helper() + + var listenConfig net.ListenConfig + + listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("finding a free port: %v", err) + } + + port := listener.Addr().(*net.TCPAddr).Port //nolint:forcetypeassert // a TCP listener + + err = listener.Close() + if err != nil { + t.Fatalf("closing listener: %v", err) + } + + return port +} + +// waitForHealthCheck waits up to 30 seconds for pixad to answer its +// health check on port. +func waitForHealthCheck(t *testing.T, port int) { + t.Helper() + + url := fmt.Sprintf("http://127.0.0.1:%d/.well-known/healthcheck.json", port) + deadline := time.Now().Add(30 * time.Second) + + for time.Now().Before(deadline) { + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil) + if err != nil { + t.Fatalf("health check request: %v", err) + } + + resp, err := http.DefaultClient.Do(req) + if err == nil { + _ = resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + return + } + } + + time.Sleep(100 * time.Millisecond) + } + + t.Fatalf("pixad did not answer its health check on port %d", port) +}