Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ca6ae6c2f | ||
|
|
bbdf3a70af |
+245
-14
@@ -126,6 +126,7 @@ const (
|
|||||||
privmsgCmd = "PRIVMSG"
|
privmsgCmd = "PRIVMSG"
|
||||||
joinCmd = "JOIN"
|
joinCmd = "JOIN"
|
||||||
apiMessages = "/api/v1/messages"
|
apiMessages = "/api/v1/messages"
|
||||||
|
apiLogout = "/api/v1/logout"
|
||||||
apiSession = "/api/v1/session"
|
apiSession = "/api/v1/session"
|
||||||
apiState = "/api/v1/state"
|
apiState = "/api/v1/state"
|
||||||
authCookieName = "neoirc_auth"
|
authCookieName = "neoirc_auth"
|
||||||
@@ -136,6 +137,8 @@ type testServer struct {
|
|||||||
httpServer *httptest.Server
|
httpServer *httptest.Server
|
||||||
t *testing.T
|
t *testing.T
|
||||||
fxApp *fxtest.App
|
fxApp *fxtest.App
|
||||||
|
broker *broker.Broker
|
||||||
|
database *db.Database
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestServer(
|
func newTestServer(
|
||||||
@@ -162,7 +165,11 @@ func newTestServerWith(
|
|||||||
)
|
)
|
||||||
copyTemplateDB(t, dbURL)
|
copyTemplateDB(t, dbURL)
|
||||||
|
|
||||||
var srv *server.Server
|
var (
|
||||||
|
srv *server.Server
|
||||||
|
brk *broker.Broker
|
||||||
|
database *db.Database
|
||||||
|
)
|
||||||
|
|
||||||
app := fxtest.New(t,
|
app := fxtest.New(t,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
@@ -195,13 +202,14 @@ func newTestServerWith(
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
},
|
},
|
||||||
newTestDB,
|
newTestDB,
|
||||||
|
broker.New,
|
||||||
stats.New,
|
stats.New,
|
||||||
newTestHealthcheck,
|
newTestHealthcheck,
|
||||||
newTestMiddleware,
|
newTestMiddleware,
|
||||||
newTestHandlers,
|
newTestHandlers,
|
||||||
newTestServerFx,
|
newTestServerFx,
|
||||||
),
|
),
|
||||||
fx.Populate(&srv),
|
fx.Populate(&srv, &brk, &database),
|
||||||
)
|
)
|
||||||
|
|
||||||
app.RequireStart()
|
app.RequireStart()
|
||||||
@@ -217,6 +225,8 @@ func newTestServerWith(
|
|||||||
httpServer: httpSrv,
|
httpServer: httpSrv,
|
||||||
t: t,
|
t: t,
|
||||||
fxApp: app,
|
fxApp: app,
|
||||||
|
broker: brk,
|
||||||
|
database: database,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,8 +301,8 @@ func newTestHandlers(
|
|||||||
database *db.Database,
|
database *db.Database,
|
||||||
hcheck *healthcheck.Healthcheck,
|
hcheck *healthcheck.Healthcheck,
|
||||||
tracker *stats.Tracker,
|
tracker *stats.Tracker,
|
||||||
|
brk *broker.Broker,
|
||||||
) (*handlers.Handlers, error) {
|
) (*handlers.Handlers, error) {
|
||||||
brk := broker.New()
|
|
||||||
svc := service.New(service.Params{ //nolint:exhaustruct
|
svc := service.New(service.Params{ //nolint:exhaustruct
|
||||||
Logger: log,
|
Logger: log,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
@@ -325,6 +335,7 @@ func newTestServerFx(
|
|||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
mware *middleware.Middleware,
|
mware *middleware.Middleware,
|
||||||
hdlr *handlers.Handlers,
|
hdlr *handlers.Handlers,
|
||||||
|
shutdowner fx.Shutdowner,
|
||||||
) (*server.Server, error) {
|
) (*server.Server, error) {
|
||||||
srv, err := server.New(lifecycle, server.Params{ //nolint:exhaustruct
|
srv, err := server.New(lifecycle, server.Params{ //nolint:exhaustruct
|
||||||
Logger: log,
|
Logger: log,
|
||||||
@@ -332,6 +343,7 @@ func newTestServerFx(
|
|||||||
Config: cfg,
|
Config: cfg,
|
||||||
Middleware: mware,
|
Middleware: mware,
|
||||||
Handlers: hdlr,
|
Handlers: hdlr,
|
||||||
|
Shutdowner: shutdowner,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("test server: %w", err)
|
return nil, fmt.Errorf("test server: %w", err)
|
||||||
@@ -1402,7 +1414,7 @@ const quitRaceTrials = 5
|
|||||||
// is sent once.
|
// is sent once.
|
||||||
func TestConcurrentLogoutsSendOneQuit(t *testing.T) {
|
func TestConcurrentLogoutsSendOneQuit(t *testing.T) {
|
||||||
checkQuitRace(t, func(tserver *testServer, tokens []string) {
|
checkQuitRace(t, func(tserver *testServer, tokens []string) {
|
||||||
tserver.postConcurrently("/api/v1/logout", nil, tokens...)
|
tserver.postConcurrently(apiLogout, nil, tokens...)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1517,15 +1529,7 @@ func TestLogoutOfOneClientKeepsSession(t *testing.T) {
|
|||||||
t.Fatalf("other client's state: got %d, want 200", status)
|
t.Fatalf("other client's state: got %d, want 200", status)
|
||||||
}
|
}
|
||||||
|
|
||||||
channels, _ := state["channels"].([]any)
|
names := channelNames(state)
|
||||||
names := make([]string, 0, len(channels))
|
|
||||||
|
|
||||||
for _, entry := range channels {
|
|
||||||
info, _ := entry.(map[string]any)
|
|
||||||
name, _ := info["name"].(string)
|
|
||||||
names = append(names, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !slices.Equal(names, []string{channel}) {
|
if !slices.Equal(names, []string{channel}) {
|
||||||
t.Errorf("session's channels: got %v, want [%s]", names, channel)
|
t.Errorf("session's channels: got %v, want [%s]", names, channel)
|
||||||
}
|
}
|
||||||
@@ -1547,6 +1551,201 @@ func TestLogoutOfOneClientKeepsSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFailedLogoutChangesNothing checks that a logout whose
|
||||||
|
// session cannot be deleted answers 500 and changes
|
||||||
|
// nothing: the client's token still works, the session is
|
||||||
|
// still on its channel and its channel peer has no QUIT.
|
||||||
|
func TestFailedLogoutChangesNothing(t *testing.T) {
|
||||||
|
const (
|
||||||
|
nick = "stuckleaver"
|
||||||
|
channel = "#stuck"
|
||||||
|
)
|
||||||
|
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
token := tserver.createSession(nick)
|
||||||
|
observerToken := tserver.createSession("stuckobserver")
|
||||||
|
|
||||||
|
for _, joiner := range []string{token, observerToken} {
|
||||||
|
tserver.sendCommand(joiner, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: channel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(observerToken, 0)
|
||||||
|
|
||||||
|
_, err := tserver.database.GetDB().ExecContext(t.Context(),
|
||||||
|
`CREATE TRIGGER keep_sessions
|
||||||
|
BEFORE DELETE ON sessions
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'sessions are kept');
|
||||||
|
END`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := doRequestAuth(
|
||||||
|
t, http.MethodPost, tserver.url(apiLogout), token, nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("logout: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusInternalServerError {
|
||||||
|
t.Errorf("logout: got %d, want 500", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, state := tserver.getState(token)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("state after the failed logout: got %d, want 200", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
names := channelNames(state)
|
||||||
|
if !slices.Equal(names, []string{channel}) {
|
||||||
|
t.Errorf("session's channels: got %v, want [%s]", names, channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, _ := tserver.pollMessages(observerToken, lastID)
|
||||||
|
|
||||||
|
quits := countQuits(msgs, nick)
|
||||||
|
if quits != 0 {
|
||||||
|
t.Errorf("%d QUITs, want 0: %v", quits, msgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// quitWakePollTimeout is the timeout, in seconds, of the
|
||||||
|
// long poll in checkQuitWakesPoll.
|
||||||
|
quitWakePollTimeout = 10
|
||||||
|
|
||||||
|
// quitWakeDeadline is how long checkQuitWakesPoll waits
|
||||||
|
// for that long poll to start waiting, and then to
|
||||||
|
// return once the session has left.
|
||||||
|
quitWakeDeadline = 5 * time.Second
|
||||||
|
|
||||||
|
// pollWaitStep is how often checkQuitWakesPoll checks
|
||||||
|
// whether the long poll is waiting yet.
|
||||||
|
pollWaitStep = 10 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestQuitWakesPeerLongPoll checks that a session's QUIT
|
||||||
|
// wakes the long poll of a user who shares a channel with
|
||||||
|
// it.
|
||||||
|
func TestQuitWakesPeerLongPoll(t *testing.T) {
|
||||||
|
const reason = "gone fishing"
|
||||||
|
|
||||||
|
checkQuitWakesPoll(t, reason, func(tserver *testServer, token string) {
|
||||||
|
tserver.sendCommand(token, map[string]any{
|
||||||
|
commandKey: irc.CmdQuit, bodyKey: []string{reason},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLastLogoutWakesPeerLongPoll checks that the logout of
|
||||||
|
// a session's last client wakes the long poll of a user who
|
||||||
|
// shares a channel with it.
|
||||||
|
func TestLastLogoutWakesPeerLongPoll(t *testing.T) {
|
||||||
|
checkQuitWakesPoll(t, "Connection closed", func(tserver *testServer, token string) {
|
||||||
|
tserver.logout(token)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkQuitWakesPoll puts a session and a peer on a
|
||||||
|
// channel, and calls leave with the session's token while
|
||||||
|
// the peer long-polls with a timeout of quitWakePollTimeout
|
||||||
|
// seconds. It checks that the poll returns within
|
||||||
|
// quitWakeDeadline with the session's QUIT, whose body is
|
||||||
|
// reason.
|
||||||
|
func checkQuitWakesPoll(
|
||||||
|
t *testing.T,
|
||||||
|
reason string,
|
||||||
|
leave func(tserver *testServer, token string),
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
const (
|
||||||
|
nick = "wakequitter"
|
||||||
|
peerNick = "wakepeer"
|
||||||
|
)
|
||||||
|
|
||||||
|
tserver := newTestServer(t)
|
||||||
|
token := tserver.createSession(nick)
|
||||||
|
peerToken := tserver.createSession(peerNick)
|
||||||
|
|
||||||
|
for _, joiner := range []string{token, peerToken} {
|
||||||
|
tserver.sendCommand(joiner, map[string]any{
|
||||||
|
commandKey: joinCmd, toKey: "#wake",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_, lastID := tserver.pollMessages(peerToken, 0)
|
||||||
|
|
||||||
|
peerID, err := tserver.database.GetSessionByNick(t.Context(), peerNick)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get session of %s: %v", peerNick, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
msgs []map[string]any
|
||||||
|
pollErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
polled := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(polled)
|
||||||
|
|
||||||
|
msgs, pollErr = tserver.longPoll(peerToken, lastID, quitWakePollTimeout)
|
||||||
|
}()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(quitWakeDeadline)
|
||||||
|
|
||||||
|
for tserver.broker.ListenerCount(peerID) == 0 {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("the long poll did not start waiting")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(pollWaitStep)
|
||||||
|
}
|
||||||
|
|
||||||
|
leave(tserver, token)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-polled:
|
||||||
|
case <-time.After(quitWakeDeadline):
|
||||||
|
t.Fatalf("the long poll did not return within %v", quitWakeDeadline)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pollErr != nil {
|
||||||
|
t.Fatalf("long poll: %v", pollErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msgs) != 1 || countQuits(msgs, nick) != 1 {
|
||||||
|
t.Fatalf("long poll: got %v, want one QUIT from %s", msgs, nick)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines, _ := msgs[0][bodyKey].([]any)
|
||||||
|
if len(lines) != 1 || lines[0] != reason {
|
||||||
|
t.Errorf("QUIT body: got %v, want [%s]", lines, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// channelNames returns the names of the channels in state,
|
||||||
|
// a response to GET /api/v1/state.
|
||||||
|
func channelNames(state map[string]any) []string {
|
||||||
|
channels, _ := state["channels"].([]any)
|
||||||
|
names := make([]string, 0, len(channels))
|
||||||
|
|
||||||
|
for _, entry := range channels {
|
||||||
|
info, _ := entry.(map[string]any)
|
||||||
|
name, _ := info["name"].(string)
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
// countQuits returns how many of msgs are a QUIT from nick.
|
// countQuits returns how many of msgs are a QUIT from nick.
|
||||||
func countQuits(msgs []map[string]any, nick string) int {
|
func countQuits(msgs []map[string]any, nick string) int {
|
||||||
quits := 0
|
quits := 0
|
||||||
@@ -1567,7 +1766,7 @@ func (tserver *testServer) logout(token string) {
|
|||||||
|
|
||||||
resp, err := doRequestAuth(
|
resp, err := doRequestAuth(
|
||||||
tserver.t, http.MethodPost,
|
tserver.t, http.MethodPost,
|
||||||
tserver.url("/api/v1/logout"), token, nil,
|
tserver.url(apiLogout), token, nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tserver.t.Fatalf("logout: %v", err)
|
tserver.t.Fatalf("logout: %v", err)
|
||||||
@@ -1580,6 +1779,38 @@ func (tserver *testServer) logout(token string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// longPoll returns the messages after afterID that the
|
||||||
|
// client token has been sent, waiting up to timeout seconds
|
||||||
|
// for one if there are none yet. It returns errors instead
|
||||||
|
// of failing the test, so a goroutine can call it.
|
||||||
|
func (tserver *testServer) longPoll(
|
||||||
|
token string, afterID int64, timeout int,
|
||||||
|
) ([]map[string]any, error) {
|
||||||
|
resp, err := doRequestAuth(
|
||||||
|
tserver.t, http.MethodGet,
|
||||||
|
tserver.url(fmt.Sprintf(
|
||||||
|
"%s?timeout=%d&after=%d", apiMessages, timeout, afterID,
|
||||||
|
)),
|
||||||
|
token, nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Messages []map[string]any `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode poll: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Messages, nil
|
||||||
|
}
|
||||||
|
|
||||||
// login logs in to the session nick with testPassword and
|
// login logs in to the session nick with testPassword and
|
||||||
// returns the new client's token.
|
// returns the new client's token.
|
||||||
func (tserver *testServer) login(nick string) string {
|
func (tserver *testServer) login(nick string) string {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import "github.com/go-chi/chi/v5"
|
import (
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
// NewTestSPAServer creates a Server whose router serves only
|
// NewTestSPAServer creates a Server whose router serves only
|
||||||
// the embedded web client.
|
// the embedded web client.
|
||||||
@@ -10,3 +14,9 @@ func NewTestSPAServer() *Server {
|
|||||||
|
|
||||||
return srv
|
return srv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServeUntilShutdown runs serveUntilShutdown, which serves the
|
||||||
|
// HTTP server on listener, and returns when it does.
|
||||||
|
func (srv *Server) ServeUntilShutdown(listener net.Listener) {
|
||||||
|
srv.serveUntilShutdown(listener)
|
||||||
|
}
|
||||||
|
|||||||
+36
-14
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -39,6 +40,7 @@ type Params struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
Middleware *middleware.Middleware
|
Middleware *middleware.Middleware
|
||||||
Handlers *handlers.Handlers
|
Handlers *handlers.Handlers
|
||||||
|
Shutdowner fx.Shutdowner
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the main HTTP server.
|
// Server is the main HTTP server.
|
||||||
@@ -66,7 +68,7 @@ func New(
|
|||||||
}
|
}
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(ctx context.Context) error {
|
||||||
srv.startupTime = time.Now()
|
srv.startupTime = time.Now()
|
||||||
|
|
||||||
// Build the router before OnStart returns, so srv can
|
// Build the router before OnStart returns, so srv can
|
||||||
@@ -78,9 +80,7 @@ func New(
|
|||||||
// A closed stdout or stderr must not end the process.
|
// A closed stdout or stderr must not end the process.
|
||||||
signal.Ignore(syscall.SIGPIPE)
|
signal.Ignore(syscall.SIGPIPE)
|
||||||
|
|
||||||
srv.startHTTP()
|
return srv.startHTTP(ctx)
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
OnStop: func(ctx context.Context) error {
|
OnStop: func(ctx context.Context) error {
|
||||||
srv.cleanShutdown(ctx)
|
srv.cleanShutdown(ctx)
|
||||||
@@ -162,15 +162,23 @@ func (srv *Server) configure() {
|
|||||||
// Server configuration placeholder.
|
// Server configuration placeholder.
|
||||||
}
|
}
|
||||||
|
|
||||||
// startHTTP creates the HTTP server and serves it from a new
|
// startHTTP binds the HTTP listen address, so that a port
|
||||||
// goroutine. srv.httpServer is set before that goroutine
|
// that is taken fails startup, then serves the HTTP server
|
||||||
// starts and never changes, so it and cleanShutdown read it
|
// from a new goroutine. srv.httpServer is set before that
|
||||||
// without a lock.
|
// goroutine starts and never changes, so it and cleanShutdown
|
||||||
func (srv *Server) startHTTP() {
|
// read it without a lock.
|
||||||
|
func (srv *Server) startHTTP(ctx context.Context) error {
|
||||||
listenAddr := fmt.Sprintf(
|
listenAddr := fmt.Sprintf(
|
||||||
":%d", srv.params.Config.Port,
|
":%d", srv.params.Config.Port,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var listenConfig net.ListenConfig
|
||||||
|
|
||||||
|
listener, err := listenConfig.Listen(ctx, "tcp", listenAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("http listen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields
|
srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields
|
||||||
Addr: listenAddr,
|
Addr: listenAddr,
|
||||||
ReadTimeout: httpReadTimeout,
|
ReadTimeout: httpReadTimeout,
|
||||||
@@ -179,16 +187,30 @@ func (srv *Server) startHTTP() {
|
|||||||
Handler: srv,
|
Handler: srv,
|
||||||
}
|
}
|
||||||
|
|
||||||
go srv.serveUntilShutdown()
|
go func() {
|
||||||
|
srv.serveUntilShutdown(listener)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Server) serveUntilShutdown() {
|
// serveUntilShutdown serves the HTTP server on listener until
|
||||||
|
// cleanShutdown stops it. If serving fails before then, it
|
||||||
|
// stops the app with exit code 1.
|
||||||
|
func (srv *Server) serveUntilShutdown(listener net.Listener) {
|
||||||
srv.log.Info(
|
srv.log.Info(
|
||||||
"http begin listen", "listenaddr", srv.httpServer.Addr,
|
"http begin listen", "listenaddr", srv.httpServer.Addr,
|
||||||
)
|
)
|
||||||
|
|
||||||
err := srv.httpServer.ListenAndServe()
|
err := srv.httpServer.Serve(listener)
|
||||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
srv.log.Error("listen error", "error", err)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
srv.log.Error("listen error", "error", err)
|
||||||
|
|
||||||
|
err = srv.params.Shutdowner.Shutdown(fx.ExitCode(1))
|
||||||
|
if err != nil {
|
||||||
|
srv.log.Error("shutdown failed", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+127
-16
@@ -1,3 +1,7 @@
|
|||||||
|
// Tests use a global viper instance for configuration,
|
||||||
|
// making parallel execution unsafe.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest
|
||||||
package server_test
|
package server_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -6,6 +10,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -40,8 +45,6 @@ const (
|
|||||||
// for a long poll the HTTP server is serving to finish, and
|
// for a long poll the HTTP server is serving to finish, and
|
||||||
// that the poll gets its response.
|
// that the poll gets its response.
|
||||||
func TestStopWaitsForRequestInProgress(t *testing.T) {
|
func TestStopWaitsForRequestInProgress(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
port := freePort(t)
|
port := freePort(t)
|
||||||
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
||||||
|
|
||||||
@@ -103,12 +106,114 @@ func TestStopWaitsForRequestInProgress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startApp starts the server with the dependencies
|
// TestStartFailsWhenHTTPPortIsTaken checks that starting the
|
||||||
// cmd/neoircd gives it, on a fresh in-memory database, with
|
// app, which runs the start hook server.New registers,
|
||||||
// the HTTP server listening on port.
|
// 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(
|
func startApp(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
port int,
|
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) {
|
) (*fxtest.App, *broker.Broker, *db.Database) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -146,13 +251,9 @@ func startApp(
|
|||||||
}),
|
}),
|
||||||
fx.Invoke(func(*server.Server) {}),
|
fx.Invoke(func(*server.Server) {}),
|
||||||
fx.Populate(&brk, &database),
|
fx.Populate(&brk, &database),
|
||||||
|
fx.Options(options...),
|
||||||
)
|
)
|
||||||
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
// Stops the app if the test ends before stopping it.
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
return app, brk, database
|
return app, brk, database
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +262,21 @@ func startApp(
|
|||||||
func freePort(t *testing.T) int {
|
func freePort(t *testing.T) int {
|
||||||
t.Helper()
|
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
|
var listenConfig net.ListenConfig
|
||||||
|
|
||||||
listener, err := listenConfig.Listen(
|
listener, err := listenConfig.Listen(
|
||||||
@@ -170,17 +286,12 @@ func freePort(t *testing.T) int {
|
|||||||
t.Fatalf("listen: %v", err)
|
t.Fatalf("listen: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("listener address %v is not TCP", listener.Addr())
|
|
||||||
}
|
|
||||||
|
|
||||||
err = listener.Close()
|
err = listener.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("close listener: %v", err)
|
t.Fatalf("close listener: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return tcpAddr.Port
|
return listener
|
||||||
}
|
}
|
||||||
|
|
||||||
// canConnect reports whether addr accepts a TCP connection.
|
// canConnect reports whether addr accepts a TCP connection.
|
||||||
|
|||||||
@@ -829,9 +829,10 @@ func (s *Service) ChangeNick(
|
|||||||
// BroadcastQuit parts the session from all its channels,
|
// BroadcastQuit parts the session from all its channels,
|
||||||
// deletes it, and queues a QUIT to the users who shared a
|
// deletes it, and queues a QUIT to the users who shared a
|
||||||
// channel with it, in one transaction, so that the QUIT is
|
// channel with it, in one transaction, so that the QUIT is
|
||||||
// queued before anyone else can take the nick. Of several
|
// queued before anyone else can take the nick, and then
|
||||||
// calls for one session at once, only the one that deletes
|
// wakes those users' long polls. Of several calls for one
|
||||||
// it sends the QUIT.
|
// session at once, only the one that deletes it sends the
|
||||||
|
// QUIT.
|
||||||
func (s *Service) BroadcastQuit(
|
func (s *Service) BroadcastQuit(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID int64,
|
sessionID int64,
|
||||||
@@ -853,6 +854,8 @@ func (s *Service) BroadcastQuit(
|
|||||||
|
|
||||||
// Logout deletes a client of the session and, if that was
|
// Logout deletes a client of the session and, if that was
|
||||||
// the session's last client, does what BroadcastQuit does.
|
// the session's last client, does what BroadcastQuit does.
|
||||||
|
// If that fails, nothing changes and Logout returns the
|
||||||
|
// error.
|
||||||
func (s *Service) Logout(
|
func (s *Service) Logout(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID, clientID int64,
|
sessionID, clientID int64,
|
||||||
|
|||||||
Reference in New Issue
Block a user