check / check (push) Waiting to run
acceptLoop called newConn, which did the reverse DNS lookup, with its three-second timeout, before the loop could accept the next connection, so one client whose address had no answering reverse DNS held up every other IRC connect. serve now does the lookup at its start, on the connection's own goroutine, and the accept loop only accepts. The server keeps the lookup function in a field, as it does the ping times, so that a test can give it one that blocks. The new test checks that a second client registers while the first one's lookup has not returned. Model: opus-5-5
1265 lines
27 KiB
Go
1265 lines
27 KiB
Go
package ircserver_test
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"runtime"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"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/ircserver"
|
|
"sneak.berlin/go/neoirc/internal/logger"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
const testTimeout = 5 * time.Second
|
|
|
|
func TestMain(m *testing.M) {
|
|
db.SetBcryptCost(4)
|
|
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
// testEnv holds the shared test infrastructure.
|
|
type testEnv struct {
|
|
database *db.Database
|
|
brk *broker.Broker
|
|
cfg *config.Config
|
|
srv *ircserver.Server
|
|
}
|
|
|
|
// testServerName is the SERVER_NAME of newTestEnv.
|
|
const testServerName = "test.irc"
|
|
|
|
func newTestEnv(t *testing.T) *testEnv {
|
|
t.Helper()
|
|
|
|
return newTestEnvWithConfig(t, &config.Config{
|
|
ServerName: testServerName,
|
|
MOTD: "Welcome to test IRC",
|
|
})
|
|
}
|
|
|
|
// newTestEnvWithOper is newTestEnv with the operator name
|
|
// testoper and password testpass.
|
|
func newTestEnvWithOper(t *testing.T) *testEnv {
|
|
t.Helper()
|
|
|
|
return newTestEnvWithConfig(t, &config.Config{
|
|
ServerName: testServerName,
|
|
MOTD: "Welcome to test IRC",
|
|
OperName: "testoper",
|
|
OperPassword: "testpass",
|
|
})
|
|
}
|
|
|
|
// newTestEnvWithConfig starts an IRC server with cfg on a
|
|
// fresh database.
|
|
func newTestEnvWithConfig(
|
|
t *testing.T,
|
|
cfg *config.Config,
|
|
) *testEnv {
|
|
t.Helper()
|
|
|
|
return newTestEnvWithPingTimes(
|
|
t, cfg, ircserver.PingInterval, ircserver.PongDeadline,
|
|
)
|
|
}
|
|
|
|
// newTestEnvWithPingTimes is newTestEnvWithConfig, except
|
|
// that the server sends a connection a PING after interval
|
|
// with nothing read, and closes it when nothing is read
|
|
// within deadline after that.
|
|
func newTestEnvWithPingTimes(
|
|
t *testing.T,
|
|
cfg *config.Config,
|
|
interval, deadline time.Duration,
|
|
) *testEnv {
|
|
t.Helper()
|
|
|
|
return newTestEnvWithResolver(
|
|
t, cfg, interval, deadline, ircserver.ResolveHost,
|
|
)
|
|
}
|
|
|
|
// newTestEnvWithResolver is newTestEnvWithPingTimes, except
|
|
// that the server looks up a client's hostname with resolve.
|
|
func newTestEnvWithResolver(
|
|
t *testing.T,
|
|
cfg *config.Config,
|
|
interval, deadline time.Duration,
|
|
resolve func(ctx context.Context, addr string) string,
|
|
) *testEnv {
|
|
t.Helper()
|
|
|
|
// A random name, so that no other test environment, not
|
|
// even an earlier run of the same test, can share it.
|
|
dsn := fmt.Sprintf(
|
|
"file:%s?mode=memory&cache=shared",
|
|
rand.Text(),
|
|
)
|
|
|
|
conn, err := sql.Open("sqlite", dsn)
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
err := conn.Close()
|
|
if err != nil {
|
|
t.Logf("close db: %v", err)
|
|
}
|
|
})
|
|
|
|
conn.SetMaxOpenConns(1)
|
|
|
|
_, err = conn.ExecContext(
|
|
t.Context(), "PRAGMA foreign_keys = ON",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("pragma: %v", err)
|
|
}
|
|
|
|
database := db.NewTestDatabaseFromConn(conn)
|
|
|
|
err = database.RunMigrations(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
|
|
brk := broker.New()
|
|
|
|
var listenConfig net.ListenConfig
|
|
|
|
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
|
|
addr := listener.Addr().String()
|
|
|
|
err = listener.Close()
|
|
if err != nil {
|
|
t.Fatalf("close listener: %v", err)
|
|
}
|
|
|
|
log := slog.New(slog.NewTextHandler(
|
|
os.Stderr,
|
|
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
|
|
))
|
|
|
|
srv := ircserver.NewTestServer(
|
|
log, cfg, database, brk, interval, deadline, resolve,
|
|
)
|
|
|
|
err = srv.Start(addr)
|
|
if err != nil {
|
|
t.Fatalf("start irc server: %v", err)
|
|
}
|
|
|
|
// Cleanups run last registered first, so the server stops
|
|
// before its database is closed.
|
|
t.Cleanup(srv.Stop)
|
|
|
|
return &testEnv{
|
|
database: database,
|
|
brk: brk,
|
|
cfg: cfg,
|
|
srv: srv,
|
|
}
|
|
}
|
|
|
|
// dial connects to the test server.
|
|
func (env *testEnv) dial(t *testing.T) *testClient {
|
|
t.Helper()
|
|
|
|
dialer := net.Dialer{Timeout: testTimeout}
|
|
|
|
conn, err := dialer.DialContext(
|
|
t.Context(),
|
|
"tcp",
|
|
env.srv.Listener().Addr().String(),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
err := conn.Close()
|
|
if err != nil {
|
|
t.Logf("close conn: %v", err)
|
|
}
|
|
})
|
|
|
|
return &testClient{
|
|
t: t,
|
|
conn: conn,
|
|
scanner: bufio.NewScanner(conn),
|
|
}
|
|
}
|
|
|
|
// The ping interval and PONG deadline for
|
|
// newTestEnvWithShortPings. They differ, so that a test can
|
|
// tell them apart; the PONG deadline is how long a test
|
|
// client has to answer a PING.
|
|
const (
|
|
testPingInterval = time.Second
|
|
testPongDeadline = 300 * time.Millisecond
|
|
)
|
|
|
|
// newTestEnvWithShortPings starts an IRC server that uses
|
|
// testPingInterval and testPongDeadline in place of
|
|
// pingInterval and pongDeadline.
|
|
func newTestEnvWithShortPings(t *testing.T) *testEnv {
|
|
t.Helper()
|
|
|
|
return newTestEnvWithPingTimes(
|
|
t,
|
|
&config.Config{ServerName: testServerName},
|
|
testPingInterval, testPongDeadline,
|
|
)
|
|
}
|
|
|
|
// isServerPing reports whether line is the PING the test
|
|
// server sends a quiet client.
|
|
func isServerPing(line string) bool {
|
|
return line == "PING :"+testServerName
|
|
}
|
|
|
|
// testClient wraps a raw TCP connection with helpers.
|
|
type testClient struct {
|
|
t *testing.T
|
|
conn net.Conn
|
|
scanner *bufio.Scanner
|
|
}
|
|
|
|
func (tc *testClient) send(line string) {
|
|
tc.t.Helper()
|
|
|
|
_ = tc.conn.SetWriteDeadline(
|
|
time.Now().Add(testTimeout),
|
|
)
|
|
|
|
_, err := fmt.Fprintf(tc.conn, "%s\r\n", line)
|
|
if err != nil {
|
|
tc.t.Fatalf("send: %v", err)
|
|
}
|
|
}
|
|
|
|
func (tc *testClient) readLine() string {
|
|
tc.t.Helper()
|
|
|
|
_ = tc.conn.SetReadDeadline(
|
|
time.Now().Add(testTimeout),
|
|
)
|
|
|
|
if !tc.scanner.Scan() {
|
|
err := tc.scanner.Err()
|
|
if err != nil {
|
|
tc.t.Fatalf("read: %v", err)
|
|
}
|
|
|
|
tc.t.Fatal("connection closed unexpectedly")
|
|
}
|
|
|
|
return tc.scanner.Text()
|
|
}
|
|
|
|
// readUntil reads lines until one matches the predicate.
|
|
func (tc *testClient) readUntil(
|
|
pred func(string) bool,
|
|
) []string {
|
|
tc.t.Helper()
|
|
|
|
var lines []string
|
|
|
|
for {
|
|
line := tc.readLine()
|
|
lines = append(lines, line)
|
|
|
|
if pred(line) {
|
|
return lines
|
|
}
|
|
}
|
|
}
|
|
|
|
// register sends NICK + USER and reads through the welcome
|
|
// burst.
|
|
func (tc *testClient) register(nick string) []string {
|
|
tc.t.Helper()
|
|
|
|
tc.send("NICK " + nick)
|
|
tc.send("USER " + nick + " 0 * :Test User")
|
|
|
|
return tc.readUntil(func(line string) bool {
|
|
return strings.Contains(line, " 376 ") ||
|
|
strings.Contains(line, " 422 ")
|
|
})
|
|
}
|
|
|
|
// readUntilClosed returns the lines received until the
|
|
// server closes the connection, and fails the test if it is
|
|
// still open after testTimeout.
|
|
func (tc *testClient) readUntilClosed() []string {
|
|
tc.t.Helper()
|
|
|
|
_ = tc.conn.SetReadDeadline(
|
|
time.Now().Add(testTimeout),
|
|
)
|
|
|
|
var lines []string
|
|
|
|
for tc.scanner.Scan() {
|
|
lines = append(lines, tc.scanner.Text())
|
|
}
|
|
|
|
err := tc.scanner.Err()
|
|
if err != nil {
|
|
tc.t.Fatalf(
|
|
"connection not closed: %v (lines: %v)",
|
|
err, lines,
|
|
)
|
|
}
|
|
|
|
return lines
|
|
}
|
|
|
|
// readServerPing reads until the PING the test server sends a
|
|
// quiet client, and fails the test if it came sooner than
|
|
// testPingInterval after lastSent, a time just before the
|
|
// client sent its last line.
|
|
func (tc *testClient) readServerPing(lastSent time.Time) {
|
|
tc.t.Helper()
|
|
|
|
tc.readUntil(isServerPing)
|
|
|
|
waited := time.Since(lastSent)
|
|
if waited < testPingInterval {
|
|
tc.t.Errorf(
|
|
"PING came %v after the client's last line, want at least %v",
|
|
waited, testPingInterval,
|
|
)
|
|
}
|
|
}
|
|
|
|
// assertContains checks that at least one line matches the
|
|
// given substring.
|
|
func assertContains(
|
|
t *testing.T,
|
|
lines []string,
|
|
substr, description string,
|
|
) {
|
|
t.Helper()
|
|
|
|
for _, line := range lines {
|
|
if strings.Contains(line, substr) {
|
|
return
|
|
}
|
|
}
|
|
|
|
t.Errorf("did not find %q in output: %s", substr, description)
|
|
}
|
|
|
|
// assertNotContains checks that no line matches the given
|
|
// substring.
|
|
func assertNotContains(
|
|
t *testing.T,
|
|
lines []string,
|
|
substr, description string,
|
|
) {
|
|
t.Helper()
|
|
|
|
for _, line := range lines {
|
|
if strings.Contains(line, substr) {
|
|
t.Errorf(
|
|
"unexpectedly found %q in output: %s",
|
|
substr, description,
|
|
)
|
|
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// joinAndDrain joins a channel and reads until
|
|
// RPL_ENDOFNAMES.
|
|
func (tc *testClient) joinAndDrain(channel string) {
|
|
tc.t.Helper()
|
|
|
|
tc.send("JOIN " + channel)
|
|
|
|
tc.readUntil(func(line string) bool {
|
|
return strings.Contains(line, " 366 ")
|
|
})
|
|
}
|
|
|
|
// sendAndExpect sends a command and reads until a line
|
|
// containing the expected substring is found.
|
|
func (tc *testClient) sendAndExpect(
|
|
cmd, expect string,
|
|
) []string {
|
|
tc.t.Helper()
|
|
|
|
tc.send(cmd)
|
|
|
|
return tc.readUntil(func(line string) bool {
|
|
return strings.Contains(line, expect)
|
|
})
|
|
}
|
|
|
|
func TestRegistration(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
lines := client.register("alice")
|
|
assertContains(t, lines, " 001 ", "RPL_WELCOME")
|
|
}
|
|
|
|
// TestRegistrationWhileAnotherLookupIsPending checks that a
|
|
// client is accepted and registered while the hostname lookup
|
|
// of an earlier client has not returned, and that it
|
|
// registers with the hostname its own lookup returned.
|
|
func TestRegistrationWhileAnotherLookupIsPending(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const lookedUpHost = "client.example"
|
|
|
|
var lookups atomic.Int32
|
|
|
|
firstLookupStarted := make(chan struct{})
|
|
|
|
// The first lookup returns only when the server stops.
|
|
resolve := func(ctx context.Context, _ string) string {
|
|
if lookups.Add(1) == 1 {
|
|
close(firstLookupStarted)
|
|
<-ctx.Done()
|
|
}
|
|
|
|
return lookedUpHost
|
|
}
|
|
|
|
env := newTestEnvWithResolver(
|
|
t, &config.Config{ServerName: testServerName},
|
|
ircserver.PingInterval, ircserver.PongDeadline, resolve,
|
|
)
|
|
|
|
env.dial(t)
|
|
|
|
select {
|
|
case <-firstLookupStarted:
|
|
case <-time.After(testTimeout):
|
|
t.Fatal("the first client's lookup did not start")
|
|
}
|
|
|
|
lines := env.dial(t).register("second")
|
|
assertContains(
|
|
t, lines, "second!second@"+lookedUpHost,
|
|
"RPL_WELCOME with the looked-up hostname",
|
|
)
|
|
}
|
|
|
|
// TestEachTestEnvHasItsOwnDatabase checks that a test
|
|
// environment starts on an empty database while another one
|
|
// is still open.
|
|
func TestEachTestEnvHasItsOwnDatabase(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
first := newTestEnv(t)
|
|
first.dial(t).register("samenick")
|
|
|
|
second := newTestEnv(t)
|
|
lines := second.dial(t).register("samenick")
|
|
assertContains(t, lines, " 001 ", "RPL_WELCOME")
|
|
}
|
|
|
|
func TestWelcomeContainsNick(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
lines := client.register("bob")
|
|
|
|
for _, line := range lines {
|
|
if strings.Contains(line, " 001 ") &&
|
|
!strings.Contains(line, "bob") {
|
|
t.Errorf("001 should contain nick: %s", line)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPingPong(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.register("pingtest")
|
|
lines := client.sendAndExpect("PING :hello", "PONG")
|
|
assertContains(t, lines, "PONG", "PONG response")
|
|
}
|
|
|
|
// TestQuietClientAnsweringPingStaysConnected checks that a
|
|
// registered client that sends nothing is sent a PING once
|
|
// the ping interval has passed since its last line, and stays
|
|
// connected as long as it answers each one, whether with a
|
|
// PONG or with another line.
|
|
func TestQuietClientAnsweringPingStaysConnected(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
answer string
|
|
}{
|
|
{name: "PONG", answer: "PONG :" + testServerName},
|
|
{name: "PING of its own", answer: "PING :answer"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnvWithShortPings(t)
|
|
client := env.dial(t)
|
|
|
|
lastSent := time.Now()
|
|
|
|
client.register("answers")
|
|
|
|
// The second PING comes later than a client that
|
|
// answers none stays connected.
|
|
for range 2 {
|
|
client.readServerPing(lastSent)
|
|
|
|
lastSent = time.Now()
|
|
|
|
client.send(tt.answer)
|
|
}
|
|
|
|
client.sendAndExpect("PING :still here", "still here")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestQuietClientNotAnsweringPingIsClosed checks that a
|
|
// registered client that does not answer the PING is
|
|
// disconnected once the PONG deadline has passed after it.
|
|
func TestQuietClientNotAnsweringPingIsClosed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// latest leaves room for a loaded host, but is sooner than
|
|
// a read deadline of two ping intervals would close it.
|
|
const (
|
|
earliest = testPingInterval + testPongDeadline
|
|
latest = 2 * testPingInterval
|
|
)
|
|
|
|
env := newTestEnvWithShortPings(t)
|
|
client := env.dial(t)
|
|
|
|
lastSent := time.Now()
|
|
|
|
client.register("silent")
|
|
client.readServerPing(lastSent)
|
|
client.readUntilClosed()
|
|
|
|
waited := time.Since(lastSent)
|
|
if waited < earliest || waited >= latest {
|
|
t.Errorf(
|
|
"closed %v after the client's last line, want %v to %v",
|
|
waited, earliest, latest,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestUnregisteredClientAnswersPing checks that a PONG before
|
|
// registration, the answer to the PING unregistered clients
|
|
// are sent too, gets no ERR_NOTREGISTERED.
|
|
func TestUnregisteredClientAnswersPing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnvWithShortPings(t)
|
|
client := env.dial(t)
|
|
|
|
client.readUntil(isServerPing)
|
|
client.send("PONG :" + testServerName)
|
|
|
|
lines := client.sendAndExpect("PING :after", "PONG")
|
|
assertNotContains(t, lines, " 451 ", "ERR_NOTREGISTERED")
|
|
}
|
|
|
|
// TestNewPingTimes checks that a server made by New sends a
|
|
// PING after 90 seconds with nothing read, and closes the
|
|
// connection 30 seconds after that, as README.md states.
|
|
func TestNewPingTimes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
wantInterval = 90 * time.Second
|
|
wantDeadline = 30 * time.Second
|
|
)
|
|
|
|
log, err := logger.New(nil, logger.Params{
|
|
Globals: &globals.Globals{},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// With no IRC_LISTEN_ADDR, New starts no listener and
|
|
// uses nothing else from its params.
|
|
srv := ircserver.New(fxtest.NewLifecycle(t), ircserver.Params{
|
|
Logger: log,
|
|
Config: &config.Config{},
|
|
})
|
|
|
|
interval, deadline := srv.PingTimes()
|
|
if interval != wantInterval || deadline != wantDeadline {
|
|
t.Errorf(
|
|
"PING after %v and close %v later, want %v and %v",
|
|
interval, deadline, wantInterval, wantDeadline,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestIdleCleanupKeepsActiveUser checks that a user who
|
|
// sends commands is kept by the idle cleanup, and a user who
|
|
// sends nothing is not.
|
|
func TestIdleCleanupKeepsActiveUser(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const idleTimeout = 500 * time.Millisecond
|
|
|
|
env := newTestEnv(t)
|
|
|
|
active := env.dial(t)
|
|
active.register("active")
|
|
|
|
idle := env.dial(t)
|
|
idle.register("idle")
|
|
|
|
time.Sleep(idleTimeout)
|
|
active.sendAndExpect("PING :still here", "PONG")
|
|
|
|
// The idle cleanup in internal/handlers removes exactly
|
|
// the users this returns.
|
|
stale, err := env.database.GetStaleOrphanSessions(
|
|
t.Context(), time.Now().Add(-idleTimeout),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("get stale sessions: %v", err)
|
|
}
|
|
|
|
if len(stale) != 1 || stale[0].Nick != "idle" {
|
|
t.Errorf("cleanup removes %v, want only idle", stale)
|
|
}
|
|
}
|
|
|
|
func TestJoinChannel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.register("joiner")
|
|
client.send("JOIN #test")
|
|
|
|
lines := client.readUntil(func(line string) bool {
|
|
return strings.Contains(line, " 366 ")
|
|
})
|
|
|
|
assertContains(t, lines, "JOIN", "JOIN echo")
|
|
assertContains(t, lines, " 366 ", "RPL_ENDOFNAMES")
|
|
}
|
|
|
|
// TestJoinInvalidChannelName checks that JOIN refuses a name
|
|
// that is not # followed by 1 to 63 letters, digits, _ or -
|
|
// with ERR_NOSUCHCHANNEL, and creates no such channel.
|
|
func TestJoinInvalidChannelName(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.register("joiner")
|
|
|
|
for _, channel := range []string{
|
|
"#bad.name",
|
|
"#",
|
|
"#" + strings.Repeat("a", 64),
|
|
} {
|
|
lines := client.sendAndExpect("JOIN "+channel, " 403 ")
|
|
|
|
assertContains(
|
|
t, lines,
|
|
" 403 joiner "+channel+" :No such channel",
|
|
channel,
|
|
)
|
|
|
|
_, err := env.database.GetChannelByName(t.Context(), channel)
|
|
if err == nil {
|
|
t.Errorf("channel %q was created", channel)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPrivmsgBetweenClients(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
alice := env.dial(t)
|
|
alice.register("alice_pm")
|
|
|
|
bob := env.dial(t)
|
|
bob.register("bob_pm")
|
|
|
|
alice.joinAndDrain("#chat")
|
|
bob.joinAndDrain("#chat")
|
|
|
|
alice.send("PRIVMSG #chat :hello bob!")
|
|
|
|
lines := bob.sendAndExpect("PING :sync", "hello bob!")
|
|
assertContains(t, lines, "hello bob!", "channel PRIVMSG")
|
|
}
|
|
|
|
func TestNickChange(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.register("oldnick")
|
|
lines := client.sendAndExpect("NICK newnick", "newnick")
|
|
assertContains(t, lines, "NICK", "NICK change echo")
|
|
}
|
|
|
|
func TestDuplicateNick(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
first := env.dial(t)
|
|
first.register("taken")
|
|
|
|
second := env.dial(t)
|
|
second.send("NICK taken")
|
|
second.send("USER taken 0 * :Test")
|
|
|
|
lines := second.readUntil(func(line string) bool {
|
|
return strings.Contains(line, " 433 ")
|
|
})
|
|
|
|
assertContains(t, lines, " 433 ", "ERR_NICKNAMEINUSE")
|
|
}
|
|
|
|
// TestNickRefused checks that NICK, before and after
|
|
// registration, answers a nick the HTTP API refuses with
|
|
// ERR_ERRONEUSNICKNAME and keeps the nick as it was, and
|
|
// that another member of the channel sees no line from it.
|
|
func TestNickRefused(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Each is what follows "NICK " on the wire.
|
|
refused := []string{
|
|
":bob PRIVMSG #inj :hi", // contains spaces
|
|
":", // empty
|
|
strings.Repeat("a", 33), // one character too long
|
|
"bob!x",
|
|
"bob@x",
|
|
"#bob",
|
|
}
|
|
|
|
env := newTestEnv(t)
|
|
|
|
// Before registration: the 432 is addressed to the nick
|
|
// sent first, and USER registers with that nick.
|
|
early := env.dial(t)
|
|
early.send("NICK early")
|
|
|
|
for _, param := range refused {
|
|
lines := early.sendAndExpect("NICK "+param, " 432 ")
|
|
assertContains(t, lines, " 432 early ", "before: "+param)
|
|
}
|
|
|
|
welcome := early.sendAndExpect("USER early 0 * :Test", " 001 ")
|
|
assertContains(t, welcome, " 001 early ", "registered as early")
|
|
|
|
// After registration.
|
|
bob := env.dial(t)
|
|
bob.register("bob")
|
|
bob.joinAndDrain("#inj")
|
|
|
|
mallory := env.dial(t)
|
|
mallory.register("mallory")
|
|
mallory.joinAndDrain("#inj")
|
|
|
|
bob.readUntil(func(l string) bool {
|
|
return strings.Contains(l, "JOIN") &&
|
|
strings.Contains(l, "mallory")
|
|
})
|
|
|
|
for _, param := range refused {
|
|
lines := mallory.sendAndExpect("NICK "+param, " 432 ")
|
|
assertContains(t, lines, " 432 mallory ", "after: "+param)
|
|
}
|
|
|
|
mallory.send("PRIVMSG #inj :still mallory")
|
|
|
|
bobLines := bob.readUntil(func(l string) bool {
|
|
return strings.Contains(l, "still mallory")
|
|
})
|
|
assertNotContains(t, bobLines, " NICK ", "bob sees no NICK")
|
|
assertContains(
|
|
t, bobLines, ":mallory!", "bob sees mallory's message",
|
|
)
|
|
}
|
|
|
|
func TestListChannels(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.register("lister")
|
|
client.joinAndDrain("#listtest")
|
|
lines := client.sendAndExpect("LIST", " 323 ")
|
|
assertContains(t, lines, " 323 ", "RPL_LISTEND") //nolint:misspell // IRC term
|
|
}
|
|
|
|
func TestWhois(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.register("whoistest")
|
|
lines := client.sendAndExpect(
|
|
"WHOIS whoistest", " 318 ",
|
|
)
|
|
|
|
assertContains(t, lines, " 311 ", "RPL_WHOISUSER")
|
|
assertContains(t, lines, " 318 ", "RPL_ENDOFWHOIS")
|
|
}
|
|
|
|
func TestQuit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.register("quitter")
|
|
lines := client.sendAndExpect(
|
|
"QUIT :goodbye", "ERROR",
|
|
)
|
|
|
|
assertContains(t, lines, "goodbye", "QUIT reason")
|
|
}
|
|
|
|
// TestStopEndsSessions checks that stopping the server ends
|
|
// a registered connection's session before Stop returns, as
|
|
// closing the connection does: the session, its client and
|
|
// its channel membership are gone from the database, and a
|
|
// channel peer has been sent the QUIT.
|
|
func TestStopEndsSessions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.register("shutirc")
|
|
client.joinAndDrain("#shut")
|
|
|
|
ctx := t.Context()
|
|
|
|
// A peer on the HTTP API, whose session outlives the stop.
|
|
peerID, peerClientID, _, err := env.database.CreateSession(
|
|
ctx, "peer", "peer", "", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("create peer session: %v", err)
|
|
}
|
|
|
|
channelID, err := env.database.GetChannelByName(ctx, "#shut")
|
|
if err != nil {
|
|
t.Fatalf("get channel: %v", err)
|
|
}
|
|
|
|
err = env.database.JoinChannel(ctx, channelID, peerID)
|
|
if err != nil {
|
|
t.Fatalf("join peer: %v", err)
|
|
}
|
|
|
|
env.srv.Stop()
|
|
|
|
_, err = env.database.GetSessionByNick(ctx, "shutirc")
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
t.Errorf("look up shutirc after stop: %v, want no rows", err)
|
|
}
|
|
|
|
clients, err := env.database.GetClientCount(ctx)
|
|
if err != nil {
|
|
t.Fatalf("count clients: %v", err)
|
|
}
|
|
|
|
if clients != 1 {
|
|
t.Errorf("%d clients after stop, want only the peer's", clients)
|
|
}
|
|
|
|
members, err := env.database.GetChannelMemberIDs(ctx, channelID)
|
|
if err != nil {
|
|
t.Fatalf("get members: %v", err)
|
|
}
|
|
|
|
if len(members) != 1 || members[0] != peerID {
|
|
t.Errorf(
|
|
"members of #shut after stop: %v, want only the peer %d",
|
|
members, peerID,
|
|
)
|
|
}
|
|
|
|
msgs, _, err := env.database.PollMessages(ctx, peerClientID, 0, 0)
|
|
if err != nil {
|
|
t.Fatalf("poll peer: %v", err)
|
|
}
|
|
|
|
for _, msg := range msgs {
|
|
if msg.Command == "QUIT" && msg.From == "shutirc" {
|
|
return
|
|
}
|
|
}
|
|
|
|
t.Errorf("peer was not sent the QUIT of shutirc: %v", msgs)
|
|
}
|
|
|
|
// TestStopReturnsWhileCleanupIsStuck checks that Stop stops
|
|
// waiting for a connection's cleanup after StopTimeout, so a
|
|
// cleanup that cannot finish does not hold up stopping.
|
|
func TestStopReturnsWhileCleanupIsStuck(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.register("stuck")
|
|
|
|
// The test database has one connection. While the test
|
|
// holds it, the cleanup's first query waits for it.
|
|
held, err := env.database.GetDB().Conn(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("take the database connection: %v", err)
|
|
}
|
|
|
|
stopped := make(chan struct{})
|
|
|
|
go func() {
|
|
env.srv.Stop()
|
|
close(stopped)
|
|
}()
|
|
|
|
select {
|
|
case <-stopped:
|
|
case <-time.After(ircserver.StopTimeout + testTimeout):
|
|
t.Error("Stop did not return while a cleanup was stuck")
|
|
}
|
|
|
|
// Lets the cleanup finish before the database is closed.
|
|
err = held.Close()
|
|
if err != nil {
|
|
t.Errorf("release the database connection: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTopicSetAndGet(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.register("topicuser")
|
|
client.joinAndDrain("#topictest")
|
|
lines := client.sendAndExpect(
|
|
"TOPIC #topictest :New topic here",
|
|
"New topic here",
|
|
)
|
|
|
|
assertContains(
|
|
t, lines, "New topic here", "TOPIC echo",
|
|
)
|
|
}
|
|
|
|
func TestUnknownCommand(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.register("unknowncmd")
|
|
lines := client.sendAndExpect("FOOBAR", " 421 ")
|
|
assertContains(t, lines, " 421 ", "ERR_UNKNOWNCOMMAND")
|
|
}
|
|
|
|
func TestDirectMessage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
sender := env.dial(t)
|
|
sender.register("dmsender")
|
|
|
|
receiver := env.dial(t)
|
|
receiver.register("dmreceiver")
|
|
|
|
// Give relay goroutines time to start.
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
sender.send("PRIVMSG dmreceiver :hello privately")
|
|
|
|
lines := receiver.readUntil(func(line string) bool {
|
|
return strings.Contains(line, "hello privately")
|
|
})
|
|
|
|
assertContains(
|
|
t, lines, "hello privately", "direct PRIVMSG",
|
|
)
|
|
}
|
|
|
|
func TestCAPNegotiation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.send("CAP LS 302")
|
|
|
|
line := client.readLine()
|
|
if !strings.Contains(line, "CAP") {
|
|
t.Errorf("expected CAP response, got: %s", line)
|
|
}
|
|
|
|
client.send("CAP END")
|
|
lines := client.register("capuser")
|
|
assertContains(t, lines, " 001 ", "registration after CAP")
|
|
}
|
|
|
|
func TestPartChannel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.register("parter")
|
|
client.joinAndDrain("#parttest")
|
|
lines := client.sendAndExpect(
|
|
"PART #parttest :leaving", "PART",
|
|
)
|
|
|
|
assertContains(t, lines, "#parttest", "PART echo")
|
|
}
|
|
|
|
func TestModeQuery(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.register("modeuser")
|
|
client.joinAndDrain("#modetest")
|
|
lines := client.sendAndExpect(
|
|
"MODE #modetest", " 324 ",
|
|
)
|
|
|
|
assertContains(
|
|
t, lines, " 324 ", "RPL_CHANNELMODEIS",
|
|
)
|
|
}
|
|
|
|
func TestWhoChannel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.register("whouser")
|
|
client.joinAndDrain("#whotest")
|
|
lines := client.sendAndExpect("WHO #whotest", " 315 ")
|
|
assertContains(t, lines, " 352 ", "RPL_WHOREPLY")
|
|
}
|
|
|
|
func TestLusers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.register("luseruser")
|
|
lines := client.sendAndExpect("LUSERS", " 255 ")
|
|
assertContains(t, lines, " 251 ", "RPL_LUSERCLIENT")
|
|
}
|
|
|
|
func TestMotd(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.register("motduser")
|
|
lines := client.sendAndExpect("MOTD", " 376 ")
|
|
assertContains(t, lines, " 376 ", "RPL_ENDOFMOTD")
|
|
}
|
|
|
|
func TestAwaySetAndClear(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.register("awayuser")
|
|
|
|
setLines := client.sendAndExpect(
|
|
"AWAY :brb lunch", " 306 ",
|
|
)
|
|
assertContains(t, setLines, " 306 ", "RPL_NOWAWAY")
|
|
|
|
clearLines := client.sendAndExpect("AWAY", " 305 ")
|
|
assertContains(t, clearLines, " 305 ", "RPL_UNAWAY")
|
|
}
|
|
|
|
func TestHandlePassPostRegistration(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.register("passuser")
|
|
lines := client.sendAndExpect(
|
|
"PASS :mypassword123", "Password set",
|
|
)
|
|
|
|
assertContains(
|
|
t, lines, "Password set", "password confirmation",
|
|
)
|
|
}
|
|
|
|
func TestPreRegistrationNotRegistered(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.send("PRIVMSG #test :hello")
|
|
|
|
line := client.readLine()
|
|
if !strings.Contains(line, " 451 ") {
|
|
t.Errorf(
|
|
"expected ERR_NOTREGISTERED (451), got: %s",
|
|
line,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestNamesNonExistentChannel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.register("namesuser")
|
|
lines := client.sendAndExpect(
|
|
"NAMES #doesnotexist", " 366 ",
|
|
)
|
|
|
|
assertContains(
|
|
t, lines, " 366 ",
|
|
"RPL_ENDOFNAMES for non-existent channel",
|
|
)
|
|
}
|
|
|
|
// TestRelayStopsWhenConnectionCloses checks that closing a
|
|
// registered client's connection stops the goroutine that
|
|
// relays its messages.
|
|
//
|
|
//nolint:paralleltest // counts every goroutine in the process
|
|
func TestRelayStopsWhenConnectionCloses(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.register("relaystop")
|
|
waitForRelayGoroutines(t, 1)
|
|
|
|
err := client.conn.Close()
|
|
if err != nil {
|
|
t.Fatalf("close: %v", err)
|
|
}
|
|
|
|
waitForRelayGoroutines(t, 0)
|
|
}
|
|
|
|
const (
|
|
stackDumpSize = 1 << 20
|
|
relayCheckStep = 10 * time.Millisecond
|
|
)
|
|
|
|
// waitForRelayGoroutines waits until exactly want goroutines
|
|
// are running the relay loop, and fails the test if that does
|
|
// not happen within testTimeout.
|
|
func waitForRelayGoroutines(t *testing.T, want int) {
|
|
t.Helper()
|
|
|
|
deadline := time.Now().Add(testTimeout)
|
|
stacks := make([]byte, stackDumpSize)
|
|
|
|
for {
|
|
n := runtime.Stack(stacks, true)
|
|
got := strings.Count(string(stacks[:n]), "(*Conn).relayMessages(")
|
|
|
|
if got == want {
|
|
return
|
|
}
|
|
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("relay goroutines: got %d, want %d", got, want)
|
|
}
|
|
|
|
time.Sleep(relayCheckStep)
|
|
}
|
|
}
|
|
|
|
func BenchmarkParseMessage(b *testing.B) {
|
|
line := ":nick!user@host PRIVMSG #channel :Hello, world!"
|
|
|
|
b.ResetTimer()
|
|
|
|
for range b.N {
|
|
_ = ircserver.ParseMessage(line)
|
|
}
|
|
}
|
|
|
|
func BenchmarkFormatMessage(b *testing.B) {
|
|
b.ResetTimer()
|
|
|
|
for range b.N {
|
|
_ = ircserver.FormatMessage(
|
|
"nick!user@host", "PRIVMSG",
|
|
"#channel", "Hello, world!",
|
|
)
|
|
}
|
|
}
|