check / check (push) Failing after 4s
The CORS middleware let every origin make credentialed requests, so a page on another subdomain of the server's domain could read and act as the signed-in user. Nothing needs cross-origin access, so the middleware and the go-chi/cors dependency go. A test checks that a request from another origin and a preflight get no Access-Control-Allow-* headers. Model: opus-5-5
391 lines
8.9 KiB
Go
391 lines
8.9 KiB
Go
// Tests use a global viper instance for configuration,
|
|
// making parallel execution unsafe.
|
|
//
|
|
//nolint:paralleltest
|
|
package server_test
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"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/dbtest"
|
|
"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) {
|
|
var srv *server.Server
|
|
|
|
app, brk, database := startApp(t, fx.Populate(&srv))
|
|
|
|
sessionID, _, token, err := database.CreateSession(
|
|
t.Context(), "poller", "poller", "", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("create session: %v", err)
|
|
}
|
|
|
|
tcpAddr, ok := srv.Listener().Addr().(*net.TCPAddr)
|
|
if !ok {
|
|
t.Fatalf("listener address %v is not TCP", srv.Listener().Addr())
|
|
}
|
|
|
|
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(tcpAddr.Port))
|
|
|
|
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, 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, 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:
|
|
}
|
|
}
|
|
|
|
// TestNoCrossOriginAccess checks that the server allows no
|
|
// cross-origin requests: neither a request from another origin
|
|
// nor a preflight gets Access-Control-Allow-Origin or
|
|
// Access-Control-Allow-Credentials, and a preflight gets 405
|
|
// like any other OPTIONS request.
|
|
func TestNoCrossOriginAccess(t *testing.T) {
|
|
var srv *server.Server
|
|
|
|
startApp(t, fx.Populate(&srv))
|
|
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
requestMethod string
|
|
status int
|
|
}{
|
|
{
|
|
name: "request",
|
|
method: http.MethodGet,
|
|
status: http.StatusOK,
|
|
},
|
|
{
|
|
name: "preflight",
|
|
method: http.MethodOptions,
|
|
requestMethod: http.MethodPost,
|
|
status: http.StatusMethodNotAllowed,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
request := httptest.NewRequestWithContext(
|
|
t.Context(), test.method,
|
|
"http://chat.example.com/api/v1/server", nil,
|
|
)
|
|
request.Header.Set("Origin", "http://blog.example.com")
|
|
|
|
if test.requestMethod != "" {
|
|
request.Header.Set(
|
|
"Access-Control-Request-Method", test.requestMethod,
|
|
)
|
|
}
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
srv.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != test.status {
|
|
t.Errorf("status %d, want %d", recorder.Code, test.status)
|
|
}
|
|
|
|
for _, header := range []string{
|
|
"Access-Control-Allow-Origin",
|
|
"Access-Control-Allow-Credentials",
|
|
} {
|
|
value := recorder.Header().Get(header)
|
|
if value != "" {
|
|
t.Errorf("%s is %q, want none", header, value)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// startApp starts the app newApp builds, with the HTTP port
|
|
// set to 0, so the system gives the server a free port.
|
|
func startApp(
|
|
t *testing.T,
|
|
options ...fx.Option,
|
|
) (*fxtest.App, *broker.Broker, *db.Database) {
|
|
t.Helper()
|
|
|
|
app, brk, database := newApp(t, 0, 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 {
|
|
cfg.DBURL = dbtest.NewURL(t)
|
|
cfg.Port = port
|
|
|
|
return cfg
|
|
}),
|
|
fx.Invoke(func(*server.Server) {}),
|
|
fx.Populate(&brk, &database),
|
|
fx.Options(options...),
|
|
)
|
|
|
|
return app, brk, database
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|