package main import ( "bytes" "context" "encoding/json" "net" "net/http" "os" "os/exec" "os/signal" "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) != "" { // A SIGTERM that comes before fx catches it is dropped, not fatal. signal.Notify(make(chan os.Signal, 1), syscall.SIGTERM) main() return } os.Exit(m.Run()) } // TestOutputIsJSONWithoutFxLines: off a terminal, every line the server // writes from start to stop is JSON, and without DEBUG none of them is // one of fx's. func TestOutputIsJSONWithoutFxLines(t *testing.T) { t.Parallel() stdout, stderr := runServerUntilStopped(t) requireJSONLines(t, stdout, stderr) if !strings.Contains(stdout.String(), `"msg":"starting"`) { t.Fatalf("no startup line in stdout:\n%s", stdout) } if strings.Contains(stdout.String(), `"msg":"started"`) { t.Fatalf("fx line in stdout without DEBUG:\n%s", stdout) } } // TestDebugLogsFxLines: with DEBUG set, fx's lines are logged, as // JSON, from the start to the signal that stops the server. func TestDebugLogsFxLines(t *testing.T) { t.Parallel() stdout, stderr := runServerUntilStopped(t, "DEBUG=1") requireJSONLines(t, stdout, stderr) for _, msg := range []string{"started", "received signal"} { if !strings.Contains(stdout.String(), `"msg":"`+msg+`"`) { t.Fatalf("no fx line %q in stdout with DEBUG set:\n%s", msg, stdout) } } } // TestRefusedSettingIsLastLine: a setting the server refuses stops the // start, and the one error line logged is the last, naming the setting // and why, without fx's text around it. func TestRefusedSettingIsLastLine(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(t.Context(), childTimeout) defer cancel() child, stdout, stderr := startServer(ctx, t, t.TempDir(), freePort(ctx, t), "REPORTS_PER_MINUTE=0") err := child.Wait() if child.ProcessState.ExitCode() != 1 { t.Fatalf("server exit = %v, want exit status 1", err) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want nothing", stderr) } lines := strings.Split(strings.TrimSpace(stdout.String()), "\n") for _, line := range lines[:len(lines)-1] { if strings.Contains(line, `"level":"ERROR"`) { t.Fatalf("an error line before the last line:\n%s", stdout) } } var last struct { Level string `json:"level"` Error string `json:"error"` } err = json.Unmarshal([]byte(lines[len(lines)-1]), &last) if err != nil { t.Fatalf("last line is not JSON: %v\n%s", err, stdout) } want := `REPORTS_PER_MINUTE "0": must be a positive whole number` if last.Level != "ERROR" || last.Error != want { t.Fatalf("last line has level %q, error %q; want ERROR, %q\n%s", last.Level, last.Error, want, stdout) } } // runServerUntilStopped runs main() in a child process with env added // to its environment, waits until it answers its health check, stops it // with SIGTERM and returns its stdout and stderr. It fails the test if // the child does not exit cleanly. func runServerUntilStopped( t *testing.T, env ...string, ) (*bytes.Buffer, *bytes.Buffer) { t.Helper() ctx, cancel := context.WithTimeout(t.Context(), childTimeout) defer cancel() port := freePort(ctx, t) child, stdout, stderr := startServer(ctx, t, t.TempDir(), port, env...) waitForHealthcheck(ctx, t, port) // The child drops a SIGTERM that comes before fx catches it (see // TestMain), so send one every 100ms until the test ends. ctx // bounds the wait: when it ends, the child is killed. stop := make(chan struct{}) defer close(stop) go func() { for { _ = child.Process.Signal(syscall.SIGTERM) select { case <-stop: return case <-time.After(100 * time.Millisecond): } } }() err := child.Wait() if err != nil { t.Fatalf("server exit = %v, want success", err) } return stdout, stderr } // 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) } } // TestRefusedSentryDSNStopsTheStart: a SENTRY_DSN that Sentry refuses // stops the start, and the error, naming SENTRY_DSN, is logged as JSON. func TestRefusedSentryDSNStopsTheStart(t *testing.T) { t.Setenv("SENTRY_DSN", "not-a-dsn") ctx, cancel := context.WithTimeout(t.Context(), childTimeout) defer cancel() child, stdout, stderr := startServer(ctx, t, t.TempDir(), 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(), "SENTRY_DSN") { t.Fatalf("no error naming SENTRY_DSN in stdout:\n%s", stdout) } } // TestPortInUseExitsWithStatus1: a server whose port is already taken // cannot listen, and stops with exit status 1. func TestPortInUseExitsWithStatus1(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(t.Context(), childTimeout) defer cancel() var lc net.ListenConfig taken, err := lc.Listen(ctx, "tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer func() { _ = taken.Close() }() _, port, err := net.SplitHostPort(taken.Addr().String()) if err != nil { t.Fatal(err) } child, stdout, _ := startServer(ctx, t, t.TempDir(), port) err = child.Wait() if child.ProcessState.ExitCode() != 1 { t.Fatalf("server exit = %v, want exit status 1\n%s", err, 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. env is // added to its environment, as NAME=value strings. 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, and // killed and reaped when the test ends if nothing waited for it. func startServer( ctx context.Context, t *testing.T, home, port string, env ...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.Env = append(child.Env, env...) child.Stdout = &stdout child.Stderr = &stderr err = child.Start() if err != nil { t.Fatal(err) } t.Cleanup(func() { if child.ProcessState == nil { _ = child.Process.Kill() _ = child.Wait() } }) 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) } } } }