Files
neoirc/internal/handlers/api_test.go
T
sneak 1233f44c8b
check / check (push) Waiting to run
Make the internal/handlers tests fast (closes #113)
Nearly all of the package's test time was SQLite work, and much of it
was setup every test repeated: each test migrated a fresh schema, and
each new session wrote the seven-line default MOTD to the database.
TestMain now migrates one in-memory template database once, and each
test starts from its own copy of it, made with SQLite's backup API.
The test config's MOTD is one line. Every test still gets an empty
database of its own, and every check runs as before.

Model: opus-5-5
2026-10-06 21:16:32 +00:00

5043 lines
105 KiB
Go

// Tests use a global viper instance for configuration,
// making parallel execution unsafe.
//
//nolint:paralleltest
package handlers_test
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"slices"
"strconv"
"strings"
"sync"
"testing"
"time"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
"golang.org/x/crypto/bcrypt"
"modernc.org/sqlite"
"sneak.berlin/go/neoirc/internal/broker"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/db"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/handlers"
"sneak.berlin/go/neoirc/internal/hashcash"
"sneak.berlin/go/neoirc/internal/healthcheck"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/middleware"
"sneak.berlin/go/neoirc/internal/server"
"sneak.berlin/go/neoirc/internal/service"
"sneak.berlin/go/neoirc/internal/stats"
"sneak.berlin/go/neoirc/pkg/irc"
)
// templateDBURL names the in-memory database TestMain migrates once
// and every test copies; it lasts while TestMain holds it open.
const templateDBURL = "file:template?mode=memory&cache=shared"
func TestMain(m *testing.M) {
db.SetBcryptCost(bcrypt.MinCost)
template, err := sql.Open("sqlite", templateDBURL)
if err != nil {
panic(err)
}
err = db.NewTestDatabaseFromConn(template).
RunMigrations(context.Background())
if err != nil {
panic(err)
}
code := m.Run()
_ = template.Close()
os.Exit(code)
}
// copyTemplateDB creates the in-memory database dbURL as a copy of
// the migrated template, so a test does not migrate the schema
// again. The copy lasts until the test ends.
func copyTemplateDB(t *testing.T, dbURL string) {
t.Helper()
database, err := sql.Open("sqlite", dbURL)
if err != nil {
t.Fatalf("open test db: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
conn, err := database.Conn(t.Context())
if err != nil {
t.Fatalf("connect test db: %v", err)
}
defer func() { _ = conn.Close() }()
err = conn.Raw(func(driverConn any) error {
restorer, ok := driverConn.(interface {
NewRestore(srcURI string) (*sqlite.Backup, error)
})
if !ok {
return errors.ErrUnsupported
}
restore, restoreErr := restorer.NewRestore(templateDBURL)
if restoreErr != nil {
return fmt.Errorf("start restore: %w", restoreErr)
}
_, restoreErr = restore.Step(-1)
if restoreErr != nil {
_ = restore.Finish()
return fmt.Errorf("restore: %w", restoreErr)
}
return restore.Finish()
})
if err != nil {
t.Fatalf("copy template db: %v", err)
}
}
const (
commandKey = "command"
bodyKey = "body"
toKey = "to"
statusKey = "status"
nickKey = "nick"
passwordKey = "password"
testPassword = "password123"
privmsgCmd = "PRIVMSG"
joinCmd = "JOIN"
apiMessages = "/api/v1/messages"
apiSession = "/api/v1/session"
apiState = "/api/v1/state"
authCookieName = "neoirc_auth"
)
// testServer wraps a test HTTP server with helpers.
type testServer struct {
httpServer *httptest.Server
t *testing.T
fxApp *fxtest.App
}
func newTestServer(
t *testing.T,
) *testServer {
t.Helper()
return newTestServerWith(t, 0, "", "")
}
func newTestServerWith(
t *testing.T,
hashcashBits int,
operName, operPassword string,
) *testServer {
t.Helper()
dbURL := fmt.Sprintf(
"file:test_%p?mode=memory&cache=shared",
t,
)
copyTemplateDB(t, dbURL)
var srv *server.Server
app := fxtest.New(t,
fx.Provide(
newTestGlobals,
logger.New,
func(
lifecycle fx.Lifecycle,
globs *globals.Globals,
log *logger.Logger,
) (*config.Config, error) {
cfg, err := config.New(
lifecycle, config.Params{ //nolint:exhaustruct
Globals: globs, Logger: log,
},
)
if err != nil {
return nil, fmt.Errorf(
"test config: %w", err,
)
}
cfg.DBURL = dbURL
cfg.Port = 0
// One line: each line is a database write per new session.
cfg.MOTD = "neoirc test server"
cfg.HashcashBits = hashcashBits
cfg.OperName = operName
cfg.OperPassword = operPassword
return cfg, nil
},
newTestDB,
stats.New,
newTestHealthcheck,
newTestMiddleware,
newTestHandlers,
newTestServerFx,
),
fx.Populate(&srv),
)
app.RequireStart()
httpSrv := httptest.NewServer(srv)
t.Cleanup(func() {
httpSrv.Close()
app.RequireStop()
})
return &testServer{
httpServer: httpSrv,
t: t,
fxApp: app,
}
}
func newTestGlobals() *globals.Globals {
return &globals.Globals{
Appname: "neoirc-test",
Version: "test",
StartTime: time.Now(),
}
}
func newTestDB(
lifecycle fx.Lifecycle,
log *logger.Logger,
cfg *config.Config,
) (*db.Database, error) {
database, err := db.New(lifecycle, db.Params{ //nolint:exhaustruct
Logger: log, Config: cfg,
})
if err != nil {
return nil, fmt.Errorf("test db: %w", err)
}
return database, nil
}
func newTestHealthcheck(
lifecycle fx.Lifecycle,
globs *globals.Globals,
cfg *config.Config,
log *logger.Logger,
database *db.Database,
tracker *stats.Tracker,
) (*healthcheck.Healthcheck, error) {
hcheck, err := healthcheck.New(lifecycle, healthcheck.Params{ //nolint:exhaustruct
Globals: globs,
Config: cfg,
Logger: log,
Database: database,
Stats: tracker,
})
if err != nil {
return nil, fmt.Errorf("test healthcheck: %w", err)
}
return hcheck, nil
}
func newTestMiddleware(
lifecycle fx.Lifecycle,
log *logger.Logger,
globs *globals.Globals,
cfg *config.Config,
) (*middleware.Middleware, error) {
mware, err := middleware.New(lifecycle, middleware.Params{ //nolint:exhaustruct
Logger: log,
Globals: globs,
Config: cfg,
})
if err != nil {
return nil, fmt.Errorf("test middleware: %w", err)
}
return mware, nil
}
func newTestHandlers(
lifecycle fx.Lifecycle,
log *logger.Logger,
globs *globals.Globals,
cfg *config.Config,
database *db.Database,
hcheck *healthcheck.Healthcheck,
tracker *stats.Tracker,
) (*handlers.Handlers, error) {
brk := broker.New()
svc := service.New(service.Params{ //nolint:exhaustruct
Logger: log,
Config: cfg,
Database: database,
Broker: brk,
})
hdlr, err := handlers.New(lifecycle, handlers.Params{ //nolint:exhaustruct
Logger: log,
Globals: globs,
Config: cfg,
Database: database,
Healthcheck: hcheck,
Stats: tracker,
Broker: brk,
Service: svc,
})
if err != nil {
return nil, fmt.Errorf("test handlers: %w", err)
}
return hdlr, nil
}
func newTestServerFx(
lifecycle fx.Lifecycle,
log *logger.Logger,
globs *globals.Globals,
cfg *config.Config,
mware *middleware.Middleware,
hdlr *handlers.Handlers,
) (*server.Server, error) {
srv, err := server.New(lifecycle, server.Params{ //nolint:exhaustruct
Logger: log,
Globals: globs,
Config: cfg,
Middleware: mware,
Handlers: hdlr,
})
if err != nil {
return nil, fmt.Errorf("test server: %w", err)
}
return srv, nil
}
func (tserver *testServer) url(path string) string {
return tserver.httpServer.URL + path
}
func doRequest(
t *testing.T,
method, url string,
body io.Reader,
) (*http.Response, error) {
t.Helper()
request, err := http.NewRequestWithContext(
t.Context(), method, url, body,
)
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
if body != nil {
request.Header.Set(
"Content-Type", "application/json",
)
}
resp, err := http.DefaultClient.Do(request)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
return resp, nil
}
func doRequestAuth(
t *testing.T,
method, url, cookie string,
body io.Reader,
) (*http.Response, error) {
t.Helper()
request, err := http.NewRequestWithContext(
t.Context(), method, url, body,
)
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
if body != nil {
request.Header.Set(
"Content-Type", "application/json",
)
}
if cookie != "" {
// A request cookie carries only its name and value, so
// gosec's G124 attribute check does not apply.
request.AddCookie(&http.Cookie{ //nolint:exhaustruct,gosec // only name+value
Name: authCookieName,
Value: cookie,
})
}
resp, err := http.DefaultClient.Do(request)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
return resp, nil
}
func (tserver *testServer) createSession(
nick string,
) string {
tserver.t.Helper()
body, err := json.Marshal(
map[string]string{nickKey: nick},
)
if err != nil {
tserver.t.Fatalf("marshal session: %v", err)
}
resp, err := doRequest(
tserver.t,
http.MethodPost,
tserver.url(apiSession),
bytes.NewReader(body),
)
if err != nil {
tserver.t.Fatalf("create session: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
tserver.t.Fatalf(
"create session: status %d: %s",
resp.StatusCode, respBody,
)
}
// Drain the body.
_, _ = io.ReadAll(resp.Body)
// Extract auth cookie from response.
for _, cookie := range resp.Cookies() {
if cookie.Name == authCookieName {
return cookie.Value
}
}
tserver.t.Fatal("no auth cookie in response")
return ""
}
func (tserver *testServer) sendCommand(
token string, cmd map[string]any,
) (int, map[string]any) {
tserver.t.Helper()
body, err := json.Marshal(cmd)
if err != nil {
tserver.t.Fatalf("marshal command: %v", err)
}
resp, err := doRequestAuth(
tserver.t,
http.MethodPost,
tserver.url(apiMessages),
token,
bytes.NewReader(body),
)
if err != nil {
tserver.t.Fatalf("send command: %v", err)
}
defer func() { _ = resp.Body.Close() }()
var result map[string]any
_ = json.NewDecoder(resp.Body).Decode(&result)
return resp.StatusCode, result
}
func (tserver *testServer) getState(
token string,
) (int, map[string]any) {
tserver.t.Helper()
resp, err := doRequestAuth(
tserver.t,
http.MethodGet,
tserver.url(apiState),
token,
nil,
)
if err != nil {
tserver.t.Fatalf("get: %v", err)
}
defer func() { _ = resp.Body.Close() }()
var result map[string]any
_ = json.NewDecoder(resp.Body).Decode(&result)
return resp.StatusCode, result
}
func (tserver *testServer) pollMessages(
token string, afterID int64,
) ([]map[string]any, int64) {
tserver.t.Helper()
pollURL := fmt.Sprintf(
"%s"+apiMessages+"?timeout=0&after=%d",
tserver.httpServer.URL, afterID,
)
resp, err := doRequestAuth(
tserver.t,
http.MethodGet,
pollURL,
token,
nil,
)
if err != nil {
tserver.t.Fatalf("poll: %v", err)
}
defer func() { _ = resp.Body.Close() }()
var result struct {
Messages []map[string]any `json:"messages"`
LastID json.Number `json:"last_id"` //nolint:tagliatelle
}
decErr := json.NewDecoder(resp.Body).Decode(&result)
if decErr != nil {
tserver.t.Fatalf("decode poll: %v", decErr)
}
lastID, _ := result.LastID.Int64()
return result.Messages, lastID
}
func postSession(
t *testing.T,
tserver *testServer,
nick string,
) *http.Response {
t.Helper()
body, err := json.Marshal(
map[string]string{nickKey: nick},
)
if err != nil {
t.Fatalf("marshal: %v", err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url(apiSession),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
return resp
}
func findMessage(
msgs []map[string]any,
command, from string,
) bool {
for _, msg := range msgs {
if msg[commandKey] == command &&
msg["from"] == from {
return true
}
}
return false
}
func findNumeric(
msgs []map[string]any,
numeric string,
) bool {
want, _ := strconv.Atoi(numeric)
for _, msg := range msgs {
code, ok := msg["code"].(float64)
if ok && int(code) == want {
return true
}
}
return false
}
// --- Tests ---
func TestCreateSessionValid(t *testing.T) {
tserver := newTestServer(t)
cookie := tserver.createSession("alice")
if cookie == "" {
t.Fatal("expected auth cookie")
}
}
func TestWelcomeNumeric(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("welcomer")
msgs, _ := tserver.pollMessages(token, 0)
if !findNumeric(msgs, "001") {
t.Fatalf(
"expected RPL_WELCOME (001), got %v",
msgs,
)
}
}
func TestJoinNumerics(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("jnumtest")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#numtest",
})
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "353") {
t.Fatalf(
"expected RPL_NAMREPLY (353), got %v",
msgs,
)
}
if !findNumeric(msgs, "366") {
t.Fatalf(
"expected RPL_ENDOFNAMES (366), got %v",
msgs,
)
}
}
func TestCreateSessionDuplicate(t *testing.T) {
tserver := newTestServer(t)
tserver.createSession("alice")
resp := postSession(t, tserver, "alice")
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusConflict {
t.Fatalf("expected 409, got %d", resp.StatusCode)
}
}
func TestCreateSessionEmpty(t *testing.T) {
tserver := newTestServer(t)
resp := postSession(t, tserver, "")
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf(
"expected 400, got %d", resp.StatusCode,
)
}
}
func TestCreateSessionInvalidChars(t *testing.T) {
tserver := newTestServer(t)
resp := postSession(t, tserver, "hello world")
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf(
"expected 400, got %d", resp.StatusCode,
)
}
}
func TestCreateSessionNumericStart(t *testing.T) {
tserver := newTestServer(t)
resp := postSession(t, tserver, "123abc")
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf(
"expected 400, got %d", resp.StatusCode,
)
}
}
func TestCreateSessionMalformed(t *testing.T) {
tserver := newTestServer(t)
resp, err := doRequest(
t,
http.MethodPost,
tserver.url(apiSession),
strings.NewReader("{bad"),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf(
"expected 400, got %d", resp.StatusCode,
)
}
}
func TestAuthNoCookie(t *testing.T) {
tserver := newTestServer(t)
status, _ := tserver.getState("")
if status != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", status)
}
}
func TestAuthBadCookie(t *testing.T) {
tserver := newTestServer(t)
status, _ := tserver.getState(
"invalid-cookie-12345",
)
if status != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", status)
}
}
func TestAuthValidToken(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("authtest")
status, result := tserver.getState(token)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
if result[nickKey] != "authtest" {
t.Fatalf(
"expected nick authtest, got %v",
result[nickKey],
)
}
}
func TestJoinChannel(t *testing.T) {
const channel = "#test"
tserver := newTestServer(t)
token := tserver.createSession("joiner")
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: joinCmd, toKey: channel,
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result["channel"] != channel {
t.Fatalf(
"expected #test, got %v", result["channel"],
)
}
}
func TestJoinWithoutHash(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("joiner2")
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: joinCmd, toKey: "other",
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result["channel"] != "#other" {
t.Fatalf(
"expected #other, got %v",
result["channel"],
)
}
}
func TestPartChannel(t *testing.T) {
const channel = "#test"
tserver := newTestServer(t)
token := tserver.createSession("parter")
tserver.sendCommand(
token,
map[string]any{
commandKey: joinCmd, toKey: channel,
},
)
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: "PART", toKey: channel,
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result["channel"] != channel {
t.Fatalf(
"expected #test, got %v", result["channel"],
)
}
}
func TestJoinMissingTo(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("joiner3")
// Drain initial MOTD/welcome numerics.
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(
token, map[string]any{commandKey: joinCmd},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestChannelMessage(t *testing.T) {
const channel = "#test"
tserver := newTestServer(t)
aliceToken := tserver.createSession("alice_msg")
bobToken := tserver.createSession("bob_msg")
tserver.sendCommand(aliceToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(bobToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
_, _ = tserver.pollMessages(aliceToken, 0)
_, bobLastID := tserver.pollMessages(bobToken, 0)
status, result := tserver.sendCommand(
aliceToken,
map[string]any{
commandKey: privmsgCmd,
toKey: channel,
bodyKey: []string{"hello world"},
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result["id"] == nil || result["id"] == "" {
t.Fatal("expected message id")
}
msgs, _ := tserver.pollMessages(
bobToken, bobLastID,
)
if !findMessage(msgs, privmsgCmd, "alice_msg") {
t.Fatalf(
"bob didn't receive alice's message: %v",
msgs,
)
}
}
func TestMessageMissingBody(t *testing.T) {
const channel = "#test"
tserver := newTestServer(t)
token := tserver.createSession("nobody")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: channel,
})
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd, toKey: channel,
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "412") {
t.Fatalf(
"expected ERR_NOTEXTTOSEND (412), got %v",
msgs,
)
}
}
func TestMessageMissingTo(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("noto")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd,
bodyKey: []string{"hello"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "411") {
t.Fatalf(
"expected ERR_NORECIPIENT (411), got %v",
msgs,
)
}
}
func TestNonMemberCannotSend(t *testing.T) {
tserver := newTestServer(t)
aliceToken := tserver.createSession("alice_nosend")
bobToken := tserver.createSession("bob_nosend")
// Only bob joins the channel.
tserver.sendCommand(bobToken, map[string]any{
commandKey: joinCmd, toKey: "#private",
})
_, lastID := tserver.pollMessages(aliceToken, 0)
// Alice tries to send without joining.
status, _ := tserver.sendCommand(
aliceToken,
map[string]any{
commandKey: privmsgCmd,
toKey: "#private",
bodyKey: []string{"sneaky"},
},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(aliceToken, lastID)
if !findNumeric(msgs, "404") {
t.Fatalf(
"expected ERR_CANNOTSENDTOCHAN (404), got %v",
msgs,
)
}
}
func TestDirectMessage(t *testing.T) {
tserver := newTestServer(t)
aliceToken := tserver.createSession("alice_dm")
bobToken := tserver.createSession("bob_dm")
status, result := tserver.sendCommand(
aliceToken,
map[string]any{
commandKey: privmsgCmd,
toKey: "bob_dm",
bodyKey: []string{"hey bob"},
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
msgs, _ := tserver.pollMessages(bobToken, 0)
if !findMessage(msgs, privmsgCmd, "alice_dm") {
t.Fatal("bob didn't receive DM")
}
aliceMsgs, _ := tserver.pollMessages(aliceToken, 0)
found := false
for _, msg := range aliceMsgs {
if msg[commandKey] == privmsgCmd &&
msg["from"] == "alice_dm" &&
msg[toKey] == "bob_dm" {
found = true
}
}
if !found {
t.Fatal("alice didn't get DM echo")
}
}
func TestDMToNonexistentUser(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("dmsender")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd,
toKey: "nobody",
bodyKey: []string{"hello?"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "401") {
t.Fatalf(
"expected ERR_NOSUCHNICK (401), got %v",
msgs,
)
}
}
func TestNickChange(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("nick_test")
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: irc.CmdNick,
bodyKey: []string{"newnick"},
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result[nickKey] != "newnick" {
t.Fatalf(
"expected newnick, got %v", result[nickKey],
)
}
}
func TestNickSameAsCurrent(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("same_nick")
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: irc.CmdNick,
bodyKey: []string{"same_nick"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
}
func TestNickCollision(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("nickuser")
tserver.createSession("taken_nick")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: irc.CmdNick,
bodyKey: []string{"taken_nick"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "433") {
t.Fatalf(
"expected ERR_NICKNAMEINUSE (433), got %v",
msgs,
)
}
}
func TestNickInvalid(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("nickval")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: irc.CmdNick,
bodyKey: []string{"bad nick!"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "432") {
t.Fatalf(
"expected ERR_ERRONEUSNICKNAME (432), got %v",
msgs,
)
}
}
func TestNickEmptyBody(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("nicknobody")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(
token, map[string]any{commandKey: irc.CmdNick},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestTopic(t *testing.T) {
const channel = "#topictest"
tserver := newTestServer(t)
token := tserver.createSession("topic_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: channel,
})
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: irc.CmdTopic,
toKey: channel,
bodyKey: []string{"Hello World Topic"},
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result["topic"] != "Hello World Topic" {
t.Fatalf(
"expected topic, got %v", result["topic"],
)
}
}
func TestTopicMissingTo(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("topicnoto")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: irc.CmdTopic,
bodyKey: []string{"topic"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestTopicMissingBody(t *testing.T) {
const channel = "#topictest"
tserver := newTestServer(t)
token := tserver.createSession("topicnobody")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: channel,
})
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: irc.CmdTopic, toKey: channel,
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestTopicNonMember(t *testing.T) {
tserver := newTestServer(t)
aliceToken := tserver.createSession("alice_topic")
bobToken := tserver.createSession("bob_topic")
// Only alice joins the channel.
tserver.sendCommand(aliceToken, map[string]any{
commandKey: joinCmd, toKey: "#topicpriv",
})
// Drain bob's initial messages.
_, lastID := tserver.pollMessages(bobToken, 0)
// Bob tries to set topic without joining.
status, _ := tserver.sendCommand(
bobToken,
map[string]any{
commandKey: irc.CmdTopic,
toKey: "#topicpriv",
bodyKey: []string{"Hijacked topic"},
},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(bobToken, lastID)
if !findNumeric(msgs, "442") {
t.Fatalf(
"expected ERR_NOTONCHANNEL (442), got %v",
msgs,
)
}
}
func TestPing(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("ping_user")
status, result := tserver.sendCommand(
token, map[string]any{commandKey: "PING"},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
if result[commandKey] != "PONG" {
t.Fatalf(
"expected PONG, got %v",
result[commandKey],
)
}
}
func TestQuit(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("quitter")
observerToken := tserver.createSession("observer")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#quitchan",
})
tserver.sendCommand(observerToken, map[string]any{
commandKey: joinCmd, toKey: "#quitchan",
})
_, lastID := tserver.pollMessages(observerToken, 0)
status, result := tserver.sendCommand(
token, map[string]any{commandKey: "QUIT"},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
msgs, _ := tserver.pollMessages(
observerToken, lastID,
)
if !findMessage(msgs, "QUIT", "quitter") {
t.Fatalf(
"observer didn't get QUIT: %v", msgs,
)
}
afterStatus, _ := tserver.getState(token)
if afterStatus != http.StatusUnauthorized {
t.Fatalf(
"expected 401 after quit, got %d",
afterStatus,
)
}
}
func TestUnknownCommand(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("cmdtest")
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(
token, map[string]any{commandKey: "BOGUS"},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "421") {
t.Fatalf(
"expected ERR_UNKNOWNCOMMAND (421), got %v",
msgs,
)
}
}
func TestEmptyCommand(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("emptycmd")
status, _ := tserver.sendCommand(
token, map[string]any{commandKey: ""},
)
if status != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", status)
}
}
func TestHistory(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("historian")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#history",
})
for range 5 {
tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd,
toKey: "#history",
bodyKey: []string{"test message"},
})
}
histURL := tserver.url(
"/api/v1/history?target=%23history&limit=3",
)
resp, err := doRequestAuth(
t, http.MethodGet, histURL, token, nil,
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf(
"expected 200, got %d", resp.StatusCode,
)
}
var msgs []map[string]any
decErr := json.NewDecoder(resp.Body).Decode(&msgs)
if decErr != nil {
t.Fatalf("decode history: %v", decErr)
}
if len(msgs) != 3 {
t.Fatalf("expected 3 messages, got %d", len(msgs))
}
}
func TestHistoryNonMember(t *testing.T) {
const channel = "#secret"
tserver := newTestServer(t)
aliceToken := tserver.createSession("alice_hist")
bobToken := tserver.createSession("bob_hist")
// Alice creates and joins a channel.
tserver.sendCommand(aliceToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(aliceToken, map[string]any{
commandKey: privmsgCmd,
toKey: channel,
bodyKey: []string{"secret stuff"},
})
// Bob tries to read history without joining.
histURL := tserver.url(
"/api/v1/history?target=%23secret",
)
resp, err := doRequestAuth(
t, http.MethodGet, histURL, bobToken, nil,
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf(
"expected 403, got %d", resp.StatusCode,
)
}
}
func TestChannelList(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("lister")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#listchan",
})
resp, err := doRequestAuth(
t,
http.MethodGet,
tserver.url("/api/v1/channels"),
token,
nil,
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf(
"expected 200, got %d", resp.StatusCode,
)
}
var channels []map[string]any
decErr := json.NewDecoder(resp.Body).Decode(
&channels,
)
if decErr != nil {
t.Fatalf("decode channels: %v", decErr)
}
found := false
for _, channel := range channels {
if channel["name"] == "#listchan" {
found = true
}
}
if !found {
t.Fatal("channel not in list")
}
}
func TestChannelMembers(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("membertest")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#members",
})
resp, err := doRequestAuth(
t,
http.MethodGet,
tserver.url(
"/api/v1/channels/members/members",
),
token,
nil,
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf(
"expected 200, got %d", resp.StatusCode,
)
}
}
func TestLongPoll(t *testing.T) {
const channel = "#longpoll"
tserver := newTestServer(t)
aliceToken := tserver.createSession("lp_alice")
bobToken := tserver.createSession("lp_bob")
tserver.sendCommand(aliceToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(bobToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
_, lastID := tserver.pollMessages(bobToken, 0)
var waitGroup sync.WaitGroup
var pollMsgs []map[string]any
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
pollURL := fmt.Sprintf(
"%s"+apiMessages+"?timeout=5&after=%d",
tserver.httpServer.URL, lastID,
)
resp, err := doRequestAuth(
t, http.MethodGet,
pollURL, bobToken, nil,
)
if err != nil {
return
}
defer func() { _ = resp.Body.Close() }()
var result struct {
Messages []map[string]any `json:"messages"`
}
_ = json.NewDecoder(resp.Body).Decode(&result)
pollMsgs = result.Messages
}()
time.Sleep(200 * time.Millisecond)
tserver.sendCommand(aliceToken, map[string]any{
commandKey: privmsgCmd,
toKey: channel,
bodyKey: []string{"wake up!"},
})
waitGroup.Wait()
if !findMessage(pollMsgs, privmsgCmd, "lp_alice") {
t.Fatalf(
"long-poll didn't receive message: %v",
pollMsgs,
)
}
}
func TestLongPollTimeout(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("lp_timeout")
// Drain initial welcome/MOTD numerics.
_, lastID := tserver.pollMessages(token, 0)
start := time.Now()
resp, err := doRequestAuth(
t,
http.MethodGet,
tserver.url(fmt.Sprintf(
"%s?timeout=1&after=%d",
apiMessages, lastID,
)),
token,
nil,
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
elapsed := time.Since(start)
if elapsed < 900*time.Millisecond {
t.Fatalf(
"long-poll returned too fast: %v", elapsed,
)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf(
"expected 200, got %d", resp.StatusCode,
)
}
}
func TestEphemeralChannelCleanup(t *testing.T) {
const channel = "#ephemeral"
tserver := newTestServer(t)
token := tserver.createSession("ephemeral")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(token, map[string]any{
commandKey: "PART", toKey: channel,
})
resp, err := doRequestAuth(
t,
http.MethodGet,
tserver.url("/api/v1/channels"),
token,
nil,
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
var channels []map[string]any
decErr := json.NewDecoder(resp.Body).Decode(
&channels,
)
if decErr != nil {
t.Fatalf("decode channels: %v", decErr)
}
for _, listed := range channels {
if listed["name"] == channel {
t.Fatal(
"ephemeral channel should be cleaned up",
)
}
}
}
func TestConcurrentSessions(t *testing.T) {
tserver := newTestServer(t)
var waitGroup sync.WaitGroup
const concurrency = 20
errs := make(chan error, concurrency)
for idx := range concurrency {
waitGroup.Add(1)
go func(index int) {
defer waitGroup.Done()
nick := fmt.Sprintf("conc_%d", index)
body, err := json.Marshal(
map[string]string{nickKey: nick},
)
if err != nil {
errs <- fmt.Errorf(
"marshal: %w", err,
)
return
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url(apiSession),
bytes.NewReader(body),
)
if err != nil {
errs <- err
return
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
errs <- fmt.Errorf( //nolint:err113
"status %d for %s",
resp.StatusCode, nick,
)
}
}(idx)
}
waitGroup.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("concurrent error: %v", err)
}
}
}
func TestServerInfo(t *testing.T) {
tserver := newTestServer(t)
resp, err := doRequest(
t,
http.MethodGet,
tserver.url("/api/v1/server"),
nil,
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf(
"expected 200, got %d", resp.StatusCode,
)
}
}
func TestHealthcheck(t *testing.T) {
tserver := newTestServer(t)
resp, err := doRequest(
t,
http.MethodGet,
tserver.url("/.well-known/healthcheck.json"),
nil,
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf(
"expected 200, got %d", resp.StatusCode,
)
}
var result map[string]any
decErr := json.NewDecoder(resp.Body).Decode(&result)
if decErr != nil {
t.Fatalf("decode healthcheck: %v", decErr)
}
if result[statusKey] != "ok" {
t.Fatalf(
"expected ok status, got %v",
result[statusKey],
)
}
}
func TestHealthcheckRuntimeStatsFields(t *testing.T) {
tserver := newTestServer(t)
resp, err := doRequest(
t,
http.MethodGet,
tserver.url("/.well-known/healthcheck.json"),
nil,
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf(
"expected 200, got %d", resp.StatusCode,
)
}
var result map[string]any
decErr := json.NewDecoder(resp.Body).Decode(&result)
if decErr != nil {
t.Fatalf("decode healthcheck: %v", decErr)
}
requiredFields := []string{
"sessions", "clients", "queuedLines",
"channels", "connectionsSinceBoot",
"sessionsSinceBoot", "messagesSinceBoot",
}
for _, field := range requiredFields {
if _, ok := result[field]; !ok {
t.Errorf(
"missing field %q in healthcheck", field,
)
}
}
}
func TestHealthcheckRuntimeStatsValues(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("statsuser")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#statschan",
})
tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd,
toKey: "#statschan",
bodyKey: []string{"hello stats"},
})
result := tserver.fetchHealthcheck(t)
assertFieldGTE(t, result, "sessions", 1)
assertFieldGTE(t, result, "clients", 1)
assertFieldGTE(t, result, "channels", 1)
assertFieldGTE(t, result, "queuedLines", 0)
assertFieldGTE(t, result, "sessionsSinceBoot", 1)
assertFieldGTE(t, result, "connectionsSinceBoot", 1)
assertFieldGTE(t, result, "messagesSinceBoot", 1)
}
func (tserver *testServer) fetchHealthcheck(
t *testing.T,
) map[string]any {
t.Helper()
resp, err := doRequest(
t,
http.MethodGet,
tserver.url("/.well-known/healthcheck.json"),
nil,
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf(
"expected 200, got %d", resp.StatusCode,
)
}
var result map[string]any
decErr := json.NewDecoder(resp.Body).Decode(&result)
if decErr != nil {
t.Fatalf("decode healthcheck: %v", decErr)
}
return result
}
func assertFieldGTE(
t *testing.T,
result map[string]any,
field string,
minimum float64,
) {
t.Helper()
val, ok := result[field].(float64)
if !ok {
t.Errorf(
"field %q: not a number (got %T)",
field, result[field],
)
return
}
if val < minimum {
t.Errorf(
"expected %s >= %v, got %v",
field, minimum, val,
)
}
}
func postJSONExpectStatus(
t *testing.T,
tserver *testServer,
path string,
payload map[string]string,
expectedStatus int,
) {
t.Helper()
body, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url(path),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != expectedStatus {
t.Fatalf(
"expected %d, got %d",
expectedStatus, resp.StatusCode,
)
}
}
func TestPassCommand(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("passuser")
// Drain initial messages.
_, _ = tserver.pollMessages(token, 0)
// Set password via PASS command.
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: irc.CmdPass,
bodyKey: []string{"s3cure_pass"},
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result[statusKey] != "ok" {
t.Fatalf(
"expected ok, got %v", result[statusKey],
)
}
}
func TestPassCommandShortPassword(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("shortpw")
// Drain initial messages.
_, lastID := tserver.pollMessages(token, 0)
// Try short password — should fail.
status, _ := tserver.sendCommand(
token,
map[string]any{
commandKey: irc.CmdPass,
bodyKey: []string{"short"},
},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestPassCommandEmpty(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("emptypw")
// Drain initial messages.
_, lastID := tserver.pollMessages(token, 0)
// Try empty password — should fail.
status, _ := tserver.sendCommand(
token,
map[string]any{commandKey: irc.CmdPass},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestLoginValid(t *testing.T) {
tserver := newTestServer(t)
// Create session and set password via PASS command.
token := tserver.createSession("loginuser")
tserver.sendCommand(token, map[string]any{
commandKey: irc.CmdPass,
bodyKey: []string{testPassword},
})
// Login with nick + password.
loginBody, err := json.Marshal(map[string]string{
nickKey: "loginuser", passwordKey: testPassword,
})
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/login"),
bytes.NewReader(loginBody),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
t.Fatalf(
"expected 200, got %d: %s",
resp.StatusCode, respBody,
)
}
// Extract auth cookie from login response.
var loginCookie string
for _, cookie := range resp.Cookies() {
if cookie.Name == authCookieName {
loginCookie = cookie.Value
break
}
}
if loginCookie == "" {
t.Fatal("expected auth cookie from login")
}
// Verify login cookie works for auth.
status, state := tserver.getState(loginCookie)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
if state[nickKey] != "loginuser" {
t.Fatalf(
"expected loginuser, got %v",
state[nickKey],
)
}
}
func TestLoginWrongPassword(t *testing.T) {
tserver := newTestServer(t)
// Create session and set password via PASS command.
token := tserver.createSession("wrongpwuser")
tserver.sendCommand(token, map[string]any{
commandKey: irc.CmdPass,
bodyKey: []string{"correctpass1"},
})
postJSONExpectStatus(
t, tserver, "/api/v1/login",
map[string]string{
nickKey: "wrongpwuser",
passwordKey: "wrongpass12",
},
http.StatusUnauthorized,
)
}
func TestLoginNonexistentUser(t *testing.T) {
tserver := newTestServer(t)
postJSONExpectStatus(
t, tserver, "/api/v1/login",
map[string]string{
nickKey: "ghostuser",
passwordKey: testPassword,
},
http.StatusUnauthorized,
)
}
func TestSessionCookie(t *testing.T) {
tserver := newTestServer(t)
body, err := json.Marshal(
map[string]string{nickKey: "cookietest"},
)
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url(apiSession),
bytes.NewReader(body),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated {
t.Fatalf(
"expected 201, got %d", resp.StatusCode,
)
}
// Verify Set-Cookie header.
var authCookie *http.Cookie
for _, cookie := range resp.Cookies() {
if cookie.Name == authCookieName {
authCookie = cookie
break
}
}
if authCookie == nil {
t.Fatal("expected neoirc_auth cookie")
}
if !authCookie.HttpOnly {
t.Fatal("cookie should be HttpOnly")
}
if authCookie.SameSite != http.SameSiteStrictMode {
t.Fatal("cookie should be SameSite=Strict")
}
// Secure even over plain HTTP, as this test server is.
if !authCookie.Secure {
t.Fatal("cookie should be Secure")
}
// Verify JSON body does NOT contain token.
var result map[string]any
_ = json.NewDecoder(resp.Body).Decode(&result)
if _, hasToken := result["token"]; hasToken {
t.Fatal("JSON body should not contain token")
}
}
func TestSessionStillWorks(t *testing.T) {
tserver := newTestServer(t)
// Verify anonymous session creation still works.
token := tserver.createSession("anon_user")
if token == "" {
t.Fatal("expected cookie for anonymous session")
}
status, state := tserver.getState(token)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
if state[nickKey] != "anon_user" {
t.Fatalf(
"expected anon_user, got %v",
state[nickKey],
)
}
}
// findNumericWithParams returns the first message matching
// the given numeric code. Returns nil if not found.
func findNumericWithParams(
msgs []map[string]any,
numeric string,
) map[string]any {
want, _ := strconv.Atoi(numeric)
for _, msg := range msgs {
code, ok := msg["code"].(float64)
if ok && int(code) == want {
return msg
}
}
return nil
}
// getNumericParams extracts the params array from a
// numeric message as a string slice.
func getNumericParams(
msg map[string]any,
) []string {
raw, exists := msg["params"]
if !exists || raw == nil {
return nil
}
arr, isArr := raw.([]any)
if !isArr {
return nil
}
result := make([]string, 0, len(arr))
for _, val := range arr {
str, isString := val.(string)
if isString {
result = append(result, str)
}
}
return result
}
func TestWhoisShowsHostInfo(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSessionWithUsername(
"whoisuser", "myident",
)
queryToken := tserver.createSession("querier")
_, lastID := tserver.pollMessages(queryToken, 0)
tserver.sendCommand(queryToken, map[string]any{
commandKey: irc.CmdWhois,
toKey: "whoisuser",
})
msgs, _ := tserver.pollMessages(queryToken, lastID)
whoisMsg := findNumericWithParams(msgs, "311")
if whoisMsg == nil {
t.Fatalf(
"expected RPL_WHOISUSER (311), got %v",
msgs,
)
}
params := getNumericParams(whoisMsg)
if len(params) < 2 {
t.Fatalf(
"expected at least 2 params, got %v",
params,
)
}
if params[1] != "myident" {
t.Fatalf(
"expected username myident, got %s",
params[1],
)
}
_ = token
}
// createSessionWithUsername creates a session with a
// specific username and returns the auth cookie value.
func (tserver *testServer) createSessionWithUsername(
nick, username string,
) string {
tserver.t.Helper()
body, err := json.Marshal(map[string]string{
nickKey: nick,
"username": username,
})
if err != nil {
tserver.t.Fatalf("marshal session: %v", err)
}
resp, err := doRequest(
tserver.t,
http.MethodPost,
tserver.url(apiSession),
bytes.NewReader(body),
)
if err != nil {
tserver.t.Fatalf("create session: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
tserver.t.Fatalf(
"create session: status %d: %s",
resp.StatusCode, respBody,
)
}
// Drain the body.
_, _ = io.ReadAll(resp.Body)
// Extract auth cookie from response.
for _, cookie := range resp.Cookies() {
if cookie.Name == authCookieName {
return cookie.Value
}
}
tserver.t.Fatal("no auth cookie in response")
return ""
}
func TestWhoShowsHostInfo(t *testing.T) {
const channel = "#whotest"
tserver := newTestServer(t)
whoToken := tserver.createSessionWithUsername(
"whouser", "whoident",
)
tserver.sendCommand(whoToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
queryToken := tserver.createSession("whoquerier")
tserver.sendCommand(queryToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
_, lastID := tserver.pollMessages(queryToken, 0)
tserver.sendCommand(queryToken, map[string]any{
commandKey: "WHO",
toKey: channel,
})
msgs, _ := tserver.pollMessages(queryToken, lastID)
assertWhoReplyUsername(t, msgs, "whouser", "whoident")
}
func assertWhoReplyUsername(
t *testing.T,
msgs []map[string]any,
targetNick, expectedUsername string,
) {
t.Helper()
for _, msg := range msgs {
code, isCode := msg["code"].(float64)
if !isCode || int(code) != 352 {
continue
}
params := getNumericParams(msg)
if len(params) < 5 || params[4] != targetNick {
continue
}
if params[1] != expectedUsername {
t.Fatalf(
"expected username %s in WHO, got %s",
expectedUsername, params[1],
)
}
return
}
t.Fatalf(
"expected RPL_WHOREPLY (352) for %s, msgs: %v",
targetNick, msgs,
)
}
func TestSessionUsernameDefault(t *testing.T) {
tserver := newTestServer(t)
// Create session without specifying username.
token := tserver.createSession("defaultusr")
queryToken := tserver.createSession("querier2")
_, lastID := tserver.pollMessages(queryToken, 0)
// WHOIS should show the nick as the username.
tserver.sendCommand(queryToken, map[string]any{
commandKey: irc.CmdWhois,
toKey: "defaultusr",
})
msgs, _ := tserver.pollMessages(queryToken, lastID)
whoisMsg := findNumericWithParams(msgs, "311")
if whoisMsg == nil {
t.Fatalf(
"expected RPL_WHOISUSER (311), got %v",
msgs,
)
}
params := getNumericParams(whoisMsg)
if len(params) < 2 {
t.Fatalf(
"expected at least 2 params, got %v",
params,
)
}
// Username defaults to nick.
if params[1] != "defaultusr" {
t.Fatalf(
"expected default username defaultusr, got %s",
params[1],
)
}
_ = token
}
func TestLoginRateLimitExceeded(t *testing.T) {
tserver := newTestServer(t)
// Exhaust the burst (default: 5 per IP) using
// nonexistent users. These fail fast (no bcrypt),
// preventing token replenishment between requests.
for range 5 {
loginBody, mErr := json.Marshal(
map[string]string{
nickKey: "nosuchuser",
passwordKey: "doesnotmatter",
},
)
if mErr != nil {
t.Fatal(mErr)
}
loginResp, rErr := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/login"),
bytes.NewReader(loginBody),
)
if rErr != nil {
t.Fatal(rErr)
}
_ = loginResp.Body.Close()
}
// The next request should be rate-limited.
loginBody, err := json.Marshal(map[string]string{
nickKey: "nosuchuser", passwordKey: "doesnotmatter",
})
if err != nil {
t.Fatal(err)
}
resp, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/login"),
bytes.NewReader(loginBody),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusTooManyRequests {
t.Fatalf(
"expected 429, got %d",
resp.StatusCode,
)
}
retryAfter := resp.Header.Get("Retry-After")
if retryAfter == "" {
t.Fatal("expected Retry-After header")
}
}
func TestLoginRateLimitAllowsNormalUse(t *testing.T) {
tserver := newTestServer(t)
// Create session and set password via PASS command.
token := tserver.createSession("normaluser")
tserver.sendCommand(token, map[string]any{
commandKey: irc.CmdPass,
bodyKey: []string{testPassword},
})
// A single login should succeed without rate limiting.
loginBody, err := json.Marshal(map[string]string{
nickKey: "normaluser", passwordKey: testPassword,
})
if err != nil {
t.Fatal(err)
}
resp2, err := doRequest(
t,
http.MethodPost,
tserver.url("/api/v1/login"),
bytes.NewReader(loginBody),
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp2.Body.Close() }()
if resp2.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp2.Body)
t.Fatalf(
"expected 200, got %d: %s",
resp2.StatusCode, respBody,
)
}
}
func TestNickBroadcastToChannels(t *testing.T) {
tserver := newTestServer(t)
aliceToken := tserver.createSession("nick_a")
bobToken := tserver.createSession("nick_b")
tserver.sendCommand(aliceToken, map[string]any{
commandKey: joinCmd, toKey: "#nicktest",
})
tserver.sendCommand(bobToken, map[string]any{
commandKey: joinCmd, toKey: "#nicktest",
})
_, lastID := tserver.pollMessages(bobToken, 0)
tserver.sendCommand(aliceToken, map[string]any{
commandKey: irc.CmdNick,
bodyKey: []string{"nick_a_new"},
})
msgs, _ := tserver.pollMessages(bobToken, lastID)
if !findMessage(msgs, irc.CmdNick, "nick_a") {
t.Fatalf(
"bob didn't get nick change: %v", msgs,
)
}
}
// --- Channel Hashcash Tests ---
const (
metaKey = "meta"
modeCmd = "MODE"
hashcashKey = "hashcash"
)
func mintTestChannelHashcash(
tb testing.TB,
bits int,
channel string,
body json.RawMessage,
) string {
tb.Helper()
bodyHash := hashcash.BodyHash(body)
return hashcash.MintChannelStamp(bits, channel, bodyHash)
}
func TestChannelHashcashSetMode(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcmode_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hctest",
})
_, lastID := tserver.pollMessages(token, 0)
// Set hashcash bits to 2 via MODE +H.
status, _ := tserver.sendCommand(
token,
map[string]any{
commandKey: modeCmd,
toKey: "#hctest",
bodyKey: []string{"+H", "2"},
},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
// Should get RPL_CHANNELMODEIS (324) confirming +H.
if !findNumeric(msgs, "324") {
t.Fatalf(
"expected RPL_CHANNELMODEIS (324), got %v",
msgs,
)
}
}
func TestChannelHashcashQueryMode(t *testing.T) {
const channel = "#hcquery"
tserver := newTestServer(t)
token := tserver.createSession("hcquery_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: channel,
})
// Set hashcash bits.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+H", "5"},
})
_, lastID := tserver.pollMessages(token, 0)
// Query mode — should show +nH.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: channel,
})
msgs, _ := tserver.pollMessages(token, lastID)
found := false
for _, msg := range msgs {
code, ok := msg["code"].(float64)
if ok && int(code) == 324 {
found = true
}
}
if !found {
t.Fatalf(
"expected RPL_CHANNELMODEIS (324), got %v",
msgs,
)
}
}
func TestChannelHashcashClearMode(t *testing.T) {
const channel = "#hcclear"
tserver := newTestServer(t)
token := tserver.createSession("hcclear_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: channel,
})
// Set hashcash bits.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+H", "5"},
})
// Clear hashcash bits.
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"-H"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
// Now message should succeed without hashcash.
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: privmsgCmd,
toKey: channel,
bodyKey: []string{"test message"},
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
}
func TestChannelHashcashRejectNoStamp(t *testing.T) {
const channel = "#hcreject"
tserver := newTestServer(t)
token := tserver.createSession("hcreject_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: channel,
})
// Set hashcash requirement.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+H", "2"},
})
_, lastID := tserver.pollMessages(token, 0)
// Send message without hashcash — should fail.
status, _ := tserver.sendCommand(
token,
map[string]any{
commandKey: privmsgCmd,
toKey: channel,
bodyKey: []string{"spam message"},
},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
// Should get ERR_CANNOTSENDTOCHAN (404).
if !findNumeric(msgs, "404") {
t.Fatalf(
"expected ERR_CANNOTSENDTOCHAN (404), got %v",
msgs,
)
}
}
func TestChannelHashcashAcceptValidStamp(t *testing.T) {
const (
channel = "#hcaccept"
text = "hello world"
)
tserver := newTestServer(t)
token := tserver.createSession("hcaccept_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: channel,
})
// Set hashcash requirement (2 bits = fast to mint).
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+H", "2"},
})
_, lastID := tserver.pollMessages(token, 0)
// Mint a valid hashcash stamp.
msgBody, marshalErr := json.Marshal(
[]string{text},
)
if marshalErr != nil {
t.Fatal(marshalErr)
}
stamp := mintTestChannelHashcash(
t, 2, channel, msgBody,
)
// Send message with valid hashcash.
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: privmsgCmd,
toKey: channel,
bodyKey: []string{text},
metaKey: map[string]any{
hashcashKey: stamp,
},
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result["id"] == nil || result["id"] == "" {
t.Fatal("expected message id for valid hashcash")
}
// Verify the message was delivered.
msgs, _ := tserver.pollMessages(token, lastID)
if !findMessage(msgs, privmsgCmd, "hcaccept_user") {
t.Fatalf(
"message not received: %v", msgs,
)
}
}
func TestChannelHashcashRejectReplayedStamp(t *testing.T) {
const (
channel = "#hcreplay"
text = "unique msg"
)
tserver := newTestServer(t)
token := tserver.createSession("hcreplay_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: channel,
})
// Set hashcash requirement.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+H", "2"},
})
_, _ = tserver.pollMessages(token, 0)
// Mint and send once — should succeed.
msgBody, marshalErr := json.Marshal(
[]string{text},
)
if marshalErr != nil {
t.Fatal(marshalErr)
}
stamp := mintTestChannelHashcash(
t, 2, channel, msgBody,
)
status, _ := tserver.sendCommand(
token,
map[string]any{
commandKey: privmsgCmd,
toKey: channel,
bodyKey: []string{text},
metaKey: map[string]any{
hashcashKey: stamp,
},
},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
_, lastID := tserver.pollMessages(token, 0)
// Replay the same stamp — should fail.
status, _ = tserver.sendCommand(
token,
map[string]any{
commandKey: privmsgCmd,
toKey: channel,
bodyKey: []string{text},
metaKey: map[string]any{
hashcashKey: stamp,
},
},
)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
msgs, _ := tserver.pollMessages(token, lastID)
// Should get ERR_CANNOTSENDTOCHAN (404).
if !findNumeric(msgs, "404") {
t.Fatalf(
"expected replay rejection (404), got %v",
msgs,
)
}
}
func TestChannelHashcashNoRequirementWorks(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcnone_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#nohashcash",
})
// No hashcash set — message should work.
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: privmsgCmd,
toKey: "#nohashcash",
bodyKey: []string{"free message"},
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result["id"] == nil || result["id"] == "" {
t.Fatal("expected message id")
}
}
func TestChannelHashcashInvalidBitsRange(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcbits_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hcbits",
})
_, lastID := tserver.pollMessages(token, 0)
// Try to set bits to 0 — should fail.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcbits",
bodyKey: []string{"+H", "0"},
})
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "472") {
t.Fatalf(
"expected ERR_UNKNOWNMODE (472), got %v",
msgs,
)
}
}
func TestChannelHashcashMissingBitsArg(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcnoarg_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hcnoarg",
})
_, lastID := tserver.pollMessages(token, 0)
// Try to set +H without bits argument.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcnoarg",
bodyKey: []string{"+H"},
})
msgs, _ := tserver.pollMessages(token, lastID)
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestNamesShowsHostmask(t *testing.T) {
tserver := newTestServer(t)
queryToken, lastID := setupChannelWithIdentMember(
tserver, "namesmember", "nmident",
"namesquery", "#namestest",
)
// Issue an explicit NAMES command.
tserver.sendCommand(queryToken, map[string]any{
commandKey: irc.CmdNames,
toKey: "#namestest",
})
msgs, _ := tserver.pollMessages(queryToken, lastID)
assertNamesHostmask(
t, msgs, "namesmember", "nmident",
)
}
func TestNamesOnJoinShowsHostmask(t *testing.T) {
tserver := newTestServer(t)
// First user joins to populate the channel.
firstToken := tserver.createSessionWithUsername(
"joinmem", "jmident",
)
tserver.sendCommand(firstToken, map[string]any{
commandKey: joinCmd, toKey: "#joinnamestest",
})
// Second user joins; the JOIN triggers
// deliverNamesNumerics which should include
// hostmask data.
joinerToken := tserver.createSession("joiner")
tserver.sendCommand(joinerToken, map[string]any{
commandKey: joinCmd, toKey: "#joinnamestest",
})
msgs, _ := tserver.pollMessages(joinerToken, 0)
assertNamesHostmask(
t, msgs, "joinmem", "jmident",
)
}
// setupChannelWithIdentMember creates a member session
// with username, joins a channel, then creates a querier
// and joins the same channel. Returns the querier token
// and last message ID.
func setupChannelWithIdentMember(
tserver *testServer,
memberNick, memberUsername,
querierNick, channel string,
) (string, int64) {
tserver.t.Helper()
memberToken := tserver.createSessionWithUsername(
memberNick, memberUsername,
)
tserver.sendCommand(memberToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
queryToken := tserver.createSession(querierNick)
tserver.sendCommand(queryToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
_, lastID := tserver.pollMessages(queryToken, 0)
return queryToken, lastID
}
// assertNamesHostmask verifies that a RPL_NAMREPLY (353)
// message contains the expected nick with hostmask format
// (nick!user@host).
func assertNamesHostmask(
t *testing.T,
msgs []map[string]any,
targetNick, expectedUsername string,
) {
t.Helper()
for _, msg := range msgs {
code, ok := msg["code"].(float64)
if !ok || int(code) != 353 {
continue
}
raw, exists := msg["body"]
if !exists || raw == nil {
continue
}
arr, isArr := raw.([]any)
if !isArr || len(arr) == 0 {
continue
}
bodyStr, isStr := arr[0].(string)
if !isStr {
continue
}
// Look for the target nick's hostmask entry.
expected := targetNick + "!" +
expectedUsername + "@"
if !strings.Contains(bodyStr, expected) {
t.Fatalf(
"expected NAMES body to contain %q, "+
"got %q",
expected, bodyStr,
)
}
return
}
t.Fatalf(
"expected RPL_NAMREPLY (353) with hostmask "+
"for %s, msgs: %v",
targetNick, msgs,
)
}
const testOperName = "admin"
const testOperPassword = "secretpass"
// newTestServerWithOper creates a test server with oper
// credentials configured (admin / secretpass).
func newTestServerWithOper(
t *testing.T,
) *testServer {
t.Helper()
return newTestServerWith(
t, 0, testOperName, testOperPassword,
)
}
func TestOperCommandSuccess(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("operuser")
_, lastID := tserver.pollMessages(token, 0)
// Send OPER command.
tserver.sendCommand(token, map[string]any{
commandKey: irc.CmdOper,
bodyKey: []string{testOperName, testOperPassword},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 381 RPL_YOUREOPER.
if !findNumeric(msgs, "381") {
t.Fatalf(
"expected RPL_YOUREOPER (381), got %v",
msgs,
)
}
}
func TestOperCommandFailure(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("badoper")
_, lastID := tserver.pollMessages(token, 0)
// Send OPER with wrong password.
tserver.sendCommand(token, map[string]any{
commandKey: irc.CmdOper,
bodyKey: []string{testOperName, "wrongpass"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 491 ERR_NOOPERHOST.
if !findNumeric(msgs, "491") {
t.Fatalf(
"expected ERR_NOOPERHOST (491), got %v",
msgs,
)
}
}
func TestOperCommandNeedMoreParams(t *testing.T) {
tserver := newTestServerWithOper(t)
token := tserver.createSession("shortoper")
_, lastID := tserver.pollMessages(token, 0)
// Send OPER with only one parameter.
tserver.sendCommand(token, map[string]any{
commandKey: irc.CmdOper,
bodyKey: []string{testOperName},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Expect 461 ERR_NEEDMOREPARAMS.
if !findNumeric(msgs, "461") {
t.Fatalf(
"expected ERR_NEEDMOREPARAMS (461), got %v",
msgs,
)
}
}
func TestOperWhoisShowsClientInfo(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create a target user.
_ = tserver.createSession("target")
// Create an oper user.
operToken := tserver.createSession("theoper")
_, lastID := tserver.pollMessages(operToken, 0)
// Authenticate as oper.
tserver.sendCommand(operToken, map[string]any{
commandKey: irc.CmdOper,
bodyKey: []string{testOperName, testOperPassword},
})
var msgs []map[string]any
msgs, lastID = tserver.pollMessages(operToken, lastID)
if !findNumeric(msgs, "381") {
t.Fatalf(
"expected RPL_YOUREOPER (381), got %v",
msgs,
)
}
// Now WHOIS the target.
tserver.sendCommand(operToken, map[string]any{
commandKey: irc.CmdWhois,
toKey: "target",
})
msgs, _ = tserver.pollMessages(operToken, lastID)
// Expect 338 RPL_WHOISACTUALLY with client IP.
whoisActually := findNumericWithParams(msgs, "338")
if whoisActually == nil {
t.Fatalf(
"expected RPL_WHOISACTUALLY (338) for "+
"oper WHOIS, got %v",
msgs,
)
}
params := getNumericParams(whoisActually)
if len(params) < 2 {
t.Fatalf(
"expected at least 2 params in 338, "+
"got %v",
params,
)
}
// First param should be the target nick.
if params[0] != "target" {
t.Fatalf(
"expected first param 'target', got %s",
params[0],
)
}
// Second param should be a non-empty IP.
if params[1] == "" {
t.Fatal("expected non-empty IP in 338 params")
}
}
func TestNonOperWhoisHidesClientInfo(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create a target user.
_ = tserver.createSession("hidden")
// Create a regular (non-oper) user.
regToken := tserver.createSession("regular")
_, lastID := tserver.pollMessages(regToken, 0)
// WHOIS the target without oper status.
tserver.sendCommand(regToken, map[string]any{
commandKey: irc.CmdWhois,
toKey: "hidden",
})
msgs, _ := tserver.pollMessages(regToken, lastID)
// Should NOT see 338 RPL_WHOISACTUALLY.
if findNumeric(msgs, "338") {
t.Fatalf(
"non-oper should not see "+
"RPL_WHOISACTUALLY (338), got %v",
msgs,
)
}
// But should see 311 RPL_WHOISUSER (normal WHOIS).
if !findNumeric(msgs, "311") {
t.Fatalf(
"expected RPL_WHOISUSER (311), got %v",
msgs,
)
}
}
func TestWhoisShowsOperatorStatus(t *testing.T) {
tserver := newTestServerWithOper(t)
// Create oper user and authenticate.
operToken := tserver.createSession("iamoper")
_, lastID := tserver.pollMessages(operToken, 0)
tserver.sendCommand(operToken, map[string]any{
commandKey: irc.CmdOper,
bodyKey: []string{testOperName, testOperPassword},
})
msgs, _ := tserver.pollMessages(operToken, lastID)
if !findNumeric(msgs, "381") {
t.Fatalf("expected 381, got %v", msgs)
}
// Another user does WHOIS on the oper.
queryToken := tserver.createSession("asker")
_, queryLastID := tserver.pollMessages(queryToken, 0)
tserver.sendCommand(queryToken, map[string]any{
commandKey: irc.CmdWhois,
toKey: "iamoper",
})
msgs, _ = tserver.pollMessages(queryToken, queryLastID)
// Should see 313 RPL_WHOISOPERATOR.
if !findNumeric(msgs, "313") {
t.Fatalf(
"expected RPL_WHOISOPERATOR (313) in "+
"WHOIS of oper, got %v",
msgs,
)
}
}
func TestOperNoOlineConfigured(t *testing.T) {
// Standard test server has no oper configured.
tserver := newTestServer(t)
token := tserver.createSession("nooline")
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: irc.CmdOper,
bodyKey: []string{testOperName, "password"},
})
msgs, _ := tserver.pollMessages(token, lastID)
// Should get 491 since no o-line is configured.
if !findNumeric(msgs, "491") {
t.Fatalf(
"expected ERR_NOOPERHOST (491) when no "+
"o-line configured, got %v",
msgs,
)
}
}
// --- Tier 1: Channel Modes Tests ---
const kickCmd = "KICK"
// TestOperatorAutoGrantOnCreate verifies that the first
// user to join a channel (the creator) gets +o.
func TestOperatorAutoGrantOnCreate(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("creator")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#opcreate",
})
_, lastID := tserver.pollMessages(token, 0)
// Issue NAMES — the creator should have @prefix.
tserver.sendCommand(token, map[string]any{
commandKey: irc.CmdNames, toKey: "#opcreate",
})
msgs, _ := tserver.pollMessages(token, lastID)
namesMsg := findNumericWithParams(msgs, "353")
if namesMsg == nil {
t.Fatalf(
"expected RPL_NAMREPLY (353), got %v",
msgs,
)
}
raw, exists := namesMsg["body"]
if !exists || raw == nil {
t.Fatal("expected body in NAMES reply")
}
arr, isArr := raw.([]any)
if !isArr || len(arr) == 0 {
t.Fatal("expected non-empty body array")
}
bodyStr, isStr := arr[0].(string)
if !isStr {
t.Fatal("expected string body")
}
if !strings.Contains(bodyStr, "@creator!") {
t.Fatalf(
"expected @creator in NAMES, got %q",
bodyStr,
)
}
}
// TestSecondJoinerNotOperator verifies that subsequent
// joiners do NOT get +o.
func TestSecondJoinerNotOperator(t *testing.T) {
const channel = "#optest2"
tserver := newTestServer(t)
creatorToken := tserver.createSession("op_first")
joinerToken := tserver.createSession("op_second")
tserver.sendCommand(creatorToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(joinerToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
_, lastID := tserver.pollMessages(joinerToken, 0)
tserver.sendCommand(joinerToken, map[string]any{
commandKey: irc.CmdNames, toKey: channel,
})
msgs, _ := tserver.pollMessages(joinerToken, lastID)
namesMsg := findNumericWithParams(msgs, "353")
if namesMsg == nil {
t.Fatalf(
"expected RPL_NAMREPLY (353), got %v",
msgs,
)
}
raw := namesMsg["body"]
arr, _ := raw.([]any)
bodyStr, _ := arr[0].(string)
// op_first should have @, op_second should not.
if !strings.Contains(bodyStr, "@op_first!") {
t.Fatalf(
"expected @op_first in NAMES, got %q",
bodyStr,
)
}
if strings.Contains(bodyStr, "@op_second!") {
t.Fatalf(
"op_second should NOT have @, got %q",
bodyStr,
)
}
}
// TestModeGrantOperator tests MODE +o.
func TestModeGrantOperator(t *testing.T) {
const channel = "#modeop"
tserver := newTestServer(t)
opToken := tserver.createSession("granter")
targetToken := tserver.createSession("grantee")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(targetToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
_, lastID := tserver.pollMessages(targetToken, 0)
// granter (creator = +o) grants +o to grantee.
status, _ := tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+o", "grantee"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
// grantee should receive MODE broadcast.
msgs, _ := tserver.pollMessages(targetToken, lastID)
if !findMessage(msgs, modeCmd, "granter") {
t.Fatalf(
"grantee didn't get MODE broadcast: %v",
msgs,
)
}
// Verify grantee now shows @ in NAMES.
_, lastID = tserver.pollMessages(targetToken, 0)
tserver.sendCommand(targetToken, map[string]any{
commandKey: irc.CmdNames, toKey: channel,
})
msgs, _ = tserver.pollMessages(targetToken, lastID)
namesMsg := findNumericWithParams(msgs, "353")
if namesMsg == nil {
t.Fatal("expected NAMES reply")
}
raw := namesMsg["body"]
arr, _ := raw.([]any)
bodyStr, _ := arr[0].(string)
if !strings.Contains(bodyStr, "@grantee!") {
t.Fatalf(
"expected @grantee in NAMES, got %q",
bodyStr,
)
}
}
// TestModeRevokeOperator tests MODE -o.
func TestModeRevokeOperator(t *testing.T) {
const channel = "#revoke"
tserver := newTestServer(t)
opToken := tserver.createSession("revoker")
targetToken := tserver.createSession("revokee")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(targetToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
// Grant +o, then revoke it.
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+o", "revokee"},
})
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"-o", "revokee"},
})
// Check NAMES — revokee should NOT have @.
_, lastID := tserver.pollMessages(opToken, 0)
tserver.sendCommand(opToken, map[string]any{
commandKey: irc.CmdNames, toKey: channel,
})
msgs, _ := tserver.pollMessages(opToken, lastID)
namesMsg := findNumericWithParams(msgs, "353")
if namesMsg == nil {
t.Fatal("expected NAMES reply")
}
raw := namesMsg["body"]
arr, _ := raw.([]any)
bodyStr, _ := arr[0].(string)
if strings.Contains(bodyStr, "@revokee!") {
t.Fatalf(
"revokee should NOT have @ after -o, got %q",
bodyStr,
)
}
}
// TestModeVoice tests MODE +v/-v.
func TestModeVoice(t *testing.T) {
const channel = "#voice"
tserver := newTestServer(t)
opToken := tserver.createSession("voicer")
targetToken := tserver.createSession("voiced")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(targetToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
// Grant +v.
status, _ := tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+v", "voiced"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
// Check NAMES for + prefix.
_, lastID := tserver.pollMessages(opToken, 0)
tserver.sendCommand(opToken, map[string]any{
commandKey: irc.CmdNames, toKey: channel,
})
msgs, _ := tserver.pollMessages(opToken, lastID)
namesMsg := findNumericWithParams(msgs, "353")
raw := namesMsg["body"]
arr, _ := raw.([]any)
bodyStr, _ := arr[0].(string)
if !strings.Contains(bodyStr, "+voiced!") {
t.Fatalf(
"expected +voiced in NAMES, got %q",
bodyStr,
)
}
// Revoke -v.
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"-v", "voiced"},
})
_, lastID = tserver.pollMessages(opToken, 0)
tserver.sendCommand(opToken, map[string]any{
commandKey: irc.CmdNames, toKey: channel,
})
msgs, _ = tserver.pollMessages(opToken, lastID)
namesMsg = findNumericWithParams(msgs, "353")
raw = namesMsg["body"]
arr, _ = raw.([]any)
bodyStr, _ = arr[0].(string)
if strings.Contains(bodyStr, "+voiced!") {
t.Fatalf(
"voiced should NOT have + after -v, got %q",
bodyStr,
)
}
}
// TestModeNonOpCannotGrant verifies non-operators
// cannot grant +o or +v.
func TestModeNonOpCannotGrant(t *testing.T) {
const channel = "#noperm"
tserver := newTestServer(t)
opToken := tserver.createSession("realop")
nonOpToken := tserver.createSession("nonop")
targetToken := tserver.createSession("target3")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(nonOpToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(targetToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
_, lastID := tserver.pollMessages(nonOpToken, 0)
// Non-op tries +o.
tserver.sendCommand(nonOpToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+o", "target3"},
})
msgs, _ := tserver.pollMessages(nonOpToken, lastID)
// Should get 482 ERR_CHANOPRIVSNEEDED.
if !findNumeric(msgs, "482") {
t.Fatalf(
"expected ERR_CHANOPRIVSNEEDED (482), "+
"got %v",
msgs,
)
}
// Non-op tries +v.
_, lastID = tserver.pollMessages(nonOpToken, 0)
tserver.sendCommand(nonOpToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+v", "target3"},
})
msgs, _ = tserver.pollMessages(nonOpToken, lastID)
if !findNumeric(msgs, "482") {
t.Fatalf(
"expected ERR_CHANOPRIVSNEEDED (482) for +v, "+
"got %v",
msgs,
)
}
}
// TestModeratedChannelBlocksNonVoiced tests +m mode.
func TestModeratedChannelBlocksNonVoiced(t *testing.T) {
const channel = "#moderated"
tserver := newTestServer(t)
opToken := tserver.createSession("modop")
regularToken := tserver.createSession("regular2")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(regularToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
// Set +m.
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+m"},
})
_, lastID := tserver.pollMessages(regularToken, 0)
// Regular user tries to send — should be blocked.
tserver.sendCommand(regularToken, map[string]any{
commandKey: privmsgCmd,
toKey: channel,
bodyKey: []string{"blocked message"},
})
msgs, _ := tserver.pollMessages(regularToken, lastID)
if !findNumeric(msgs, "404") {
t.Fatalf(
"expected ERR_CANNOTSENDTOCHAN (404) "+
"for +m, got %v",
msgs,
)
}
}
// TestModeratedChannelAllowsOp tests that operators can
// send in +m channels.
func TestModeratedChannelAllowsOp(t *testing.T) {
const channel = "#modop"
tserver := newTestServer(t)
opToken := tserver.createSession("modop2")
observerToken := tserver.createSession("observer2")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(observerToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
// Set +m.
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+m"},
})
_, lastID := tserver.pollMessages(observerToken, 0)
// Op sends — should work.
status, result := tserver.sendCommand(opToken, map[string]any{
commandKey: privmsgCmd,
toKey: channel,
bodyKey: []string{"op message"},
})
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
msgs, _ := tserver.pollMessages(observerToken, lastID)
if !findMessage(msgs, privmsgCmd, "modop2") {
t.Fatalf(
"observer didn't receive op's message: %v",
msgs,
)
}
}
// TestModeratedChannelAllowsVoiced tests that voiced
// users can send in +m channels.
func TestModeratedChannelAllowsVoiced(t *testing.T) {
const channel = "#modvoice"
tserver := newTestServer(t)
opToken := tserver.createSession("modop3")
voicedToken := tserver.createSession("modvoiced")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(voicedToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
// Set +m and +v on voiced user.
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+m"},
})
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+v", "modvoiced"},
})
_, lastID := tserver.pollMessages(opToken, 0)
// Voiced user sends — should work.
status, result := tserver.sendCommand(
voicedToken, map[string]any{
commandKey: privmsgCmd,
toKey: channel,
bodyKey: []string{"voiced message"},
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
msgs, _ := tserver.pollMessages(opToken, lastID)
if !findMessage(msgs, privmsgCmd, "modvoiced") {
t.Fatalf(
"op didn't receive voiced user's message: %v",
msgs,
)
}
}
// TestTopicLockDefaultOn verifies new channels have +t.
func TestTopicLockDefaultOn(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("topiclock")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#tldefault",
})
_, lastID := tserver.pollMessages(token, 0)
// Query mode — should show +nt.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd, toKey: "#tldefault",
})
msgs, _ := tserver.pollMessages(token, lastID)
modeMsg := findNumericWithParams(msgs, "324")
if modeMsg == nil {
t.Fatalf(
"expected RPL_CHANNELMODEIS (324), got %v",
msgs,
)
}
params := getNumericParams(modeMsg)
if len(params) < 2 {
t.Fatalf(
"expected at least 2 params, got %v",
params,
)
}
if !strings.Contains(params[1], "t") {
t.Fatalf(
"expected +t in mode string, got %q",
params[1],
)
}
}
// TestTopicLockEnforced verifies non-operators cannot
// change topic when +t is active.
func TestTopicLockEnforced(t *testing.T) {
const channel = "#tlock"
tserver := newTestServer(t)
opToken := tserver.createSession("topicop")
regularToken := tserver.createSession("topicuser")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(regularToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
// +t is on by default. Non-op tries to set topic.
_, lastID := tserver.pollMessages(regularToken, 0)
tserver.sendCommand(regularToken, map[string]any{
commandKey: irc.CmdTopic,
toKey: channel,
bodyKey: []string{"unauthorized topic"},
})
msgs, _ := tserver.pollMessages(regularToken, lastID)
if !findNumeric(msgs, "482") {
t.Fatalf(
"expected ERR_CHANOPRIVSNEEDED (482) "+
"for +t, got %v",
msgs,
)
}
}
// TestTopicLockOpCanChange verifies operators CAN change
// topic when +t is active.
func TestTopicLockOpCanChange(t *testing.T) {
tserver := newTestServer(t)
opToken := tserver.createSession("topicop2")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: "#tlock2",
})
// Op sets topic — should succeed (creator has +o).
status, result := tserver.sendCommand(
opToken, map[string]any{
commandKey: irc.CmdTopic,
toKey: "#tlock2",
bodyKey: []string{"op topic"},
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result["topic"] != "op topic" {
t.Fatalf(
"expected topic 'op topic', got %v",
result["topic"],
)
}
}
// TestTopicLockDisabled verifies that -t allows anyone
// to change topic.
func TestTopicLockDisabled(t *testing.T) {
const channel = "#tloff"
tserver := newTestServer(t)
opToken := tserver.createSession("tloff_op")
regularToken := tserver.createSession("tloff_user")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(regularToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
// Disable +t.
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"-t"},
})
// Now regular user sets topic — should succeed.
status, result := tserver.sendCommand(
regularToken, map[string]any{
commandKey: irc.CmdTopic,
toKey: channel,
bodyKey: []string{"user topic"},
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result["topic"] != "user topic" {
t.Fatalf(
"expected 'user topic', got %v",
result["topic"],
)
}
}
// TestKickByOperator tests that an operator can kick
// a user.
func TestKickByOperator(t *testing.T) {
const channel = "#kicktest"
tserver := newTestServer(t)
opToken := tserver.createSession("kicker")
targetToken := tserver.createSession("kicked")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(targetToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
_, lastID := tserver.pollMessages(targetToken, 0)
// Kick the target.
status, _ := tserver.sendCommand(opToken, map[string]any{
commandKey: kickCmd,
toKey: channel,
bodyKey: []string{"kicked", "misbehaving"},
})
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
// Target should receive KICK message.
msgs, _ := tserver.pollMessages(targetToken, lastID)
if !findMessage(msgs, kickCmd, "kicker") {
t.Fatalf(
"kicked user didn't receive KICK: %v",
msgs,
)
}
// Verify kicked user is no longer in channel:
// try to send — should fail.
_, lastID = tserver.pollMessages(targetToken, 0)
tserver.sendCommand(targetToken, map[string]any{
commandKey: privmsgCmd,
toKey: channel,
bodyKey: []string{"still here?"},
})
msgs, _ = tserver.pollMessages(targetToken, lastID)
if !findNumeric(msgs, "404") {
t.Fatalf(
"expected 404 after kick, got %v", msgs,
)
}
}
// TestKickByNonOperator tests that non-operators cannot
// kick.
func TestKickByNonOperator(t *testing.T) {
const channel = "#kickperm"
tserver := newTestServer(t)
opToken := tserver.createSession("op_kick2")
nonOpToken := tserver.createSession("nonop_kick")
targetToken := tserver.createSession("target_kick")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(nonOpToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(targetToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
_, lastID := tserver.pollMessages(nonOpToken, 0)
// Non-op tries to kick.
tserver.sendCommand(nonOpToken, map[string]any{
commandKey: kickCmd,
toKey: channel,
bodyKey: []string{"target_kick", "nope"},
})
msgs, _ := tserver.pollMessages(nonOpToken, lastID)
if !findNumeric(msgs, "482") {
t.Fatalf(
"expected ERR_CHANOPRIVSNEEDED (482), "+
"got %v",
msgs,
)
}
}
// TestKickTargetNotInChannel tests kicking a user not
// in the channel.
func TestKickTargetNotInChannel(t *testing.T) {
tserver := newTestServer(t)
opToken := tserver.createSession("op_kicknot")
_ = tserver.createSession("outsider")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: "#kicknotinchan",
})
_, lastID := tserver.pollMessages(opToken, 0)
tserver.sendCommand(opToken, map[string]any{
commandKey: kickCmd,
toKey: "#kicknotinchan",
bodyKey: []string{"outsider", "bye"},
})
msgs, _ := tserver.pollMessages(opToken, lastID)
if !findNumeric(msgs, "441") {
t.Fatalf(
"expected ERR_USERNOTINCHANNEL (441), "+
"got %v",
msgs,
)
}
}
// TestKickBroadcastToChannel verifies the KICK message
// is broadcast to all channel members.
func TestKickBroadcastToChannel(t *testing.T) {
const channel = "#kickbc"
tserver := newTestServer(t)
opToken := tserver.createSession("op_kb")
targetToken := tserver.createSession("target_kb")
observerToken := tserver.createSession("obs_kb")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(targetToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(observerToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
_, lastID := tserver.pollMessages(observerToken, 0)
tserver.sendCommand(opToken, map[string]any{
commandKey: kickCmd,
toKey: channel,
bodyKey: []string{"target_kb", "reason"},
})
msgs, _ := tserver.pollMessages(observerToken, lastID)
if !findMessage(msgs, kickCmd, "op_kb") {
t.Fatalf(
"observer didn't receive KICK: %v", msgs,
)
}
}
// TestNoticeNoAwayReply verifies NOTICE doesn't trigger
// RPL_AWAY.
func TestNoticeNoAwayReply(t *testing.T) {
tserver := newTestServer(t)
senderToken := tserver.createSession("noticesend")
awayToken := tserver.createSession("noticeaway")
// Set away status.
tserver.sendCommand(awayToken, map[string]any{
commandKey: "AWAY",
bodyKey: []string{"I am away"},
})
_, lastID := tserver.pollMessages(senderToken, 0)
// Send NOTICE — should NOT trigger RPL_AWAY.
tserver.sendCommand(senderToken, map[string]any{
commandKey: irc.CmdNotice,
toKey: "noticeaway",
bodyKey: []string{"notice message"},
})
msgs, _ := tserver.pollMessages(senderToken, lastID)
if findNumeric(msgs, "301") {
t.Fatalf(
"NOTICE should NOT trigger RPL_AWAY (301), "+
"got %v",
msgs,
)
}
}
// TestPrivmsgTriggersAway verifies PRIVMSG DOES trigger
// RPL_AWAY.
func TestPrivmsgTriggersAway(t *testing.T) {
tserver := newTestServer(t)
senderToken := tserver.createSession("msgsend")
awayToken := tserver.createSession("msgaway")
// Set away status.
tserver.sendCommand(awayToken, map[string]any{
commandKey: "AWAY",
bodyKey: []string{"I am away"},
})
_, lastID := tserver.pollMessages(senderToken, 0)
// Send PRIVMSG — should trigger RPL_AWAY.
tserver.sendCommand(senderToken, map[string]any{
commandKey: privmsgCmd,
toKey: "msgaway",
bodyKey: []string{"hello?"},
})
msgs, _ := tserver.pollMessages(senderToken, lastID)
if !findNumeric(msgs, "301") {
t.Fatalf(
"PRIVMSG should trigger RPL_AWAY (301), "+
"got %v",
msgs,
)
}
}
// TestNoticeSkipsHashcash verifies NOTICE bypasses
// hashcash on +H channels.
func TestNoticeSkipsHashcash(t *testing.T) {
const channel = "#hcnotice"
tserver := newTestServer(t)
opToken := tserver.createSession("hcnotice_op")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
// Set hashcash requirement.
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+H", "2"},
})
// Send NOTICE without hashcash — should succeed.
status, result := tserver.sendCommand(
opToken, map[string]any{
commandKey: irc.CmdNotice,
toKey: channel,
bodyKey: []string{"server notice"},
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result["id"] == nil || result["id"] == "" {
t.Fatal("expected message id for NOTICE")
}
}
// TestModeratedNoticeBlocked verifies +m blocks NOTICE
// from non-voiced/non-op users too.
func TestModeratedNoticeBlocked(t *testing.T) {
const channel = "#modnotice"
tserver := newTestServer(t)
opToken := tserver.createSession("modnotop")
regularToken := tserver.createSession("modnotice")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(regularToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+m"},
})
_, lastID := tserver.pollMessages(regularToken, 0)
tserver.sendCommand(regularToken, map[string]any{
commandKey: irc.CmdNotice,
toKey: channel,
bodyKey: []string{"blocked notice"},
})
msgs, _ := tserver.pollMessages(regularToken, lastID)
if !findNumeric(msgs, "404") {
t.Fatalf(
"expected 404 for NOTICE in +m, got %v",
msgs,
)
}
}
// TestNonOpCannotSetModerated verifies non-operators
// cannot set +m.
func TestNonOpCannotSetModerated(t *testing.T) {
const channel = "#modperm"
tserver := newTestServer(t)
opToken := tserver.createSession("setmod_op")
regularToken := tserver.createSession("setmod_reg")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(regularToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
_, lastID := tserver.pollMessages(regularToken, 0)
tserver.sendCommand(regularToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+m"},
})
msgs, _ := tserver.pollMessages(regularToken, lastID)
if !findNumeric(msgs, "482") {
t.Fatalf(
"expected 482 for non-op +m, got %v", msgs,
)
}
}
// TestISupportPrefix verifies PREFIX=(ov)@+ is in 005.
func TestISupportPrefix(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("isupport")
msgs, _ := tserver.pollMessages(token, 0)
isupportMsg := findNumericWithParams(msgs, "005")
if isupportMsg == nil {
t.Fatalf(
"expected RPL_ISUPPORT (005), got %v",
msgs,
)
}
params := getNumericParams(isupportMsg)
if !slices.Contains(params, "PREFIX=(ov)@+") {
t.Fatalf(
"expected PREFIX=(ov)@+ in ISUPPORT, "+
"got %v",
params,
)
}
}
// TestModeQueryShowsModerated verifies MODE query shows
// +m when set.
func TestModeQueryShowsModerated(t *testing.T) {
const channel = "#mqtest"
tserver := newTestServer(t)
token := tserver.createSession("mquery")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+m"},
})
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd, toKey: channel,
})
msgs, _ := tserver.pollMessages(token, lastID)
modeMsg := findNumericWithParams(msgs, "324")
if modeMsg == nil {
t.Fatal("expected 324")
}
params := getNumericParams(modeMsg)
if len(params) < 2 || !strings.Contains(params[1], "m") {
t.Fatalf(
"expected +m in mode string, got %v",
params,
)
}
}
// TestKickDefaultReason verifies KICK uses kicker's
// nick as default reason.
func TestKickDefaultReason(t *testing.T) {
const channel = "#kickdef"
tserver := newTestServer(t)
opToken := tserver.createSession("kickdefop")
targetToken := tserver.createSession("kickdeftg")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(targetToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
_, lastID := tserver.pollMessages(targetToken, 0)
// Kick with only nick, no reason.
tserver.sendCommand(opToken, map[string]any{
commandKey: kickCmd,
toKey: channel,
bodyKey: []string{"kickdeftg"},
})
msgs, _ := tserver.pollMessages(targetToken, lastID)
if !findMessage(msgs, kickCmd, "kickdefop") {
t.Fatalf(
"expected KICK message, got %v", msgs,
)
}
}
// --- Tier 2 Handler Tests ---
const (
inviteCmd = "INVITE"
joinedStatus = "joined"
)
// TestBanAddRemoveList verifies +b add, list, and -b
// remove via MODE commands.
func TestBanAddRemoveList(t *testing.T) {
const channel = "#bans"
tserver := newTestServer(t)
opToken := tserver.createSession("banop")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
// Add a ban.
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+b", "*!*@evil.com"},
})
_, lastID := tserver.pollMessages(opToken, 0)
// List bans (+b with no argument).
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+b"},
})
msgs, _ := tserver.pollMessages(opToken, lastID)
// Should have RPL_BANLIST (367).
banMsg := findNumericWithParams(msgs, "367")
if banMsg == nil {
t.Fatalf("expected 367 RPL_BANLIST, got %v", msgs)
}
// Should have RPL_ENDOFBANLIST (368).
if !findNumeric(msgs, "368") {
t.Fatal("expected 368 RPL_ENDOFBANLIST")
}
// Remove the ban.
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"-b", "*!*@evil.com"},
})
_, lastID = tserver.pollMessages(opToken, lastID)
// List again — should be empty (just end-of-list).
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+b"},
})
msgs, _ = tserver.pollMessages(opToken, lastID)
banMsg = findNumericWithParams(msgs, "367")
if banMsg != nil {
t.Fatal("expected no 367 after ban removal")
}
if !findNumeric(msgs, "368") {
t.Fatal("expected 368 RPL_ENDOFBANLIST")
}
}
// TestBanBlocksJoin verifies that a banned user cannot
// join a channel.
func TestBanBlocksJoin(t *testing.T) {
const channel = "#banjoin"
tserver := newTestServer(t)
opToken := tserver.createSession("banop2")
userToken := tserver.createSession("banned2")
// Op creates channel and sets a ban.
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+b", "banned2!*@*"},
})
// Banned user tries to join.
_, lastID := tserver.pollMessages(userToken, 0)
tserver.sendCommand(userToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
msgs, _ := tserver.pollMessages(userToken, lastID)
// Should get ERR_BANNEDFROMCHAN (474).
if !findNumeric(msgs, "474") {
t.Fatalf("expected 474 ERR_BANNEDFROMCHAN, got %v", msgs)
}
}
// TestBanBlocksPrivmsg verifies that a banned user who
// is already in a channel cannot send messages.
func TestBanBlocksPrivmsg(t *testing.T) {
const channel = "#banmsg"
tserver := newTestServer(t)
opToken := tserver.createSession("banmsgop")
userToken := tserver.createSession("banmsgusr")
// Both join.
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(userToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
// Op bans the user.
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+b", "banmsgusr!*@*"},
})
// User tries to send a message.
_, lastID := tserver.pollMessages(userToken, 0)
tserver.sendCommand(userToken, map[string]any{
commandKey: privmsgCmd,
toKey: channel,
bodyKey: []string{"hello"},
})
msgs, _ := tserver.pollMessages(userToken, lastID)
// Should get ERR_CANNOTSENDTOCHAN (404).
if !findNumeric(msgs, "404") {
t.Fatalf("expected 404 ERR_CANNOTSENDTOCHAN, got %v", msgs)
}
}
// TestInviteOnlyJoin verifies +i behavior: join rejected
// without invite, accepted with invite.
func TestInviteOnlyJoin(t *testing.T) {
const channel = "#invonly"
tserver := newTestServer(t)
opToken := tserver.createSession("invop")
userToken := tserver.createSession("invusr")
// Op creates channel and sets +i.
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+i"},
})
// User tries to join without invite.
_, lastID := tserver.pollMessages(userToken, 0)
tserver.sendCommand(userToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
msgs, _ := tserver.pollMessages(userToken, lastID)
if !findNumeric(msgs, "473") {
t.Fatalf(
"expected 473 ERR_INVITEONLYCHAN, got %v",
msgs,
)
}
// Op invites user.
tserver.sendCommand(opToken, map[string]any{
commandKey: inviteCmd,
bodyKey: []string{"invusr", channel},
})
// User tries again — should succeed with invite.
_, result := tserver.sendCommand(userToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
if result[statusKey] != joinedStatus {
t.Fatalf(
"expected join to succeed with invite, got %v",
result,
)
}
}
// TestSecretChannelHiddenFromList verifies +s hides a
// channel from LIST for non-members.
func TestSecretChannelHiddenFromList(t *testing.T) {
const channel = "#secret"
tserver := newTestServer(t)
opToken := tserver.createSession("secop")
outsiderToken := tserver.createSession("secout")
// Op creates secret channel.
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+s"},
})
// Outsider does LIST.
_, lastID := tserver.pollMessages(outsiderToken, 0)
tserver.sendCommand(outsiderToken, map[string]any{
commandKey: "LIST",
})
msgs, _ := tserver.pollMessages(outsiderToken, lastID)
// Should NOT see #secret in any 322 (RPL_LIST).
for _, msg := range msgs {
code, ok := msg["code"].(float64)
if !ok || int(code) != 322 {
continue
}
params := getNumericParams(msg)
for _, p := range params {
if p == channel {
t.Fatal("outsider should not see #secret in LIST")
}
}
}
// Member does LIST — should see it.
_, lastID = tserver.pollMessages(opToken, 0)
tserver.sendCommand(opToken, map[string]any{
commandKey: "LIST",
})
msgs, _ = tserver.pollMessages(opToken, lastID)
found := false
for _, msg := range msgs {
code, ok := msg["code"].(float64)
if !ok || int(code) != 322 {
continue
}
params := getNumericParams(msg)
for _, p := range params {
if p == channel {
found = true
}
}
}
if !found {
t.Fatal("member should see #secret in LIST")
}
}
// TestChannelKeyJoin verifies +k behavior: wrong/missing
// key is rejected, correct key allows join.
func TestChannelKeyJoin(t *testing.T) {
const channel = "#keyed"
tserver := newTestServer(t)
opToken := tserver.createSession("keyop")
userToken := tserver.createSession("keyusr")
// Op creates keyed channel.
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+k", "mykey"},
})
// User tries without key.
_, lastID := tserver.pollMessages(userToken, 0)
tserver.sendCommand(userToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
msgs, _ := tserver.pollMessages(userToken, lastID)
if !findNumeric(msgs, "475") {
t.Fatalf(
"expected 475 ERR_BADCHANNELKEY, got %v",
msgs,
)
}
// User tries with wrong key.
tserver.sendCommand(userToken, map[string]any{
commandKey: joinCmd,
toKey: channel,
bodyKey: []string{"wrongkey"},
})
msgs, _ = tserver.pollMessages(userToken, lastID)
if !findNumeric(msgs, "475") {
t.Fatalf(
"expected 475 ERR_BADCHANNELKEY for wrong key, got %v",
msgs,
)
}
// User tries with correct key.
_, result := tserver.sendCommand(userToken, map[string]any{
commandKey: joinCmd,
toKey: channel,
bodyKey: []string{"mykey"},
})
if result[statusKey] != joinedStatus {
t.Fatalf(
"expected join to succeed with correct key, got %v",
result,
)
}
}
// TestUserLimitEnforcement verifies +l behavior: blocks
// join when at capacity.
func TestUserLimitEnforcement(t *testing.T) {
const channel = "#limited"
tserver := newTestServer(t)
opToken := tserver.createSession("limop")
user1Token := tserver.createSession("limusr1")
user2Token := tserver.createSession("limusr2")
// Op creates channel with limit 2.
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: []string{"+l", "2"},
})
// User1 joins — should succeed (2 members now: op + user1).
_, result := tserver.sendCommand(user1Token, map[string]any{
commandKey: joinCmd, toKey: channel,
})
if result[statusKey] != joinedStatus {
t.Fatalf("user1 should join, got %v", result)
}
// User2 tries to join — should fail (at limit: 2/2).
_, lastID := tserver.pollMessages(user2Token, 0)
tserver.sendCommand(user2Token, map[string]any{
commandKey: joinCmd, toKey: channel,
})
msgs, _ := tserver.pollMessages(user2Token, lastID)
if !findNumeric(msgs, "471") {
t.Fatalf(
"expected 471 ERR_CHANNELISFULL, got %v",
msgs,
)
}
}
// TestModeStringIncludesNewModes verifies that querying
// channel mode returns the new modes (+i, +s, +k, +l).
func TestModeStringIncludesNewModes(t *testing.T) {
const channel = "#modestr"
tserver := newTestServer(t)
opToken := tserver.createSession("modestrop")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
// Set all tier 2 modes.
for _, modeChange := range [][]string{
{"+i"}, {"+s"}, {"+k", "pw"}, {"+l", "50"},
} {
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: modeChange,
})
}
_, lastID := tserver.pollMessages(opToken, 0)
// Query mode.
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd, toKey: channel,
})
msgs, _ := tserver.pollMessages(opToken, lastID)
modeMsg := findNumericWithParams(msgs, "324")
if modeMsg == nil {
t.Fatal("expected 324 RPL_CHANNELMODEIS")
}
params := getNumericParams(modeMsg)
if len(params) < 2 {
t.Fatalf("too few params in 324: %v", params)
}
modeString := params[1]
for _, c := range []string{"i", "s", "k", "l"} {
if !strings.Contains(modeString, c) {
t.Fatalf(
"mode string %q missing %q",
modeString, c,
)
}
}
}
// TestISUPPORT verifies the 005 numeric includes the
// updated CHANMODES string.
func TestISUPPORT(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("isupport")
msgs, _ := tserver.pollMessages(token, 0)
isupp := findNumericWithParams(msgs, "005")
if isupp == nil {
t.Fatal("expected 005 RPL_ISUPPORT")
}
body, _ := isupp["body"].(string)
params := getNumericParams(isupp)
combined := body + " " + strings.Join(params, " ")
if !strings.Contains(combined, "CHANMODES=b,k,Hl,imnst") {
t.Fatalf(
"ISUPPORT missing updated CHANMODES, got body=%q params=%v",
body, params,
)
}
}
// TestNonOpCannotSetModes verifies non-operators
// cannot set +i, +s, +k, +l, +b.
func TestNonOpCannotSetModes(t *testing.T) {
const channel = "#noperm"
tserver := newTestServer(t)
opToken := tserver.createSession("modeopx")
userToken := tserver.createSession("modeusrx")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
tserver.sendCommand(userToken, map[string]any{
commandKey: joinCmd, toKey: channel,
})
modes := [][]string{
{"+i"}, {"+s"}, {"+k", "key"}, {"+l", "10"},
{"+b", "bad!*@*"},
}
for _, modeChange := range modes {
_, lastID := tserver.pollMessages(userToken, 0)
tserver.sendCommand(userToken, map[string]any{
commandKey: modeCmd,
toKey: channel,
bodyKey: modeChange,
})
msgs, _ := tserver.pollMessages(userToken, lastID)
// Should get 482 ERR_CHANOPRIVSNEEDED.
if !findNumeric(msgs, "482") {
t.Fatalf(
"expected 482 for %v, got %v",
modeChange, msgs,
)
}
}
}