Compare commits
2 Commits
9c6c4cd07d
...
feature/87
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
142d0f5919 | ||
| 9a79d92c0d |
@@ -2367,3 +2367,132 @@ func (database *Database) SetChannelUserLimit(
|
|||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
hostname TEXT NOT NULL DEFAULT '',
|
hostname TEXT NOT NULL DEFAULT '',
|
||||||
ip TEXT NOT NULL DEFAULT '',
|
ip TEXT NOT NULL DEFAULT '',
|
||||||
is_oper INTEGER NOT NULL DEFAULT 0,
|
is_oper INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_wallops INTEGER NOT NULL DEFAULT 0,
|
||||||
password_hash TEXT NOT NULL DEFAULT '',
|
password_hash TEXT NOT NULL DEFAULT '',
|
||||||
signing_key TEXT NOT NULL DEFAULT '',
|
signing_key TEXT NOT NULL DEFAULT '',
|
||||||
away_message TEXT NOT NULL DEFAULT '',
|
away_message TEXT NOT NULL DEFAULT '',
|
||||||
|
|||||||
@@ -1014,10 +1014,12 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
switch command {
|
switch command {
|
||||||
case irc.CmdAway:
|
case irc.CmdAway, irc.CmdNick,
|
||||||
hdlr.handleAway(
|
irc.CmdPass, irc.CmdInvite:
|
||||||
|
hdlr.dispatchBodyOnlyCommand(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, bodyLines,
|
sessionID, clientID, nick,
|
||||||
|
command, bodyLines,
|
||||||
)
|
)
|
||||||
case irc.CmdPrivmsg, irc.CmdNotice:
|
case irc.CmdPrivmsg, irc.CmdNotice:
|
||||||
hdlr.handlePrivmsg(
|
hdlr.handlePrivmsg(
|
||||||
@@ -1036,27 +1038,12 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick, target, body,
|
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:
|
case irc.CmdTopic:
|
||||||
hdlr.handleTopic(
|
hdlr.handleTopic(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
target, body, bodyLines,
|
target, body, bodyLines,
|
||||||
)
|
)
|
||||||
case irc.CmdInvite:
|
|
||||||
hdlr.handleInvite(
|
|
||||||
writer, request,
|
|
||||||
sessionID, clientID, nick, bodyLines,
|
|
||||||
)
|
|
||||||
case irc.CmdKick:
|
case irc.CmdKick:
|
||||||
hdlr.handleKick(
|
hdlr.handleKick(
|
||||||
writer, request,
|
writer, request,
|
||||||
@@ -1067,12 +1054,15 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
hdlr.handleQuit(
|
hdlr.handleQuit(
|
||||||
writer, request, sessionID, nick, body,
|
writer, request, sessionID, nick, body,
|
||||||
)
|
)
|
||||||
case irc.CmdOper:
|
case irc.CmdOper, irc.CmdKill, irc.CmdWallops:
|
||||||
hdlr.handleOper(
|
hdlr.dispatchOperCommand(
|
||||||
writer, request,
|
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(
|
hdlr.dispatchInfoCommand(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
@@ -1127,6 +1117,11 @@ func (hdlr *Handlers) dispatchQueryCommand(
|
|||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick,
|
||||||
)
|
)
|
||||||
|
case irc.CmdUserhost:
|
||||||
|
hdlr.handleUserhost(
|
||||||
|
writer, request,
|
||||||
|
sessionID, clientID, nick, bodyLines,
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
request.Context(), clientID,
|
request.Context(), clientID,
|
||||||
@@ -2416,7 +2411,8 @@ func (hdlr *Handlers) executeTopic(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// dispatchInfoCommand handles informational IRC commands
|
// 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(
|
func (hdlr *Handlers) dispatchInfoCommand(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
@@ -2442,6 +2438,34 @@ func (hdlr *Handlers) dispatchInfoCommand(
|
|||||||
},
|
},
|
||||||
http.StatusOK)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2532,15 +2556,11 @@ func (hdlr *Handlers) handleMode(
|
|||||||
|
|
||||||
channel := target
|
channel := target
|
||||||
if !strings.HasPrefix(channel, "#") {
|
if !strings.HasPrefix(channel, "#") {
|
||||||
// User mode query — return empty modes.
|
hdlr.handleUserMode(
|
||||||
hdlr.enqueueNumeric(
|
writer, request,
|
||||||
request.Context(), clientID,
|
sessionID, clientID, nick, target,
|
||||||
irc.RplUmodeIs, nick, nil, "+",
|
bodyLines,
|
||||||
)
|
)
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
727
internal/handlers/utility.go
Normal file
727
internal/handlers/utility.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
982
internal/handlers/utility_test.go
Normal file
982
internal/handlers/utility_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -2,25 +2,32 @@ package irc
|
|||||||
|
|
||||||
// IRC command names (RFC 1459 / RFC 2812).
|
// IRC command names (RFC 1459 / RFC 2812).
|
||||||
const (
|
const (
|
||||||
CmdAway = "AWAY"
|
CmdAdmin = "ADMIN"
|
||||||
CmdInvite = "INVITE"
|
CmdAway = "AWAY"
|
||||||
CmdJoin = "JOIN"
|
CmdInfo = "INFO"
|
||||||
CmdKick = "KICK"
|
CmdInvite = "INVITE"
|
||||||
CmdList = "LIST"
|
CmdJoin = "JOIN"
|
||||||
CmdLusers = "LUSERS"
|
CmdKick = "KICK"
|
||||||
CmdMode = "MODE"
|
CmdKill = "KILL"
|
||||||
CmdMotd = "MOTD"
|
CmdList = "LIST"
|
||||||
CmdNames = "NAMES"
|
CmdLusers = "LUSERS"
|
||||||
CmdNick = "NICK"
|
CmdMode = "MODE"
|
||||||
CmdNotice = "NOTICE"
|
CmdMotd = "MOTD"
|
||||||
CmdOper = "OPER"
|
CmdNames = "NAMES"
|
||||||
CmdPass = "PASS"
|
CmdNick = "NICK"
|
||||||
CmdPart = "PART"
|
CmdNotice = "NOTICE"
|
||||||
CmdPing = "PING"
|
CmdOper = "OPER"
|
||||||
CmdPong = "PONG"
|
CmdPass = "PASS"
|
||||||
CmdPrivmsg = "PRIVMSG"
|
CmdPart = "PART"
|
||||||
CmdQuit = "QUIT"
|
CmdPing = "PING"
|
||||||
CmdTopic = "TOPIC"
|
CmdPong = "PONG"
|
||||||
CmdWho = "WHO"
|
CmdPrivmsg = "PRIVMSG"
|
||||||
CmdWhois = "WHOIS"
|
CmdQuit = "QUIT"
|
||||||
|
CmdTime = "TIME"
|
||||||
|
CmdTopic = "TOPIC"
|
||||||
|
CmdUserhost = "USERHOST"
|
||||||
|
CmdVersion = "VERSION"
|
||||||
|
CmdWallops = "WALLOPS"
|
||||||
|
CmdWho = "WHO"
|
||||||
|
CmdWhois = "WHOIS"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user