check / check (push) Waiting to run
bcrypt cannot hash a password longer than 72 bytes. PASS over the HTTP API answered one with a 500, and the IRC listener confirmed it without storing it. Both transports now refuse a password outside 8 to 72 bytes with the reply a short one already got: 461 over the HTTP API, a NOTICE over the IRC listener. setPassword returns its error, and "Password set" is sent only when the password was stored. A PASS given before registration is checked after the welcome burst and gets the same NOTICE. Model: opus-5-5
1390 lines
30 KiB
Go
1390 lines
30 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",
|
|
)
|
|
}
|
|
|
|
// maxPasswordBytes is the longest password bcrypt can hash.
|
|
const maxPasswordBytes = 72
|
|
|
|
// passwordLengthNotice is the NOTICE text for a password that
|
|
// is too short or too long.
|
|
const passwordLengthNotice = "Password must be 8 to 72 bytes"
|
|
|
|
// assertCanLogin fails the test if nick cannot log in with
|
|
// password.
|
|
func assertCanLogin(t *testing.T, env *testEnv, nick, password string) {
|
|
t.Helper()
|
|
|
|
_, _, token, err := env.database.LoginUser(
|
|
t.Context(), nick, password, "127.0.0.1", "localhost",
|
|
)
|
|
if err != nil || token == "" {
|
|
t.Errorf("log in as %s: token %q, %v", nick, token, err)
|
|
}
|
|
}
|
|
|
|
// TestPassPostRegistrationLength checks that a PASS over
|
|
// maxPasswordBytes after registration is refused with a
|
|
// NOTICE, and that one of exactly maxPasswordBytes is stored
|
|
// and confirmed.
|
|
func TestPassPostRegistrationLength(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.register("longpass")
|
|
|
|
client.sendAndExpect(
|
|
"PASS :"+strings.Repeat("a", maxPasswordBytes+1),
|
|
passwordLengthNotice,
|
|
)
|
|
|
|
longest := strings.Repeat("a", maxPasswordBytes)
|
|
client.sendAndExpect("PASS :"+longest, "Password set")
|
|
|
|
assertCanLogin(t, env, "longpass", longest)
|
|
}
|
|
|
|
// TestPassPostRegistrationNotStored checks that PASS is not
|
|
// confirmed when the password could not be stored.
|
|
func TestPassPostRegistrationNotStored(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
client.register("failpass")
|
|
|
|
_, err := env.database.GetDB().ExecContext(t.Context(),
|
|
`CREATE TRIGGER refuse_password
|
|
BEFORE UPDATE OF password_hash ON sessions
|
|
BEGIN SELECT RAISE(ABORT, 'refused'); END`,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("create trigger: %v", err)
|
|
}
|
|
|
|
client.send("PASS :mypassword123")
|
|
|
|
// The server answers commands in order, so anything it
|
|
// sent about PASS comes before the PONG.
|
|
lines := client.sendAndExpect("PING :after", "PONG")
|
|
|
|
assertNotContains(
|
|
t, lines, "Password set",
|
|
"confirmation of a password that was not stored",
|
|
)
|
|
}
|
|
|
|
// TestPassBeforeRegistrationLength checks that a PASS before
|
|
// registration that is too short or too long is refused with
|
|
// a NOTICE once registration completes.
|
|
func TestPassBeforeRegistrationLength(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
nick, password string
|
|
}{
|
|
{"preshort", "short"},
|
|
{"prelong", strings.Repeat("a", maxPasswordBytes+1)},
|
|
}
|
|
|
|
env := newTestEnv(t)
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.nick, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := env.dial(t)
|
|
|
|
client.send("PASS :" + tc.password)
|
|
client.register(tc.nick)
|
|
|
|
client.readUntil(func(line string) bool {
|
|
return strings.Contains(line, passwordLengthNotice)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPassBeforeRegistrationStored checks that a PASS of
|
|
// exactly maxPasswordBytes before registration is stored.
|
|
func TestPassBeforeRegistrationStored(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
client := env.dial(t)
|
|
|
|
longest := strings.Repeat("a", maxPasswordBytes)
|
|
|
|
client.send("PASS :" + longest)
|
|
client.register("prestored")
|
|
|
|
// The password is stored after the welcome burst; the
|
|
// PONG comes after that.
|
|
client.sendAndExpect("PING :after", "PONG")
|
|
|
|
assertCanLogin(t, env, "prestored", longest)
|
|
}
|
|
|
|
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!",
|
|
)
|
|
}
|
|
}
|