Files
neoirc/internal/handlers/api_test.go
clawbot c20ad88dfe
All checks were successful
check / check (push) Successful in 1m12s
fix: KILL actually disconnects the victim; unify HTTP/IRC divergences
Addresses the 2026-08-10 FAIL review (findings 1-7, 9, 10).

KILL never terminated the victim's connection on either transport: both
paths called BroadcastQuit, which deletes the session row and tells the
victim's peers it quit, but leaves the victim holding a socket that looks
alive and silently delivers nothing while its nick is freed for reuse.
Service now owns a session-ID keyed registry of live wire connections that
ircserver populates at registration, and both KILL paths go through the new
Service.KillSession, which broadcasts the QUIT and then sends the victim a
KILL and ERROR :Closing Link before closing its socket. The victim's relay
goroutine is cancelled and its cleanup no longer re-broadcasts a QUIT for
an already-deleted session. TestIntegrationKill now asserts the victim
reads to EOF and is gone from NAMES and WHO, not just that an observer saw
the QUIT relay.

HTTP MODE <othernick> with no body answered with the requester's own modes,
because the target check sat inside the mode-change branch. The check is
hoisted above the query/change split, and both transports now compare nicks
with EqualFold since IRC nicks are case-insensitive.

Service.QueryUserMode returned "+" for a database failure, making an
unreadable mode indistinguishable from an unset one; it now returns an
error, and both callers surface it. db.GetUserhostInfo likewise treated
every scan error as "nick not found"; only sql.ErrNoRows is skipped now.

The four new unsynchronized c.nick reads this branch introduced are read
through currentNick() under c.mu, and c.closed is now guarded everywhere
because KILL writes it from another client's goroutine. Conn.send takes a
write mutex, as a connection is now written to by three goroutines.

server.Server.Run was left with no in-tree callers when its body was
inlined into the fx OnStart hook; it is deleted rather than left to drift.

INFO and VERSION had two implementations that had already diverged: the
version string is now Service.ServerVersion and the INFO body is
Service.InfoLines, used verbatim by both transports. The ctx parameters on
handleVersion/handleAdmin/handleInfo/handleTime existed only to be
discarded and are gone.
2026-09-03 15:19:50 +00:00

4877 lines
102 KiB
Go

