// Tests use a global viper instance for configuration, // making parallel execution unsafe. // //nolint:paralleltest package server_test import ( "crypto/rand" "fmt" "net" "net/http" "strconv" "strings" "testing" "time" "go.uber.org/fx" "go.uber.org/fx/fxtest" "sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/db" "sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/handlers" "sneak.berlin/go/neoirc/internal/healthcheck" "sneak.berlin/go/neoirc/internal/logger" "sneak.berlin/go/neoirc/internal/middleware" "sneak.berlin/go/neoirc/internal/server" "sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/stats" ) const ( testTimeout = 5 * time.Second // checkStep is how often waitUntil checks again. checkStep = 10 * time.Millisecond // stopHoldTime is how long a test waits to see that the // app has not stopped. stopHoldTime = 200 * time.Millisecond ) // TestStopWaitsForRequestInProgress checks that stopping the // app, which runs the stop hook server.New registers, waits // for a long poll the HTTP server is serving to finish, and // that the poll gets its response. func TestStopWaitsForRequestInProgress(t *testing.T) { port := freePort(t) addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) app, brk, database := startApp(t, port) sessionID, _, token, err := database.CreateSession( t.Context(), "poller", "poller", "", "", ) if err != nil { t.Fatalf("create session: %v", err) } waitUntil(t, "the HTTP server to listen", func() bool { return canConnect(t, addr) }) statuses := make(chan int, 1) go func() { statuses <- getAuthenticated( t, "http://"+addr+"/api/v1/messages?timeout=30", token, ) }() waitUntil(t, "the long poll to wait for a message", func() bool { return brk.ListenerCount(sessionID) == 1 }) var stopErr error stopped := make(chan struct{}) go func() { stopErr = app.Stop(t.Context()) close(stopped) }() select { case <-stopped: t.Error("the app stopped while a request was in progress") case <-time.After(stopHoldTime): } brk.Notify(sessionID) status := <-statuses if status != http.StatusOK { t.Errorf("status %d, want %d", status, http.StatusOK) } select { case <-stopped: if stopErr != nil { t.Errorf("stop: %v", stopErr) } case <-time.After(testTimeout): t.Fatal("the app did not stop after the request finished") } } // TestStartFailsWhenHTTPPortIsTaken checks that starting the // app, which runs the start hook server.New registers, // returns an error naming the HTTP listen address when // something else is already listening on its port. func TestStartFailsWhenHTTPPortIsTaken(t *testing.T) { var listenConfig net.ListenConfig taken, err := listenConfig.Listen(t.Context(), "tcp", ":0") if err != nil { t.Fatalf("listen: %v", err) } t.Cleanup(func() { _ = taken.Close() }) tcpAddr, ok := taken.Addr().(*net.TCPAddr) if !ok { t.Fatalf("listener address %v is not TCP", taken.Addr()) } app, _, _ := newApp(t, tcpAddr.Port) err = app.Start(t.Context()) if err == nil { app.RequireStop() t.Fatal("the app started while its HTTP port was taken") } listenAddr := fmt.Sprintf(":%d", tcpAddr.Port) if !strings.Contains(err.Error(), listenAddr) { t.Errorf("start error %q does not name %s", err, listenAddr) } } // TestServeFailureStopsApp checks that when serving HTTP fails // after the start hook has bound the listen address, the // server stops the app with exit code 1. func TestServeFailureStopsApp(t *testing.T) { var srv *server.Server app, _, _ := startApp(t, freePort(t), fx.Populate(&srv)) shutdowns := app.Wait() srv.ServeUntilShutdown(closedListener(t)) select { case shutdown := <-shutdowns: if shutdown.ExitCode != 1 { t.Errorf("exit code %d, want 1", shutdown.ExitCode) } default: t.Error("the server did not stop the app") } } // TestServeAfterStopDoesNotStopApp checks that serving HTTP // after the app has stopped, which ends at once because the // stop hook has shut the HTTP server down, does not stop the // app. Serving ends the same way on every normal stop. func TestServeAfterStopDoesNotStopApp(t *testing.T) { var srv *server.Server app, _, _ := startApp(t, freePort(t), fx.Populate(&srv)) shutdowns := app.Wait() app.RequireStop() srv.ServeUntilShutdown(closedListener(t)) select { case shutdown := <-shutdowns: t.Errorf( "the server stopped the app with exit code %d", shutdown.ExitCode, ) default: } } // startApp starts the app newApp builds, with the HTTP server // listening on port. func startApp( t *testing.T, port int, options ...fx.Option, ) (*fxtest.App, *broker.Broker, *db.Database) { t.Helper() app, brk, database := newApp(t, port, options...) app.RequireStart() // Stops the app if the test ends before stopping it. t.Cleanup(app.RequireStop) return app, brk, database } // newApp builds, without starting it, an app that runs the // server with the dependencies cmd/neoircd gives it, on a // fresh in-memory database, with the HTTP port set to port. // options are added to the app, for a test that needs more // from it than the broker and the database. func newApp( t *testing.T, port int, options ...fx.Option, ) (*fxtest.App, *broker.Broker, *db.Database) { t.Helper() var ( brk *broker.Broker database *db.Database ) app := fxtest.New(t, fx.Supply(&globals.Globals{ Appname: "neoirc-test", Version: "test", StartTime: time.Now(), }), fx.Provide( broker.New, config.New, db.New, handlers.New, healthcheck.New, logger.New, middleware.New, server.New, service.New, stats.New, ), fx.Decorate(func(cfg *config.Config) *config.Config { // A random name, so that no other test can share it. cfg.DBURL = fmt.Sprintf( "file:%s?mode=memory&cache=shared", rand.Text(), ) cfg.Port = port return cfg }), fx.Invoke(func(*server.Server) {}), fx.Populate(&brk, &database), fx.Options(options...), ) return app, brk, database } // freePort returns a TCP port that nothing was listening on a // moment ago. func freePort(t *testing.T) int { t.Helper() listener := closedListener(t) tcpAddr, ok := listener.Addr().(*net.TCPAddr) if !ok { t.Fatalf("listener address %v is not TCP", listener.Addr()) } return tcpAddr.Port } // closedListener returns a TCP listener that has already been // closed, so that accepting a connection on it fails. func closedListener(t *testing.T) net.Listener { t.Helper() var listenConfig net.ListenConfig listener, err := listenConfig.Listen( t.Context(), "tcp", "127.0.0.1:0", ) if err != nil { t.Fatalf("listen: %v", err) } err = listener.Close() if err != nil { t.Fatalf("close listener: %v", err) } return listener } // canConnect reports whether addr accepts a TCP connection. func canConnect(t *testing.T, addr string) bool { t.Helper() var dialer net.Dialer conn, err := dialer.DialContext(t.Context(), "tcp", addr) if err != nil { return false } _ = conn.Close() return true } // waitUntil calls done every checkStep until it returns true, // and fails the test if it does not within testTimeout. func waitUntil(t *testing.T, what string, done func() bool) { t.Helper() deadline := time.Now().Add(testTimeout) for !done() { if time.Now().After(deadline) { t.Fatalf("timed out waiting for %s", what) } time.Sleep(checkStep) } } // getAuthenticated sends a GET request to url as the session // whose auth token is token, and returns the response status, // or 0 if there was no response. func getAuthenticated(t *testing.T, url, token string) int { t.Helper() request, err := http.NewRequestWithContext( t.Context(), http.MethodGet, url, nil, ) if err != nil { t.Errorf("new request: %v", err) return 0 } request.Header.Set("Cookie", "neoirc_auth="+token) client := http.Client{Timeout: testTimeout} response, err := client.Do(request) if err != nil { t.Errorf("get %s: %v", url, err) return 0 } _ = response.Body.Close() return response.StatusCode }