From 142d0f59196937fabb4b25b51ad2a37a0844fa71 Mon Sep 17 00:00:00 2001 From: user Date: Thu, 26 Mar 2026 21:56:36 -0700 Subject: [PATCH] feat: implement Tier 3 utility IRC commands Implement all 7 utility IRC commands from issue #87: User commands: - USERHOST: quick lookup of user@host for up to 5 nicks (RPL 302) - VERSION: server version string using globals.Version (RPL 351) - ADMIN: server admin contact info (RPL 256-259) - INFO: server software info text (RPL 371/374) - TIME: server local time in RFC format (RPL 391) Oper commands: - KILL: forcibly disconnect a user (requires is_oper), broadcasts QUIT to all shared channels, cleans up sessions - WALLOPS: broadcast message to all users with +w usermode (requires is_oper) Supporting changes: - Add is_wallops column to sessions table in 001_initial.sql - Add user mode +w tracking via MODE nick +w/-w - User mode queries now return actual modes (+o, +w) - MODE -o allows de-opering yourself; MODE +o rejected - MODE for other users returns ERR_USERSDONTMATCH (502) - Extract dispatch helpers to reduce dispatchCommand complexity Tests cover all commands including error cases, oper checks, user mode set/unset, KILL broadcast, WALLOPS delivery, and edge cases (self-kill, nonexistent users, missing params). closes #87 --- internal/db/queries.go | 129 ++++ internal/db/schema/001_initial.sql | 1 + internal/handlers/api.go | 82 ++- internal/handlers/utility.go | 727 +++++++++++++++++++++ internal/handlers/utility_test.go | 982 +++++++++++++++++++++++++++++ pkg/irc/commands.go | 49 +- 6 files changed, 1918 insertions(+), 52 deletions(-) create mode 100644 internal/handlers/utility.go create mode 100644 internal/handlers/utility_test.go diff --git a/internal/db/queries.go b/internal/db/queries.go index 9029337..b50f81a 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -2367,3 +2367,132 @@ func (database *Database) SetChannelUserLimit( return nil } + +// SetSessionWallops sets the wallops (+w) flag on a +// session. +func (database *Database) SetSessionWallops( + ctx context.Context, + sessionID int64, + enabled bool, +) error { + val := 0 + if enabled { + val = 1 + } + + _, err := database.conn.ExecContext( + ctx, + `UPDATE sessions SET is_wallops = ? WHERE id = ?`, + val, sessionID, + ) + if err != nil { + return fmt.Errorf("set session wallops: %w", err) + } + + return nil +} + +// IsSessionWallops returns whether the session has the +// wallops (+w) usermode set. +func (database *Database) IsSessionWallops( + ctx context.Context, + sessionID int64, +) (bool, error) { + var isWallops int + + err := database.conn.QueryRowContext( + ctx, + `SELECT is_wallops FROM sessions WHERE id = ?`, + sessionID, + ).Scan(&isWallops) + if err != nil { + return false, fmt.Errorf( + "check session wallops: %w", err, + ) + } + + return isWallops != 0, nil +} + +// GetWallopsSessionIDs returns all session IDs that have +// the wallops (+w) usermode set. +func (database *Database) GetWallopsSessionIDs( + ctx context.Context, +) ([]int64, error) { + rows, err := database.conn.QueryContext( + ctx, + `SELECT id FROM sessions WHERE is_wallops = 1`, + ) + if err != nil { + return nil, fmt.Errorf( + "get wallops sessions: %w", err, + ) + } + + defer func() { _ = rows.Close() }() + + var ids []int64 + + for rows.Next() { + var sessionID int64 + if scanErr := rows.Scan(&sessionID); scanErr != nil { + return nil, fmt.Errorf( + "scan wallops session: %w", scanErr, + ) + } + + ids = append(ids, sessionID) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf( + "iterate wallops sessions: %w", err, + ) + } + + return ids, nil +} + +// UserhostInfo holds the data needed for RPL_USERHOST. +type UserhostInfo struct { + Nick string + Username string + Hostname string + IsOper bool + AwayMessage string +} + +// GetUserhostInfo returns USERHOST info for the given +// nicks. Only nicks that exist are returned. +func (database *Database) GetUserhostInfo( + ctx context.Context, + nicks []string, +) ([]UserhostInfo, error) { + if len(nicks) == 0 { + return nil, nil + } + + results := make([]UserhostInfo, 0, len(nicks)) + + for _, nick := range nicks { + var info UserhostInfo + + err := database.conn.QueryRowContext( + ctx, + `SELECT nick, username, hostname, + is_oper, away_message + FROM sessions WHERE nick = ?`, + nick, + ).Scan( + &info.Nick, &info.Username, &info.Hostname, + &info.IsOper, &info.AwayMessage, + ) + if err != nil { + continue // nick not found, skip + } + + results = append(results, info) + } + + return results, nil +} diff --git a/internal/db/schema/001_initial.sql b/internal/db/schema/001_initial.sql index a29bdaa..d226e67 100644 --- a/internal/db/schema/001_initial.sql +++ b/internal/db/schema/001_initial.sql @@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS sessions ( hostname TEXT NOT NULL DEFAULT '', ip TEXT NOT NULL DEFAULT '', is_oper INTEGER NOT NULL DEFAULT 0, + is_wallops INTEGER NOT NULL DEFAULT 0, password_hash TEXT NOT NULL DEFAULT '', signing_key TEXT NOT NULL DEFAULT '', away_message TEXT NOT NULL DEFAULT '', diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 34f1987..623329d 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -1014,10 +1014,12 @@ func (hdlr *Handlers) dispatchCommand( bodyLines func() []string, ) { switch command { - case irc.CmdAway: - hdlr.handleAway( + case irc.CmdAway, irc.CmdNick, + irc.CmdPass, irc.CmdInvite: + hdlr.dispatchBodyOnlyCommand( writer, request, - sessionID, clientID, nick, bodyLines, + sessionID, clientID, nick, + command, bodyLines, ) case irc.CmdPrivmsg, irc.CmdNotice: hdlr.handlePrivmsg( @@ -1036,27 +1038,12 @@ func (hdlr *Handlers) dispatchCommand( writer, request, sessionID, clientID, nick, target, body, ) - case irc.CmdNick: - hdlr.handleNick( - writer, request, - sessionID, clientID, nick, bodyLines, - ) - case irc.CmdPass: - hdlr.handlePass( - writer, request, - sessionID, clientID, nick, bodyLines, - ) case irc.CmdTopic: hdlr.handleTopic( writer, request, sessionID, clientID, nick, target, body, bodyLines, ) - case irc.CmdInvite: - hdlr.handleInvite( - writer, request, - sessionID, clientID, nick, bodyLines, - ) case irc.CmdKick: hdlr.handleKick( writer, request, @@ -1067,12 +1054,15 @@ func (hdlr *Handlers) dispatchCommand( hdlr.handleQuit( writer, request, sessionID, nick, body, ) - case irc.CmdOper: - hdlr.handleOper( + case irc.CmdOper, irc.CmdKill, irc.CmdWallops: + hdlr.dispatchOperCommand( writer, request, - sessionID, clientID, nick, bodyLines, + sessionID, clientID, nick, + command, bodyLines, ) - case irc.CmdMotd, irc.CmdPing: + case irc.CmdMotd, irc.CmdPing, + irc.CmdVersion, irc.CmdAdmin, + irc.CmdInfo, irc.CmdTime: hdlr.dispatchInfoCommand( writer, request, sessionID, clientID, nick, @@ -1127,6 +1117,11 @@ func (hdlr *Handlers) dispatchQueryCommand( writer, request, sessionID, clientID, nick, ) + case irc.CmdUserhost: + hdlr.handleUserhost( + writer, request, + sessionID, clientID, nick, bodyLines, + ) default: hdlr.enqueueNumeric( request.Context(), clientID, @@ -2416,7 +2411,8 @@ func (hdlr *Handlers) executeTopic( } // dispatchInfoCommand handles informational IRC commands -// that produce server-side numerics (MOTD, PING). +// that produce server-side numerics (MOTD, PING, +// VERSION, ADMIN, INFO, TIME). func (hdlr *Handlers) dispatchInfoCommand( writer http.ResponseWriter, request *http.Request, @@ -2442,6 +2438,34 @@ func (hdlr *Handlers) dispatchInfoCommand( }, http.StatusOK) + return + case irc.CmdVersion: + hdlr.handleVersion( + writer, request, + sessionID, clientID, nick, + ) + + return + case irc.CmdAdmin: + hdlr.handleAdmin( + writer, request, + sessionID, clientID, nick, + ) + + return + case irc.CmdInfo: + hdlr.handleInfo( + writer, request, + sessionID, clientID, nick, + ) + + return + case irc.CmdTime: + hdlr.handleTime( + writer, request, + sessionID, clientID, nick, + ) + return } @@ -2532,15 +2556,11 @@ func (hdlr *Handlers) handleMode( channel := target if !strings.HasPrefix(channel, "#") { - // User mode query — return empty modes. - hdlr.enqueueNumeric( - request.Context(), clientID, - irc.RplUmodeIs, nick, nil, "+", + hdlr.handleUserMode( + writer, request, + sessionID, clientID, nick, target, + bodyLines, ) - hdlr.broker.Notify(sessionID) - hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, - http.StatusOK) return } diff --git a/internal/handlers/utility.go b/internal/handlers/utility.go new file mode 100644 index 0000000..8c5d99c --- /dev/null +++ b/internal/handlers/utility.go @@ -0,0 +1,727 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "git.eeqj.de/sneak/neoirc/internal/db" + "git.eeqj.de/sneak/neoirc/pkg/irc" +) + +// maxUserhostNicks is the maximum number of nicks allowed +// in a single USERHOST query (RFC 2812). +const maxUserhostNicks = 5 + +// dispatchBodyOnlyCommand routes commands that take +// (writer, request, sessionID, clientID, nick, bodyLines). +func (hdlr *Handlers) dispatchBodyOnlyCommand( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, command string, + bodyLines func() []string, +) { + switch command { + case irc.CmdAway: + hdlr.handleAway( + writer, request, + sessionID, clientID, nick, bodyLines, + ) + case irc.CmdNick: + hdlr.handleNick( + writer, request, + sessionID, clientID, nick, bodyLines, + ) + case irc.CmdPass: + hdlr.handlePass( + writer, request, + sessionID, clientID, nick, bodyLines, + ) + case irc.CmdInvite: + hdlr.handleInvite( + writer, request, + sessionID, clientID, nick, bodyLines, + ) + } +} + +// dispatchOperCommand routes oper-related commands (OPER, +// KILL, WALLOPS) to their handlers. +func (hdlr *Handlers) dispatchOperCommand( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, command string, + bodyLines func() []string, +) { + switch command { + case irc.CmdOper: + hdlr.handleOper( + writer, request, + sessionID, clientID, nick, bodyLines, + ) + case irc.CmdKill: + hdlr.handleKill( + writer, request, + sessionID, clientID, nick, bodyLines, + ) + case irc.CmdWallops: + hdlr.handleWallops( + writer, request, + sessionID, clientID, nick, bodyLines, + ) + } +} + +// handleUserhost handles the USERHOST command. +// Returns user@host info for up to 5 nicks. +func (hdlr *Handlers) handleUserhost( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, + bodyLines func() []string, +) { + ctx := request.Context() + + lines := bodyLines() + if len(lines) == 0 { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNeedMoreParams, nick, + []string{irc.CmdUserhost}, + "Not enough parameters", + ) + + return + } + + // Limit to 5 nicks per RFC 2812. + nicks := lines + if len(nicks) > maxUserhostNicks { + nicks = nicks[:maxUserhostNicks] + } + + infos, err := hdlr.params.Database.GetUserhostInfo( + ctx, nicks, + ) + if err != nil { + hdlr.log.Error( + "userhost query failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return + } + + replyStr := hdlr.buildUserhostReply(infos) + + hdlr.enqueueNumeric( + ctx, clientID, irc.RplUserHost, nick, nil, + replyStr, + ) + + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// buildUserhostReply builds the RPL_USERHOST reply +// string per RFC 2812. +func (hdlr *Handlers) buildUserhostReply( + infos []db.UserhostInfo, +) string { + replies := make([]string, 0, len(infos)) + + for idx := range infos { + info := &infos[idx] + + username := info.Username + if username == "" { + username = info.Nick + } + + hostname := info.Hostname + if hostname == "" { + hostname = hdlr.serverName() + } + + operStar := "" + if info.IsOper { + operStar = "*" + } + + awayPrefix := "+" + if info.AwayMessage != "" { + awayPrefix = "-" + } + + replies = append(replies, + info.Nick+operStar+"="+ + awayPrefix+username+"@"+hostname, + ) + } + + return strings.Join(replies, " ") +} + +// handleVersion handles the VERSION command. +// Returns the server version string. +func (hdlr *Handlers) handleVersion( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, +) { + ctx := request.Context() + srvName := hdlr.serverName() + version := hdlr.serverVersion() + + // 351 RPL_VERSION + hdlr.enqueueNumeric( + ctx, clientID, irc.RplVersion, nick, + []string{version + ".", srvName}, + "", + ) + + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// handleAdmin handles the ADMIN command. +// Returns server admin contact info. +func (hdlr *Handlers) handleAdmin( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, +) { + ctx := request.Context() + srvName := hdlr.serverName() + + // 256 RPL_ADMINME + hdlr.enqueueNumeric( + ctx, clientID, irc.RplAdminMe, nick, + []string{srvName}, + "Administrative info", + ) + + // 257 RPL_ADMINLOC1 + hdlr.enqueueNumeric( + ctx, clientID, irc.RplAdminLoc1, nick, nil, + "neoirc server", + ) + + // 258 RPL_ADMINLOC2 + hdlr.enqueueNumeric( + ctx, clientID, irc.RplAdminLoc2, nick, nil, + "IRC over HTTP", + ) + + // 259 RPL_ADMINEMAIL + hdlr.enqueueNumeric( + ctx, clientID, irc.RplAdminEmail, nick, nil, + "admin@"+srvName, + ) + + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// handleInfo handles the INFO command. +// Returns server software information. +func (hdlr *Handlers) handleInfo( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, +) { + ctx := request.Context() + version := hdlr.serverVersion() + + infoLines := []string{ + "neoirc — IRC semantics over HTTP", + "Version: " + version, + "Written in Go", + "Started: " + + hdlr.params.Globals.StartTime. + Format(time.RFC1123), + } + + for _, line := range infoLines { + // 371 RPL_INFO + hdlr.enqueueNumeric( + ctx, clientID, irc.RplInfo, nick, nil, + line, + ) + } + + // 374 RPL_ENDOFINFO + hdlr.enqueueNumeric( + ctx, clientID, irc.RplEndOfInfo, nick, nil, + "End of /INFO list", + ) + + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// handleTime handles the TIME command. +// Returns the server's local time in RFC format. +func (hdlr *Handlers) handleTime( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, +) { + ctx := request.Context() + srvName := hdlr.serverName() + + // 391 RPL_TIME + hdlr.enqueueNumeric( + ctx, clientID, irc.RplTime, nick, + []string{srvName}, + time.Now().Format(time.RFC1123), + ) + + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// handleKill handles the KILL command. +// Forcibly disconnects a user (oper only). +func (hdlr *Handlers) handleKill( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, + bodyLines func() []string, +) { + ctx := request.Context() + + // Check oper status. + isOper, err := hdlr.params.Database.IsSessionOper( + ctx, sessionID, + ) + if err != nil || !isOper { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNoPrivileges, nick, nil, + "Permission Denied- You're not an IRC operator", + ) + + return + } + + lines := bodyLines() + if len(lines) == 0 { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNeedMoreParams, nick, + []string{irc.CmdKill}, + "Not enough parameters", + ) + + return + } + + targetNick := strings.TrimSpace(lines[0]) + if targetNick == "" { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNeedMoreParams, nick, + []string{irc.CmdKill}, + "Not enough parameters", + ) + + return + } + + reason := "KILLed" + if len(lines) > 1 { + reason = lines[1] + } + + targetSID, lookupErr := hdlr.params.Database. + GetSessionByNick(ctx, targetNick) + if lookupErr != nil { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNoSuchNick, nick, + []string{targetNick}, + "No such nick/channel", + ) + + return + } + + // Do not allow killing yourself. + if targetSID == sessionID { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrCantKillServer, nick, nil, + "You cannot KILL yourself", + ) + + return + } + + hdlr.executeKillUser( + request, targetSID, targetNick, nick, reason, + ) + + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// executeKillUser forcibly disconnects a user: broadcasts +// QUIT to their channels, parts all channels, and deletes +// the session. +func (hdlr *Handlers) executeKillUser( + request *http.Request, + targetSID int64, + targetNick, killerNick, reason string, +) { + ctx := request.Context() + + quitMsg := "Killed (" + killerNick + " (" + reason + "))" + + quitBody, err := json.Marshal([]string{quitMsg}) + if err != nil { + hdlr.log.Error( + "marshal kill quit body", "error", err, + ) + + return + } + + channels, _ := hdlr.params.Database. + GetSessionChannels(ctx, targetSID) + + notified := map[int64]bool{} + + var dbID int64 + + if len(channels) > 0 { + dbID, _, _ = hdlr.params.Database.InsertMessage( + ctx, irc.CmdQuit, targetNick, "", + nil, json.RawMessage(quitBody), nil, + ) + } + + for _, chanInfo := range channels { + memberIDs, _ := hdlr.params.Database. + GetChannelMemberIDs(ctx, chanInfo.ID) + + for _, mid := range memberIDs { + if mid != targetSID && !notified[mid] { + notified[mid] = true + + _ = hdlr.params.Database.EnqueueToSession( + ctx, mid, dbID, + ) + + hdlr.broker.Notify(mid) + } + } + + _ = hdlr.params.Database.PartChannel( + ctx, chanInfo.ID, targetSID, + ) + + _ = hdlr.params.Database.DeleteChannelIfEmpty( + ctx, chanInfo.ID, + ) + } + + _ = hdlr.params.Database.DeleteSession( + ctx, targetSID, + ) +} + +// handleWallops handles the WALLOPS command. +// Broadcasts a message to all users with +w usermode +// (oper only). +func (hdlr *Handlers) handleWallops( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick string, + bodyLines func() []string, +) { + ctx := request.Context() + + // Check oper status. + isOper, err := hdlr.params.Database.IsSessionOper( + ctx, sessionID, + ) + if err != nil || !isOper { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNoPrivileges, nick, nil, + "Permission Denied- You're not an IRC operator", + ) + + return + } + + lines := bodyLines() + if len(lines) == 0 { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrNeedMoreParams, nick, + []string{irc.CmdWallops}, + "Not enough parameters", + ) + + return + } + + message := strings.Join(lines, " ") + + wallopsSIDs, err := hdlr.params.Database. + GetWallopsSessionIDs(ctx) + if err != nil { + hdlr.log.Error( + "get wallops sessions failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return + } + + if len(wallopsSIDs) > 0 { + body, mErr := json.Marshal([]string{message}) + if mErr != nil { + hdlr.log.Error( + "marshal wallops body", "error", mErr, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return + } + + _ = hdlr.fanOutSilent( + request, irc.CmdWallops, nick, "*", + json.RawMessage(body), wallopsSIDs, + ) + } + + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// handleUserMode handles user mode queries and changes +// (e.g., MODE nick, MODE nick +w). +func (hdlr *Handlers) handleUserMode( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, target string, + bodyLines func() []string, +) { + ctx := request.Context() + + lines := bodyLines() + + // Mode change requested. + if len(lines) > 0 { + // Users can only change their own modes. + if target != nick && target != "" { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrUsersDoNotMatch, nick, nil, + "Can't change mode for other users", + ) + + return + } + + hdlr.applyUserModeChange( + writer, request, + sessionID, clientID, nick, lines[0], + ) + + return + } + + // Mode query — build the current mode string. + modeStr := hdlr.buildUserModeString(ctx, sessionID) + + hdlr.enqueueNumeric( + ctx, clientID, irc.RplUmodeIs, nick, nil, + modeStr, + ) + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// buildUserModeString constructs the mode string for a +// user (e.g., "+ow" for oper+wallops). +func (hdlr *Handlers) buildUserModeString( + ctx context.Context, + sessionID int64, +) string { + modes := "+" + + isOper, err := hdlr.params.Database.IsSessionOper( + ctx, sessionID, + ) + if err == nil && isOper { + modes += "o" + } + + isWallops, err := hdlr.params.Database.IsSessionWallops( + ctx, sessionID, + ) + if err == nil && isWallops { + modes += "w" + } + + return modes +} + +// applyUserModeChange applies a user mode change string +// (e.g., "+w", "-w"). +func (hdlr *Handlers) applyUserModeChange( + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, modeStr string, +) { + ctx := request.Context() + + if len(modeStr) < 2 { //nolint:mnd // +/- and mode char + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrUmodeUnknownFlag, nick, nil, + "Unknown MODE flag", + ) + + return + } + + adding := modeStr[0] == '+' + modeChar := modeStr[1:] + + applied, err := hdlr.applyModeChar( + ctx, writer, request, + sessionID, clientID, nick, + modeChar, adding, + ) + if err != nil || !applied { + return + } + + newModes := hdlr.buildUserModeString(ctx, sessionID) + + hdlr.enqueueNumeric( + ctx, clientID, irc.RplUmodeIs, nick, nil, + newModes, + ) + + hdlr.broker.Notify(sessionID) + hdlr.respondJSON(writer, request, + map[string]string{"status": "ok"}, + http.StatusOK) +} + +// applyModeChar applies a single user mode character. +// Returns (applied, error). +func (hdlr *Handlers) applyModeChar( + ctx context.Context, + writer http.ResponseWriter, + request *http.Request, + sessionID, clientID int64, + nick, modeChar string, + adding bool, +) (bool, error) { + switch modeChar { + case "w": + err := hdlr.params.Database.SetSessionWallops( + ctx, sessionID, adding, + ) + if err != nil { + hdlr.log.Error( + "set wallops mode failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return false, fmt.Errorf( + "set wallops: %w", err, + ) + } + case "o": + // +o cannot be set via MODE, only via OPER. + if adding { + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrUmodeUnknownFlag, nick, nil, + "Unknown MODE flag", + ) + + return false, nil + } + + err := hdlr.params.Database.SetSessionOper( + ctx, sessionID, false, + ) + if err != nil { + hdlr.log.Error( + "clear oper mode failed", "error", err, + ) + hdlr.respondError( + writer, request, + "internal error", + http.StatusInternalServerError, + ) + + return false, fmt.Errorf( + "clear oper: %w", err, + ) + } + default: + hdlr.respondIRCError( + writer, request, clientID, sessionID, + irc.ErrUmodeUnknownFlag, nick, nil, + "Unknown MODE flag", + ) + + return false, nil + } + + return true, nil +} diff --git a/internal/handlers/utility_test.go b/internal/handlers/utility_test.go new file mode 100644 index 0000000..2367882 --- /dev/null +++ b/internal/handlers/utility_test.go @@ -0,0 +1,982 @@ +// Tests for Tier 3 utility IRC commands: USERHOST, +// VERSION, ADMIN, INFO, TIME, KILL, WALLOPS. +// +//nolint:paralleltest +package handlers_test + +import ( + "strings" + "testing" +) + +// --- USERHOST --- + +func TestUserhostSingleNick(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("alice") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "USERHOST", + bodyKey: []string{"alice"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 302 RPL_USERHOST. + msg := findNumericWithParams(msgs, "302") + if msg == nil { + t.Fatalf( + "expected RPL_USERHOST (302), got %v", + msgs, + ) + } + + // Body should contain "alice" with the + // nick=+user@host format. + body := getNumericBody(msg) + if !strings.Contains(body, "alice") { + t.Fatalf( + "expected body to contain 'alice', got %q", + body, + ) + } + + // '+' means not away. + if !strings.Contains(body, "=+") { + t.Fatalf( + "expected not-away prefix '=+', got %q", + body, + ) + } +} + +func TestUserhostMultipleNicks(t *testing.T) { + tserver := newTestServer(t) + + token1 := tserver.createSession("bob") + token2 := tserver.createSession("carol") + + _ = token2 + + _, lastID := tserver.pollMessages(token1, 0) + + tserver.sendCommand(token1, map[string]any{ + commandKey: "USERHOST", + bodyKey: []string{"bob", "carol"}, + }) + + msgs, _ := tserver.pollMessages(token1, lastID) + + msg := findNumericWithParams(msgs, "302") + if msg == nil { + t.Fatalf( + "expected RPL_USERHOST (302), got %v", + msgs, + ) + } + + body := getNumericBody(msg) + if !strings.Contains(body, "bob") { + t.Fatalf( + "expected body to contain 'bob', got %q", + body, + ) + } + + if !strings.Contains(body, "carol") { + t.Fatalf( + "expected body to contain 'carol', got %q", + body, + ) + } +} + +func TestUserhostNonexistentNick(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("dave") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "USERHOST", + bodyKey: []string{"nobody"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Should still get 302 but with empty body. + msg := findNumericWithParams(msgs, "302") + if msg == nil { + t.Fatalf( + "expected RPL_USERHOST (302), got %v", + msgs, + ) + } +} + +func TestUserhostNoParams(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("eve") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "USERHOST", + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 461 ERR_NEEDMOREPARAMS. + if !findNumeric(msgs, "461") { + t.Fatalf( + "expected ERR_NEEDMOREPARAMS (461), got %v", + msgs, + ) + } +} + +func TestUserhostShowsOper(t *testing.T) { + tserver := newTestServerWithOper(t) + + token := tserver.createSession("opernick") + _, lastID := tserver.pollMessages(token, 0) + + // Authenticate as oper. + tserver.sendCommand(token, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, testOperPassword}, + }) + + _, lastID = tserver.pollMessages(token, lastID) + + // USERHOST should show '*' for oper. + tserver.sendCommand(token, map[string]any{ + commandKey: "USERHOST", + bodyKey: []string{"opernick"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + msg := findNumericWithParams(msgs, "302") + if msg == nil { + t.Fatalf( + "expected RPL_USERHOST (302), got %v", + msgs, + ) + } + + body := getNumericBody(msg) + if !strings.Contains(body, "opernick*=") { + t.Fatalf( + "expected oper '*' in reply, got %q", + body, + ) + } +} + +func TestUserhostShowsAway(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("awaynick") + _, lastID := tserver.pollMessages(token, 0) + + // Set away. + tserver.sendCommand(token, map[string]any{ + commandKey: "AWAY", + bodyKey: []string{"gone fishing"}, + }) + + _, lastID = tserver.pollMessages(token, lastID) + + // USERHOST should show '-' for away. + tserver.sendCommand(token, map[string]any{ + commandKey: "USERHOST", + bodyKey: []string{"awaynick"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + msg := findNumericWithParams(msgs, "302") + if msg == nil { + t.Fatalf( + "expected RPL_USERHOST (302), got %v", + msgs, + ) + } + + body := getNumericBody(msg) + if !strings.Contains(body, "=-") { + t.Fatalf( + "expected away prefix '=-' in reply, got %q", + body, + ) + } +} + +// --- VERSION --- + +func TestVersion(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("frank") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "VERSION", + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 351 RPL_VERSION. + msg := findNumericWithParams(msgs, "351") + if msg == nil { + t.Fatalf( + "expected RPL_VERSION (351), got %v", + msgs, + ) + } + + params := getNumericParams(msg) + if len(params) == 0 { + t.Fatal("expected VERSION params, got none") + } + + // First param should contain version string. + if !strings.Contains(params[0], "test") { + t.Fatalf( + "expected version to contain 'test', got %q", + params[0], + ) + } +} + +// --- ADMIN --- + +func TestAdmin(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("grace") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "ADMIN", + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 256 RPL_ADMINME. + if !findNumeric(msgs, "256") { + t.Fatalf( + "expected RPL_ADMINME (256), got %v", + msgs, + ) + } + + // Expect 257 RPL_ADMINLOC1. + if !findNumeric(msgs, "257") { + t.Fatalf( + "expected RPL_ADMINLOC1 (257), got %v", + msgs, + ) + } + + // Expect 258 RPL_ADMINLOC2. + if !findNumeric(msgs, "258") { + t.Fatalf( + "expected RPL_ADMINLOC2 (258), got %v", + msgs, + ) + } + + // Expect 259 RPL_ADMINEMAIL. + if !findNumeric(msgs, "259") { + t.Fatalf( + "expected RPL_ADMINEMAIL (259), got %v", + msgs, + ) + } +} + +// --- INFO --- + +func TestInfo(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("hank") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "INFO", + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 371 RPL_INFO (at least one). + if !findNumeric(msgs, "371") { + t.Fatalf( + "expected RPL_INFO (371), got %v", + msgs, + ) + } + + // Expect 374 RPL_ENDOFINFO. + if !findNumeric(msgs, "374") { + t.Fatalf( + "expected RPL_ENDOFINFO (374), got %v", + msgs, + ) + } +} + +// --- TIME --- + +func TestTime(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("iris") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "TIME", + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 391 RPL_TIME. + msg := findNumericWithParams(msgs, "391") + if msg == nil { + t.Fatalf( + "expected RPL_TIME (391), got %v", + msgs, + ) + } +} + +// --- KILL --- + +func TestKillSuccess(t *testing.T) { + tserver := newTestServerWithOper(t) + + // Create the victim first. + victimToken := tserver.createSession("victim") + _ = victimToken + + // Create oper user. + operToken := tserver.createSession("killer") + _, lastID := tserver.pollMessages(operToken, 0) + + // Authenticate as oper. + tserver.sendCommand(operToken, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, testOperPassword}, + }) + + _, lastID = tserver.pollMessages(operToken, lastID) + + // Kill the victim. + status, result := tserver.sendCommand( + operToken, map[string]any{ + commandKey: "KILL", + bodyKey: []string{"victim", "go away"}, + }, + ) + + if status != 200 { + t.Fatalf("expected 200, got %d: %v", status, result) + } + + resultStatus, _ := result[statusKey].(string) + if resultStatus != "ok" { + t.Fatalf( + "expected status ok, got %v", + result, + ) + } + + // Verify the victim's session is gone by trying + // to WHOIS them. + tserver.sendCommand(operToken, map[string]any{ + commandKey: "WHOIS", + toKey: "victim", + }) + + msgs, _ := tserver.pollMessages(operToken, lastID) + + // Should get 401 ERR_NOSUCHNICK. + if !findNumeric(msgs, "401") { + t.Fatalf( + "expected victim to be gone (401), got %v", + msgs, + ) + } +} + +func TestKillNotOper(t *testing.T) { + tserver := newTestServer(t) + + _ = tserver.createSession("target") + + token := tserver.createSession("notoper") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "KILL", + bodyKey: []string{"target", "no reason"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 481 ERR_NOPRIVILEGES. + if !findNumeric(msgs, "481") { + t.Fatalf( + "expected ERR_NOPRIVILEGES (481), got %v", + msgs, + ) + } +} + +func TestKillNoParams(t *testing.T) { + tserver := newTestServerWithOper(t) + + token := tserver.createSession("opertest") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, testOperPassword}, + }) + + _, lastID = tserver.pollMessages(token, lastID) + + tserver.sendCommand(token, map[string]any{ + commandKey: "KILL", + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 461 ERR_NEEDMOREPARAMS. + if !findNumeric(msgs, "461") { + t.Fatalf( + "expected ERR_NEEDMOREPARAMS (461), got %v", + msgs, + ) + } +} + +// sendOperKillCommand is a helper that creates an oper +// session, authenticates, then sends KILL with the given +// target nick, and returns the resulting messages. +func sendOperKillCommand( + t *testing.T, + tserver *testServer, + operNick, targetNick string, +) []map[string]any { + t.Helper() + + token := tserver.createSession(operNick) + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, testOperPassword}, + }) + + _, lastID = tserver.pollMessages(token, lastID) + + tserver.sendCommand(token, map[string]any{ + commandKey: "KILL", + bodyKey: []string{targetNick}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + return msgs +} + +func TestKillNonexistentUser(t *testing.T) { + tserver := newTestServerWithOper(t) + + msgs := sendOperKillCommand( + t, tserver, "opertest2", "ghost", + ) + + // Expect 401 ERR_NOSUCHNICK. + if !findNumeric(msgs, "401") { + t.Fatalf( + "expected ERR_NOSUCHNICK (401), got %v", + msgs, + ) + } +} + +func TestKillSelf(t *testing.T) { + tserver := newTestServerWithOper(t) + + msgs := sendOperKillCommand( + t, tserver, "selfkiller", "selfkiller", + ) + + // Expect 483 ERR_CANTKILLSERVER. + if !findNumeric(msgs, "483") { + t.Fatalf( + "expected ERR_CANTKILLSERVER (483), got %v", + msgs, + ) + } +} + +func TestKillBroadcastsQuit(t *testing.T) { + tserver := newTestServerWithOper(t) + + // Create victim and join a channel. + victimToken := tserver.createSession("vuser") + + tserver.sendCommand(victimToken, map[string]any{ + commandKey: joinCmd, + toKey: "#killtest", + }) + + // Create observer and join same channel. + observerToken := tserver.createSession("observer") + + tserver.sendCommand(observerToken, map[string]any{ + commandKey: joinCmd, + toKey: "#killtest", + }) + + _, lastObs := tserver.pollMessages(observerToken, 0) + + // Create oper. + operToken := tserver.createSession("theoper2") + + tserver.pollMessages(operToken, 0) + + tserver.sendCommand(operToken, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, testOperPassword}, + }) + + tserver.pollMessages(operToken, 0) + + // Kill the victim. + tserver.sendCommand(operToken, map[string]any{ + commandKey: "KILL", + bodyKey: []string{"vuser", "testing kill"}, + }) + + // Observer should see a QUIT message. + msgs, _ := tserver.pollMessages(observerToken, lastObs) + + foundQuit := false + + for _, msg := range msgs { + cmd, _ := msg["command"].(string) + if cmd == "QUIT" { + from, _ := msg["from"].(string) + if from == "vuser" { + foundQuit = true + + break + } + } + } + + if !foundQuit { + t.Fatalf( + "expected QUIT from vuser, got %v", + msgs, + ) + } +} + +// --- WALLOPS --- + +func TestWallopsSuccess(t *testing.T) { + tserver := newTestServerWithOper(t) + + // Create receiver with +w. + receiverToken := tserver.createSession("receiver") + + tserver.sendCommand(receiverToken, map[string]any{ + commandKey: "MODE", + toKey: "receiver", + bodyKey: []string{"+w"}, + }) + + _, lastRecv := tserver.pollMessages(receiverToken, 0) + + // Create oper. + operToken := tserver.createSession("walloper") + + tserver.pollMessages(operToken, 0) + + tserver.sendCommand(operToken, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, testOperPassword}, + }) + + tserver.pollMessages(operToken, 0) + + // Also set +w on oper so they receive it too. + tserver.sendCommand(operToken, map[string]any{ + commandKey: "MODE", + toKey: "walloper", + bodyKey: []string{"+w"}, + }) + + tserver.pollMessages(operToken, 0) + + // Send WALLOPS. + tserver.sendCommand(operToken, map[string]any{ + commandKey: "WALLOPS", + bodyKey: []string{"server going down"}, + }) + + // Receiver should get the WALLOPS message. + msgs, _ := tserver.pollMessages(receiverToken, lastRecv) + + foundWallops := false + + for _, msg := range msgs { + cmd, _ := msg["command"].(string) + if cmd == "WALLOPS" { + foundWallops = true + + break + } + } + + if !foundWallops { + t.Fatalf( + "expected WALLOPS message, got %v", + msgs, + ) + } +} + +func TestWallopsNotOper(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("notoper2") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "WALLOPS", + bodyKey: []string{"hello"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 481 ERR_NOPRIVILEGES. + if !findNumeric(msgs, "481") { + t.Fatalf( + "expected ERR_NOPRIVILEGES (481), got %v", + msgs, + ) + } +} + +func TestWallopsNoParams(t *testing.T) { + tserver := newTestServerWithOper(t) + + token := tserver.createSession("operempty") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, testOperPassword}, + }) + + _, lastID = tserver.pollMessages(token, lastID) + + tserver.sendCommand(token, map[string]any{ + commandKey: "WALLOPS", + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 461 ERR_NEEDMOREPARAMS. + if !findNumeric(msgs, "461") { + t.Fatalf( + "expected ERR_NEEDMOREPARAMS (461), got %v", + msgs, + ) + } +} + +func TestWallopsNotReceivedWithoutW(t *testing.T) { + tserver := newTestServerWithOper(t) + + // Create receiver WITHOUT +w. + receiverToken := tserver.createSession("nowallops") + _, lastRecv := tserver.pollMessages(receiverToken, 0) + + // Create oper. + operToken := tserver.createSession("walloper2") + + tserver.pollMessages(operToken, 0) + + tserver.sendCommand(operToken, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, testOperPassword}, + }) + + tserver.pollMessages(operToken, 0) + + // Send WALLOPS. + tserver.sendCommand(operToken, map[string]any{ + commandKey: "WALLOPS", + bodyKey: []string{"secret message"}, + }) + + // Receiver should NOT get the WALLOPS message. + msgs, _ := tserver.pollMessages(receiverToken, lastRecv) + + for _, msg := range msgs { + cmd, _ := msg["command"].(string) + if cmd == "WALLOPS" { + t.Fatalf( + "did not expect WALLOPS for user "+ + "without +w, got %v", + msgs, + ) + } + } +} + +// --- User Mode +w --- + +func TestUserModeSetW(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("wmoder") + _, lastID := tserver.pollMessages(token, 0) + + // Set +w. + tserver.sendCommand(token, map[string]any{ + commandKey: "MODE", + toKey: "wmoder", + bodyKey: []string{"+w"}, + }) + + msgs, lastID := tserver.pollMessages(token, lastID) + + // Expect 221 RPL_UMODEIS with "+w". + msg := findNumericWithParams(msgs, "221") + if msg == nil { + t.Fatalf( + "expected RPL_UMODEIS (221), got %v", + msgs, + ) + } + + body := getNumericBody(msg) + if !strings.Contains(body, "w") { + t.Fatalf( + "expected mode string to contain 'w', got %q", + body, + ) + } + + // Now query mode. + tserver.sendCommand(token, map[string]any{ + commandKey: "MODE", + toKey: "wmoder", + }) + + msgs, _ = tserver.pollMessages(token, lastID) + + msg = findNumericWithParams(msgs, "221") + if msg == nil { + t.Fatalf( + "expected RPL_UMODEIS (221) on query, got %v", + msgs, + ) + } + + body = getNumericBody(msg) + if !strings.Contains(body, "w") { + t.Fatalf( + "expected mode '+w' in query, got %q", + body, + ) + } +} + +func TestUserModeUnsetW(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("wunsetter") + _, lastID := tserver.pollMessages(token, 0) + + // Set +w first. + tserver.sendCommand(token, map[string]any{ + commandKey: "MODE", + toKey: "wunsetter", + bodyKey: []string{"+w"}, + }) + + _, lastID = tserver.pollMessages(token, lastID) + + // Unset -w. + tserver.sendCommand(token, map[string]any{ + commandKey: "MODE", + toKey: "wunsetter", + bodyKey: []string{"-w"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + msg := findNumericWithParams(msgs, "221") + if msg == nil { + t.Fatalf( + "expected RPL_UMODEIS (221), got %v", + msgs, + ) + } + + body := getNumericBody(msg) + if strings.Contains(body, "w") { + t.Fatalf( + "expected 'w' to be removed, got %q", + body, + ) + } +} + +func TestUserModeUnknownFlag(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("badmode") + _, lastID := tserver.pollMessages(token, 0) + + tserver.sendCommand(token, map[string]any{ + commandKey: "MODE", + toKey: "badmode", + bodyKey: []string{"+z"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 501 ERR_UMODEUNKNOWNFLAG. + if !findNumeric(msgs, "501") { + t.Fatalf( + "expected ERR_UMODEUNKNOWNFLAG (501), got %v", + msgs, + ) + } +} + +func TestUserModeCannotSetO(t *testing.T) { + tserver := newTestServer(t) + + token := tserver.createSession("tryoper") + _, lastID := tserver.pollMessages(token, 0) + + // Try to set +o via MODE (should fail). + tserver.sendCommand(token, map[string]any{ + commandKey: "MODE", + toKey: "tryoper", + bodyKey: []string{"+o"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 501 ERR_UMODEUNKNOWNFLAG. + if !findNumeric(msgs, "501") { + t.Fatalf( + "expected ERR_UMODEUNKNOWNFLAG (501), got %v", + msgs, + ) + } +} + +func TestUserModeDeoper(t *testing.T) { + tserver := newTestServerWithOper(t) + + token := tserver.createSession("deoper") + _, lastID := tserver.pollMessages(token, 0) + + // Authenticate as oper. + tserver.sendCommand(token, map[string]any{ + commandKey: "OPER", + bodyKey: []string{testOperName, testOperPassword}, + }) + + _, lastID = tserver.pollMessages(token, lastID) + + // Use MODE -o to de-oper. + tserver.sendCommand(token, map[string]any{ + commandKey: "MODE", + toKey: "deoper", + bodyKey: []string{"-o"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + msg := findNumericWithParams(msgs, "221") + if msg == nil { + t.Fatalf( + "expected RPL_UMODEIS (221), got %v", + msgs, + ) + } + + body := getNumericBody(msg) + if strings.Contains(body, "o") { + t.Fatalf( + "expected 'o' to be removed, got %q", + body, + ) + } +} + +func TestUserModeCannotChangeOtherUser(t *testing.T) { + tserver := newTestServer(t) + + _ = tserver.createSession("other") + + token := tserver.createSession("changer") + _, lastID := tserver.pollMessages(token, 0) + + // Try to change another user's mode. + tserver.sendCommand(token, map[string]any{ + commandKey: "MODE", + toKey: "other", + bodyKey: []string{"+w"}, + }) + + msgs, _ := tserver.pollMessages(token, lastID) + + // Expect 502 ERR_USERSDONTMATCH. + if !findNumeric(msgs, "502") { + t.Fatalf( + "expected ERR_USERSDONTMATCH (502), got %v", + msgs, + ) + } +} + +// getNumericBody extracts the body text from a numeric +// message. The body is stored as a JSON array; this +// returns the first element. +func getNumericBody(msg map[string]any) string { + raw, exists := msg["body"] + if !exists || raw == nil { + return "" + } + + arr, isArr := raw.([]any) + if !isArr || len(arr) == 0 { + return "" + } + + str, isStr := arr[0].(string) + if !isStr { + return "" + } + + return str +} diff --git a/pkg/irc/commands.go b/pkg/irc/commands.go index 91893ec..afb40a7 100644 --- a/pkg/irc/commands.go +++ b/pkg/irc/commands.go @@ -2,25 +2,32 @@ package irc // IRC command names (RFC 1459 / RFC 2812). const ( - CmdAway = "AWAY" - CmdInvite = "INVITE" - CmdJoin = "JOIN" - CmdKick = "KICK" - CmdList = "LIST" - CmdLusers = "LUSERS" - CmdMode = "MODE" - CmdMotd = "MOTD" - CmdNames = "NAMES" - CmdNick = "NICK" - CmdNotice = "NOTICE" - CmdOper = "OPER" - CmdPass = "PASS" - CmdPart = "PART" - CmdPing = "PING" - CmdPong = "PONG" - CmdPrivmsg = "PRIVMSG" - CmdQuit = "QUIT" - CmdTopic = "TOPIC" - CmdWho = "WHO" - CmdWhois = "WHOIS" + CmdAdmin = "ADMIN" + CmdAway = "AWAY" + CmdInfo = "INFO" + CmdInvite = "INVITE" + CmdJoin = "JOIN" + CmdKick = "KICK" + CmdKill = "KILL" + CmdList = "LIST" + CmdLusers = "LUSERS" + CmdMode = "MODE" + CmdMotd = "MOTD" + CmdNames = "NAMES" + CmdNick = "NICK" + CmdNotice = "NOTICE" + CmdOper = "OPER" + CmdPass = "PASS" + CmdPart = "PART" + CmdPing = "PING" + CmdPong = "PONG" + CmdPrivmsg = "PRIVMSG" + CmdQuit = "QUIT" + CmdTime = "TIME" + CmdTopic = "TOPIC" + CmdUserhost = "USERHOST" + CmdVersion = "VERSION" + CmdWallops = "WALLOPS" + CmdWho = "WHO" + CmdWhois = "WHOIS" ) -- 2.49.1