// Tests use a global viper instance for configuration,
// making parallel execution unsafe.
//
//nolint:paralleltest
package handlers_test
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"sync"
"testing"
"time"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
"golang.org/x/crypto/bcrypt"
"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"
)
func TestMain(m *testing.M) {
db.SetBcryptCost(bcrypt.MinCost)
os.Exit(m.Run())
}
const (
commandKey = "command"
bodyKey = "body"
toKey = "to"
statusKey = "status"
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,
)
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
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,
Globals: globs,
})
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 != "" {
request.AddCookie(&http.Cookie{ //nolint:exhaustruct // only name+value needed
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{"nick": 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{"nick": 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["nick"] != "authtest" {
t.Fatalf(
"expected nick authtest, got %v",
result["nick"],
)
}
}
func TestJoinChannel(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("joiner")
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: joinCmd, toKey: "#test",
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result["channel"] != "#test" {
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) {
tserver := newTestServer(t)
token := tserver.createSession("parter")
tserver.sendCommand(
token,
map[string]any{
commandKey: joinCmd, toKey: "#test",
},
)
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: "PART", toKey: "#test",
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result["channel"] != "#test" {
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) {
tserver := newTestServer(t)
aliceToken := tserver.createSession("alice_msg")
bobToken := tserver.createSession("bob_msg")
tserver.sendCommand(aliceToken, map[string]any{
commandKey: joinCmd, toKey: "#test",
})
tserver.sendCommand(bobToken, map[string]any{
commandKey: joinCmd, toKey: "#test",
})
_, _ = tserver.pollMessages(aliceToken, 0)
_, bobLastID := tserver.pollMessages(bobToken, 0)
status, result := tserver.sendCommand(
aliceToken,
map[string]any{
commandKey: privmsgCmd,
toKey: "#test",
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) {
tserver := newTestServer(t)
token := tserver.createSession("nobody")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#test",
})
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: privmsgCmd, toKey: "#test",
})
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: "NICK",
bodyKey: []string{"newnick"},
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
if result["nick"] != "newnick" {
t.Fatalf(
"expected newnick, got %v", result["nick"],
)
}
}
func TestNickSameAsCurrent(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("same_nick")
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "NICK",
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: "NICK",
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: "NICK",
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: "NICK"},
)
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) {
tserver := newTestServer(t)
token := tserver.createSession("topic_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#topictest",
})
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: "TOPIC",
toKey: "#topictest",
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: "TOPIC",
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) {
tserver := newTestServer(t)
token := tserver.createSession("topicnobody")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#topictest",
})
_, lastID := tserver.pollMessages(token, 0)
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: "TOPIC", toKey: "#topictest",
})
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: "TOPIC",
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) {
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: "#secret",
})
tserver.sendCommand(aliceToken, map[string]any{
commandKey: privmsgCmd,
toKey: "#secret",
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) {
tserver := newTestServer(t)
aliceToken := tserver.createSession("lp_alice")
bobToken := tserver.createSession("lp_bob")
tserver.sendCommand(aliceToken, map[string]any{
commandKey: joinCmd, toKey: "#longpoll",
})
tserver.sendCommand(bobToken, map[string]any{
commandKey: joinCmd, toKey: "#longpoll",
})
_, 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: "#longpoll",
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) {
tserver := newTestServer(t)
token := tserver.createSession("ephemeral")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#ephemeral",
})
tserver.sendCommand(token, map[string]any{
commandKey: "PART", toKey: "#ephemeral",
})
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 _, channel := range channels {
if channel["name"] == "#ephemeral" {
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{"nick": 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: "PASS",
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: "PASS",
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: "PASS"},
)
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: "PASS",
bodyKey: []string{"password123"},
})
// Login with nick + password.
loginBody, err := json.Marshal(map[string]string{
"nick": "loginuser", "password": "password123",
})
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["nick"] != "loginuser" {
t.Fatalf(
"expected loginuser, got %v",
state["nick"],
)
}
}
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: "PASS",
bodyKey: []string{"correctpass1"},
})
postJSONExpectStatus(
t, tserver, "/api/v1/login",
map[string]string{
"nick": "wrongpwuser",
"password": "wrongpass12",
},
http.StatusUnauthorized,
)
}
func TestLoginNonexistentUser(t *testing.T) {
tserver := newTestServer(t)
postJSONExpectStatus(
t, tserver, "/api/v1/login",
map[string]string{
"nick": "ghostuser",
"password": "password123",
},
http.StatusUnauthorized,
)
}
func TestSessionCookie(t *testing.T) {
tserver := newTestServer(t)
body, err := json.Marshal(
map[string]string{"nick": "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")
}
// 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["nick"] != "anon_user" {
t.Fatalf(
"expected anon_user, got %v",
state["nick"],
)
}
}
// 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: "WHOIS",
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{
"nick": 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) {
tserver := newTestServer(t)
whoToken := tserver.createSessionWithUsername(
"whouser", "whoident",
)
tserver.sendCommand(whoToken, map[string]any{
commandKey: joinCmd, toKey: "#whotest",
})
queryToken := tserver.createSession("whoquerier")
tserver.sendCommand(queryToken, map[string]any{
commandKey: joinCmd, toKey: "#whotest",
})
_, lastID := tserver.pollMessages(queryToken, 0)
tserver.sendCommand(queryToken, map[string]any{
commandKey: "WHO",
toKey: "#whotest",
})
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: "WHOIS",
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{
"nick": "nosuchuser",
"password": "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{
"nick": "nosuchuser", "password": "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: "PASS",
bodyKey: []string{"password123"},
})
// A single login should succeed without rate limiting.
loginBody, err := json.Marshal(map[string]string{
"nick": "normaluser", "password": "password123",
})
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: "NICK",
bodyKey: []string{"nick_a_new"},
})
msgs, _ := tserver.pollMessages(bobToken, lastID)
if !findMessage(msgs, "NICK", "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) {
tserver := newTestServer(t)
token := tserver.createSession("hcquery_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hcquery",
})
// Set hashcash bits.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcquery",
bodyKey: []string{"+H", "5"},
})
_, lastID := tserver.pollMessages(token, 0)
// Query mode — should show +nH.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcquery",
})
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) {
tserver := newTestServer(t)
token := tserver.createSession("hcclear_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hcclear",
})
// Set hashcash bits.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcclear",
bodyKey: []string{"+H", "5"},
})
// Clear hashcash bits.
status, _ := tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcclear",
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: "#hcclear",
bodyKey: []string{"test message"},
},
)
if status != http.StatusOK {
t.Fatalf(
"expected 200, got %d: %v", status, result,
)
}
}
func TestChannelHashcashRejectNoStamp(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("hcreject_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hcreject",
})
// Set hashcash requirement.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcreject",
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: "#hcreject",
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) {
tserver := newTestServer(t)
token := tserver.createSession("hcaccept_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hcaccept",
})
// Set hashcash requirement (2 bits = fast to mint).
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcaccept",
bodyKey: []string{"+H", "2"},
})
_, lastID := tserver.pollMessages(token, 0)
// Mint a valid hashcash stamp.
msgBody, marshalErr := json.Marshal(
[]string{"hello world"},
)
if marshalErr != nil {
t.Fatal(marshalErr)
}
stamp := mintTestChannelHashcash(
t, 2, "#hcaccept", msgBody,
)
// Send message with valid hashcash.
status, result := tserver.sendCommand(
token,
map[string]any{
commandKey: privmsgCmd,
toKey: "#hcaccept",
bodyKey: []string{"hello world"},
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) {
tserver := newTestServer(t)
token := tserver.createSession("hcreplay_user")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#hcreplay",
})
// Set hashcash requirement.
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#hcreplay",
bodyKey: []string{"+H", "2"},
})
_, _ = tserver.pollMessages(token, 0)
// Mint and send once — should succeed.
msgBody, marshalErr := json.Marshal(
[]string{"unique msg"},
)
if marshalErr != nil {
t.Fatal(marshalErr)
}
stamp := mintTestChannelHashcash(
t, 2, "#hcreplay", msgBody,
)
status, _ := tserver.sendCommand(
token,
map[string]any{
commandKey: privmsgCmd,
toKey: "#hcreplay",
bodyKey: []string{"unique msg"},
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: "#hcreplay",
bodyKey: []string{"unique msg"},
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: "NAMES",
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: "OPER",
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: "OPER",
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: "OPER",
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: "OPER",
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: "WHOIS",
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: "WHOIS",
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: "OPER",
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: "WHOIS",
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: "OPER",
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: "NAMES", 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) {
tserver := newTestServer(t)
creatorToken := tserver.createSession("op_first")
joinerToken := tserver.createSession("op_second")
tserver.sendCommand(creatorToken, map[string]any{
commandKey: joinCmd, toKey: "#optest2",
})
tserver.sendCommand(joinerToken, map[string]any{
commandKey: joinCmd, toKey: "#optest2",
})
_, lastID := tserver.pollMessages(joinerToken, 0)
tserver.sendCommand(joinerToken, map[string]any{
commandKey: "NAMES", toKey: "#optest2",
})
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) {
tserver := newTestServer(t)
opToken := tserver.createSession("granter")
targetToken := tserver.createSession("grantee")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: "#modeop",
})
tserver.sendCommand(targetToken, map[string]any{
commandKey: joinCmd, toKey: "#modeop",
})
_, lastID := tserver.pollMessages(targetToken, 0)
// granter (creator = +o) grants +o to grantee.
status, _ := tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: "#modeop",
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: "NAMES", toKey: "#modeop",
})
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) {
tserver := newTestServer(t)
opToken := tserver.createSession("revoker")
targetToken := tserver.createSession("revokee")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: "#revoke",
})
tserver.sendCommand(targetToken, map[string]any{
commandKey: joinCmd, toKey: "#revoke",
})
// Grant +o, then revoke it.
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: "#revoke",
bodyKey: []string{"+o", "revokee"},
})
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: "#revoke",
bodyKey: []string{"-o", "revokee"},
})
// Check NAMES — revokee should NOT have @.
_, lastID := tserver.pollMessages(opToken, 0)
tserver.sendCommand(opToken, map[string]any{
commandKey: "NAMES", toKey: "#revoke",
})
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) {
tserver := newTestServer(t)
opToken := tserver.createSession("voicer")
targetToken := tserver.createSession("voiced")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: "#voice",
})
tserver.sendCommand(targetToken, map[string]any{
commandKey: joinCmd, toKey: "#voice",
})
// Grant +v.
status, _ := tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: "#voice",
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: "NAMES", toKey: "#voice",
})
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: "#voice",
bodyKey: []string{"-v", "voiced"},
})
_, lastID = tserver.pollMessages(opToken, 0)
tserver.sendCommand(opToken, map[string]any{
commandKey: "NAMES", toKey: "#voice",
})
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) {
tserver := newTestServer(t)
opToken := tserver.createSession("realop")
nonOpToken := tserver.createSession("nonop")
targetToken := tserver.createSession("target3")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: "#noperm",
})
tserver.sendCommand(nonOpToken, map[string]any{
commandKey: joinCmd, toKey: "#noperm",
})
tserver.sendCommand(targetToken, map[string]any{
commandKey: joinCmd, toKey: "#noperm",
})
_, lastID := tserver.pollMessages(nonOpToken, 0)
// Non-op tries +o.
tserver.sendCommand(nonOpToken, map[string]any{
commandKey: modeCmd,
toKey: "#noperm",
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: "#noperm",
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) {
tserver := newTestServer(t)
opToken := tserver.createSession("modop")
regularToken := tserver.createSession("regular2")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: "#moderated",
})
tserver.sendCommand(regularToken, map[string]any{
commandKey: joinCmd, toKey: "#moderated",
})
// Set +m.
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: "#moderated",
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: "#moderated",
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) {
tserver := newTestServer(t)
opToken := tserver.createSession("modop2")
observerToken := tserver.createSession("observer2")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: "#modop",
})
tserver.sendCommand(observerToken, map[string]any{
commandKey: joinCmd, toKey: "#modop",
})
// Set +m.
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: "#modop",
bodyKey: []string{"+m"},
})
_, lastID := tserver.pollMessages(observerToken, 0)
// Op sends — should work.
status, result := tserver.sendCommand(opToken, map[string]any{
commandKey: privmsgCmd,
toKey: "#modop",
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) {
tserver := newTestServer(t)
opToken := tserver.createSession("modop3")
voicedToken := tserver.createSession("modvoiced")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: "#modvoice",
})
tserver.sendCommand(voicedToken, map[string]any{
commandKey: joinCmd, toKey: "#modvoice",
})
// Set +m and +v on voiced user.
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: "#modvoice",
bodyKey: []string{"+m"},
})
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: "#modvoice",
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: "#modvoice",
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) {
tserver := newTestServer(t)
opToken := tserver.createSession("topicop")
regularToken := tserver.createSession("topicuser")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: "#tlock",
})
tserver.sendCommand(regularToken, map[string]any{
commandKey: joinCmd, toKey: "#tlock",
})
// +t is on by default. Non-op tries to set topic.
_, lastID := tserver.pollMessages(regularToken, 0)
tserver.sendCommand(regularToken, map[string]any{
commandKey: "TOPIC",
toKey: "#tlock",
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: "TOPIC",
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) {
tserver := newTestServer(t)
opToken := tserver.createSession("tloff_op")
regularToken := tserver.createSession("tloff_user")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: "#tloff",
})
tserver.sendCommand(regularToken, map[string]any{
commandKey: joinCmd, toKey: "#tloff",
})
// Disable +t.
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: "#tloff",
bodyKey: []string{"-t"},
})
// Now regular user sets topic — should succeed.
status, result := tserver.sendCommand(
regularToken, map[string]any{
commandKey: "TOPIC",
toKey: "#tloff",
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) {
tserver := newTestServer(t)
opToken := tserver.createSession("kicker")
targetToken := tserver.createSession("kicked")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: "#kicktest",
})
tserver.sendCommand(targetToken, map[string]any{
commandKey: joinCmd, toKey: "#kicktest",
})
_, lastID := tserver.pollMessages(targetToken, 0)
// Kick the target.
status, _ := tserver.sendCommand(opToken, map[string]any{
commandKey: kickCmd,
toKey: "#kicktest",
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: "#kicktest",
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) {
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: "#kickperm",
})
tserver.sendCommand(nonOpToken, map[string]any{
commandKey: joinCmd, toKey: "#kickperm",
})
tserver.sendCommand(targetToken, map[string]any{
commandKey: joinCmd, toKey: "#kickperm",
})
_, lastID := tserver.pollMessages(nonOpToken, 0)
// Non-op tries to kick.
tserver.sendCommand(nonOpToken, map[string]any{
commandKey: kickCmd,
toKey: "#kickperm",
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) {
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: "#kickbc",
})
tserver.sendCommand(targetToken, map[string]any{
commandKey: joinCmd, toKey: "#kickbc",
})
tserver.sendCommand(observerToken, map[string]any{
commandKey: joinCmd, toKey: "#kickbc",
})
_, lastID := tserver.pollMessages(observerToken, 0)
tserver.sendCommand(opToken, map[string]any{
commandKey: kickCmd,
toKey: "#kickbc",
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: "NOTICE",
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) {
tserver := newTestServer(t)
opToken := tserver.createSession("hcnotice_op")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: "#hcnotice",
})
// Set hashcash requirement.
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: "#hcnotice",
bodyKey: []string{"+H", "2"},
})
// Send NOTICE without hashcash — should succeed.
status, result := tserver.sendCommand(
opToken, map[string]any{
commandKey: "NOTICE",
toKey: "#hcnotice",
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) {
tserver := newTestServer(t)
opToken := tserver.createSession("modnotop")
regularToken := tserver.createSession("modnotice")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: "#modnotice",
})
tserver.sendCommand(regularToken, map[string]any{
commandKey: joinCmd, toKey: "#modnotice",
})
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: "#modnotice",
bodyKey: []string{"+m"},
})
_, lastID := tserver.pollMessages(regularToken, 0)
tserver.sendCommand(regularToken, map[string]any{
commandKey: "NOTICE",
toKey: "#modnotice",
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) {
tserver := newTestServer(t)
opToken := tserver.createSession("setmod_op")
regularToken := tserver.createSession("setmod_reg")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: "#modperm",
})
tserver.sendCommand(regularToken, map[string]any{
commandKey: joinCmd, toKey: "#modperm",
})
_, lastID := tserver.pollMessages(regularToken, 0)
tserver.sendCommand(regularToken, map[string]any{
commandKey: modeCmd,
toKey: "#modperm",
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)
found := false
for _, param := range params {
if param == "PREFIX=(ov)@+" {
found = true
break
}
}
if !found {
t.Fatalf(
"expected PREFIX=(ov)@+ in ISUPPORT, "+
"got %v",
params,
)
}
}
// TestModeQueryShowsModerated verifies MODE query shows
// +m when set.
func TestModeQueryShowsModerated(t *testing.T) {
tserver := newTestServer(t)
token := tserver.createSession("mquery")
tserver.sendCommand(token, map[string]any{
commandKey: joinCmd, toKey: "#mqtest",
})
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd,
toKey: "#mqtest",
bodyKey: []string{"+m"},
})
_, lastID := tserver.pollMessages(token, 0)
tserver.sendCommand(token, map[string]any{
commandKey: modeCmd, toKey: "#mqtest",
})
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) {
tserver := newTestServer(t)
opToken := tserver.createSession("kickdefop")
targetToken := tserver.createSession("kickdeftg")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: "#kickdef",
})
tserver.sendCommand(targetToken, map[string]any{
commandKey: joinCmd, toKey: "#kickdef",
})
_, lastID := tserver.pollMessages(targetToken, 0)
// Kick with only nick, no reason.
tserver.sendCommand(opToken, map[string]any{
commandKey: kickCmd,
toKey: "#kickdef",
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) {
tserver := newTestServer(t)
opToken := tserver.createSession("banop")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: "#bans",
})
// Add a ban.
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: "#bans",
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: "#bans",
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: "#bans",
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: "#bans",
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) {
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: "#banjoin",
})
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: "#banjoin",
bodyKey: []string{"+b", "banned2!*@*"},
})
// Banned user tries to join.
_, lastID := tserver.pollMessages(userToken, 0)
tserver.sendCommand(userToken, map[string]any{
commandKey: joinCmd, toKey: "#banjoin",
})
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) {
tserver := newTestServer(t)
opToken := tserver.createSession("banmsgop")
userToken := tserver.createSession("banmsgusr")
// Both join.
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: "#banmsg",
})
tserver.sendCommand(userToken, map[string]any{
commandKey: joinCmd, toKey: "#banmsg",
})
// Op bans the user.
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: "#banmsg",
bodyKey: []string{"+b", "banmsgusr!*@*"},
})
// User tries to send a message.
_, lastID := tserver.pollMessages(userToken, 0)
tserver.sendCommand(userToken, map[string]any{
commandKey: privmsgCmd,
toKey: "#banmsg",
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) {
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: "#invonly",
})
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: "#invonly",
bodyKey: []string{"+i"},
})
// User tries to join without invite.
_, lastID := tserver.pollMessages(userToken, 0)
tserver.sendCommand(userToken, map[string]any{
commandKey: joinCmd, toKey: "#invonly",
})
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", "#invonly"},
})
// User tries again — should succeed with invite.
_, result := tserver.sendCommand(userToken, map[string]any{
commandKey: joinCmd, toKey: "#invonly",
})
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) {
tserver := newTestServer(t)
opToken := tserver.createSession("secop")
outsiderToken := tserver.createSession("secout")
// Op creates secret channel.
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: "#secret",
})
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: "#secret",
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 == "#secret" {
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 == "#secret" {
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) {
tserver := newTestServer(t)
opToken := tserver.createSession("keyop")
userToken := tserver.createSession("keyusr")
// Op creates keyed channel.
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: "#keyed",
})
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: "#keyed",
bodyKey: []string{"+k", "mykey"},
})
// User tries without key.
_, lastID := tserver.pollMessages(userToken, 0)
tserver.sendCommand(userToken, map[string]any{
commandKey: joinCmd, toKey: "#keyed",
})
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: "#keyed",
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: "#keyed",
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) {
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: "#limited",
})
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: "#limited",
bodyKey: []string{"+l", "2"},
})
// User1 joins — should succeed (2 members now: op + user1).
_, result := tserver.sendCommand(user1Token, map[string]any{
commandKey: joinCmd, toKey: "#limited",
})
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: "#limited",
})
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) {
tserver := newTestServer(t)
opToken := tserver.createSession("modestrop")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: "#modestr",
})
// Set all tier 2 modes.
for _, modeChange := range [][]string{
{"+i"}, {"+s"}, {"+k", "pw"}, {"+l", "50"},
} {
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd,
toKey: "#modestr",
bodyKey: modeChange,
})
}
_, lastID := tserver.pollMessages(opToken, 0)
// Query mode.
tserver.sendCommand(opToken, map[string]any{
commandKey: modeCmd, toKey: "#modestr",
})
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) {
tserver := newTestServer(t)
opToken := tserver.createSession("modeopx")
userToken := tserver.createSession("modeusrx")
tserver.sendCommand(opToken, map[string]any{
commandKey: joinCmd, toKey: "#noperm",
})
tserver.sendCommand(userToken, map[string]any{
commandKey: joinCmd, toKey: "#noperm",
})
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: "#noperm",
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,
)
}
}
}