package main import ( "bytes" "context" "encoding/json" "net" "net/http" "os" "os/exec" "path/filepath" "strings" "syscall" "testing" "time" ) // The tests run main() in a child process, this test binary started // again with runMainEnv set, because main() can exit its process and // takes its settings from the environment. const runMainEnv = "NETWATCH_SERVER_RUN_MAIN" // childTimeout bounds each child's whole run; it is killed after it. const childTimeout = 10 * time.Second func TestMain(m *testing.M) { if os.Getenv(runMainEnv) != "" { main() return } os.Exit(m.Run()) } // TestOutputIsJSON: off a terminal, every line the server writes from // start to stop is JSON, fx's own lines included. func TestOutputIsJSON(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(t.Context(), childTimeout) defer cancel() port := freePort(ctx, t) child, stdout, stderr := startServer(ctx, t, t.TempDir(), port) waitForHealthcheck(ctx, t, port) err := child.Process.Signal(syscall.SIGTERM) if err != nil { t.Fatal(err) } err = child.Wait() if err != nil { t.Fatalf("server exit = %v, want success", err) } requireJSONLines(t, stdout, stderr) if !strings.Contains(stdout.String(), `"msg":"starting"`) { t.Fatalf("no startup line in stdout:\n%s", stdout) } } // TestMalformedConfigFileStopsTheStart: a config file the server finds // but cannot read stops the start, and the error is logged as JSON. func TestMalformedConfigFileStopsTheStart(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(t.Context(), childTimeout) defer cancel() home := t.TempDir() dir := filepath.Join(home, ".config", "netwatch-server") err := os.MkdirAll(dir, 0o750) if err != nil { t.Fatal(err) } err = os.WriteFile(filepath.Join(dir, "netwatch-server.yaml"), []byte("PORT: [8080\n"), 0o600) if err != nil { t.Fatal(err) } child, stdout, stderr := startServer(ctx, t, home, freePort(ctx, t)) err = child.Wait() if child.ProcessState.ExitCode() != 1 { t.Fatalf("server exit = %v, want exit status 1", err) } requireJSONLines(t, stdout, stderr) if !strings.Contains(stdout.String(), "netwatch-server.yaml") { t.Fatalf("no error naming the config file in stdout:\n%s", stdout) } } // startServer runs main() in a child process listening on // 127.0.0.1:port, with home as its HOME and working directory and its // data directory in home, so it touches nothing outside home. Its // stdout and stderr go to the two buffers returned, which hold all of // it once child.Wait returns. The child is killed when ctx ends. func startServer( ctx context.Context, t *testing.T, home, port string, ) (*exec.Cmd, *bytes.Buffer, *bytes.Buffer) { t.Helper() self, err := os.Executable() if err != nil { t.Fatal(err) } var stdout, stderr bytes.Buffer child := exec.CommandContext(ctx, self) //nolint:gosec // this test binary child.Dir = home child.Env = append(os.Environ(), runMainEnv+"=1", "HOME="+home, "DATA_DIR="+filepath.Join(home, "data"), "BIND_ADDRESS=127.0.0.1", "PORT="+port, ) child.Stdout = &stdout child.Stderr = &stderr err = child.Start() if err != nil { t.Fatal(err) } return child, &stdout, &stderr } // freePort returns a TCP port on 127.0.0.1 that was free a moment ago. func freePort(ctx context.Context, t *testing.T) string { t.Helper() var lc net.ListenConfig l, err := lc.Listen(ctx, "tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } _ = l.Close() _, port, err := net.SplitHostPort(l.Addr().String()) if err != nil { t.Fatal(err) } return port } // waitForHealthcheck returns once the health check on port answers 200. func waitForHealthcheck(ctx context.Context, t *testing.T, port string) { t.Helper() url := "http://127.0.0.1:" + port + "/.well-known/healthcheck" for { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { t.Fatal(err) } resp, err := http.DefaultClient.Do(req) if err == nil { _ = resp.Body.Close() if resp.StatusCode == http.StatusOK { return } } select { case <-ctx.Done(): t.Fatalf("health check never answered: %v", err) case <-time.After(50 * time.Millisecond): } } } // requireJSONLines fails the test on each line of outs that is not // JSON. func requireJSONLines(t *testing.T, outs ...*bytes.Buffer) { t.Helper() for _, out := range outs { for line := range strings.Lines(out.String()) { if !json.Valid([]byte(line)) { t.Errorf("line is not JSON: %s", line) } } } }