Compare commits
1 Commits
feature/87
...
a515e1505a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a515e1505a |
@@ -2307,8 +2307,8 @@ IRC_LISTEN_ADDR=
|
|||||||
| Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` |
|
| Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` |
|
||||||
| Channels | `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE` |
|
| Channels | `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE` |
|
||||||
| Messaging | `PRIVMSG`, `NOTICE` |
|
| Messaging | `PRIVMSG`, `NOTICE` |
|
||||||
| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY`, `USERHOST`, `VERSION`, `ADMIN`, `INFO`, `TIME` |
|
| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY` |
|
||||||
| Operator | `OPER`, `KILL`, `WALLOPS` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) |
|
| Operator | `OPER` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) |
|
||||||
|
|
||||||
### Protocol Details
|
### Protocol Details
|
||||||
|
|
||||||
@@ -2820,10 +2820,6 @@ guess is borne by the server (bcrypt), not the client.
|
|||||||
login from additional devices via `POST /api/v1/login`
|
login from additional devices via `POST /api/v1/login`
|
||||||
- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for
|
- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for
|
||||||
all API authentication
|
all API authentication
|
||||||
- [x] **Tier 3 utility commands** — USERHOST (302), VERSION (351), ADMIN
|
|
||||||
(256–259), INFO (371/374), TIME (391), KILL (oper-only forced
|
|
||||||
disconnect), WALLOPS (oper-only broadcast to +w users)
|
|
||||||
- [x] **User mode +w** — wallops usermode via `MODE nick +w/-w`
|
|
||||||
|
|
||||||
### Future (1.0+)
|
### Future (1.0+)
|
||||||
|
|
||||||
|
|||||||
@@ -2413,132 +2413,3 @@ 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,7 +10,6 @@ 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 '',
|
||||||
|
|||||||
@@ -969,12 +969,10 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
bodyLines func() []string,
|
bodyLines func() []string,
|
||||||
) {
|
) {
|
||||||
switch command {
|
switch command {
|
||||||
case irc.CmdAway, irc.CmdNick,
|
case irc.CmdAway:
|
||||||
irc.CmdPass, irc.CmdInvite:
|
hdlr.handleAway(
|
||||||
hdlr.dispatchBodyOnlyCommand(
|
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick, bodyLines,
|
||||||
command, bodyLines,
|
|
||||||
)
|
)
|
||||||
case irc.CmdPrivmsg, irc.CmdNotice:
|
case irc.CmdPrivmsg, irc.CmdNotice:
|
||||||
hdlr.handlePrivmsg(
|
hdlr.handlePrivmsg(
|
||||||
@@ -993,12 +991,27 @@ 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,
|
||||||
@@ -1009,15 +1022,12 @@ func (hdlr *Handlers) dispatchCommand(
|
|||||||
hdlr.handleQuit(
|
hdlr.handleQuit(
|
||||||
writer, request, sessionID, nick, body,
|
writer, request, sessionID, nick, body,
|
||||||
)
|
)
|
||||||
case irc.CmdOper, irc.CmdKill, irc.CmdWallops:
|
case irc.CmdOper:
|
||||||
hdlr.dispatchOperCommand(
|
hdlr.handleOper(
|
||||||
writer, request,
|
writer, request,
|
||||||
sessionID, clientID, nick,
|
sessionID, clientID, nick, bodyLines,
|
||||||
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,
|
||||||
@@ -1072,11 +1082,6 @@ 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,
|
||||||
@@ -1869,8 +1874,7 @@ func (hdlr *Handlers) deliverSetTopicNumerics(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
@@ -1896,34 +1900,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1980,11 +1956,15 @@ func (hdlr *Handlers) handleMode(
|
|||||||
|
|
||||||
channel := target
|
channel := target
|
||||||
if !strings.HasPrefix(channel, "#") {
|
if !strings.HasPrefix(channel, "#") {
|
||||||
hdlr.handleUserMode(
|
// User mode query — return empty modes.
|
||||||
writer, request,
|
hdlr.enqueueNumeric(
|
||||||
sessionID, clientID, nick, target,
|
request.Context(), clientID,
|
||||||
bodyLines,
|
irc.RplUmodeIs, nick, nil, "+",
|
||||||
)
|
)
|
||||||
|
hdlr.broker.Notify(sessionID)
|
||||||
|
hdlr.respondJSON(writer, request,
|
||||||
|
map[string]string{"status": "ok"},
|
||||||
|
http.StatusOK)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,551 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/neoirc/internal/db"
|
|
||||||
"sneak.berlin/go/neoirc/internal/service"
|
|
||||||
"sneak.berlin/go/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()
|
|
||||||
|
|
||||||
var targetNick string
|
|
||||||
if len(lines) > 0 {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
quitReason := "Killed (" + nick + " (" + reason + "))"
|
|
||||||
|
|
||||||
hdlr.svc.BroadcastQuit(
|
|
||||||
request.Context(), targetSID,
|
|
||||||
targetNick, quitReason,
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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). Delegates to the
|
|
||||||
// shared service.ApplyUserMode / service.QueryUserMode so
|
|
||||||
// that mode string processing is identical for both the
|
|
||||||
// HTTP API and IRC wire protocol.
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
newModes, err := hdlr.svc.ApplyUserMode(
|
|
||||||
ctx, sessionID, lines[0],
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
var ircErr *service.IRCError
|
|
||||||
if errors.As(err, &ircErr) {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request,
|
|
||||||
clientID, sessionID,
|
|
||||||
ircErr.Code, nick, ircErr.Params,
|
|
||||||
ircErr.Message,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.respondError(
|
|
||||||
writer, request,
|
|
||||||
"internal error",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
|
||||||
ctx, clientID, irc.RplUmodeIs, nick, nil,
|
|
||||||
newModes,
|
|
||||||
)
|
|
||||||
|
|
||||||
hdlr.broker.Notify(sessionID)
|
|
||||||
hdlr.respondJSON(writer, request,
|
|
||||||
map[string]string{"status": "ok"},
|
|
||||||
http.StatusOK)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mode query — delegate to shared service.
|
|
||||||
modeStr := hdlr.svc.QueryUserMode(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)
|
|
||||||
}
|
|
||||||
@@ -1,982 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -8,29 +8,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/neoirc/internal/globals"
|
|
||||||
"sneak.berlin/go/neoirc/internal/service"
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
"sneak.berlin/go/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// versionString returns the server version for IRC
|
|
||||||
// responses, falling back to "neoirc-dev" when globals
|
|
||||||
// are not set (e.g. during tests).
|
|
||||||
func versionString() string {
|
|
||||||
name := globals.Appname
|
|
||||||
ver := globals.Version
|
|
||||||
|
|
||||||
if name == "" {
|
|
||||||
name = "neoirc"
|
|
||||||
}
|
|
||||||
|
|
||||||
if ver == "" {
|
|
||||||
ver = "dev"
|
|
||||||
}
|
|
||||||
|
|
||||||
return name + "-" + ver
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendIRCError maps a service.IRCError to an IRC numeric
|
// sendIRCError maps a service.IRCError to an IRC numeric
|
||||||
// reply on the wire.
|
// reply on the wire.
|
||||||
func (c *Conn) sendIRCError(err error) {
|
func (c *Conn) sendIRCError(err error) {
|
||||||
@@ -450,7 +431,7 @@ func (c *Conn) handleMode(
|
|||||||
if strings.HasPrefix(target, "#") {
|
if strings.HasPrefix(target, "#") {
|
||||||
c.handleChannelMode(ctx, msg)
|
c.handleChannelMode(ctx, msg)
|
||||||
} else {
|
} else {
|
||||||
c.handleUserMode(ctx, msg)
|
c.handleUserMode(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,10 +694,7 @@ func (c *Conn) applyChannelModes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleUserMode handles MODE for users.
|
// handleUserMode handles MODE for users.
|
||||||
func (c *Conn) handleUserMode(
|
func (c *Conn) handleUserMode(msg *Message) {
|
||||||
ctx context.Context,
|
|
||||||
msg *Message,
|
|
||||||
) {
|
|
||||||
target := msg.Params[0]
|
target := msg.Params[0]
|
||||||
|
|
||||||
if !strings.EqualFold(target, c.nick) {
|
if !strings.EqualFold(target, c.nick) {
|
||||||
@@ -728,34 +706,8 @@ func (c *Conn) handleUserMode(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode query (no mode string).
|
// We don't support user modes beyond the basics.
|
||||||
if len(msg.Params) < 2 { //nolint:mnd
|
c.sendNumeric(irc.RplUmodeIs, "+")
|
||||||
modes := c.svc.QueryUserMode(ctx, c.sessionID)
|
|
||||||
c.sendNumeric(irc.RplUmodeIs, modes)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
newModes, err := c.svc.ApplyUserMode(
|
|
||||||
ctx, c.sessionID, msg.Params[1],
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
var ircErr *service.IRCError
|
|
||||||
if errors.As(err, &ircErr) {
|
|
||||||
c.sendNumeric(ircErr.Code, ircErr.Message)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.sendNumeric(
|
|
||||||
irc.ErrUmodeUnknownFlag,
|
|
||||||
"Unknown MODE flag",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.sendNumeric(irc.RplUmodeIs, newModes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleNames replies with channel member list.
|
// handleNames replies with channel member list.
|
||||||
@@ -1347,191 +1299,3 @@ func (c *Conn) handleUserhost(
|
|||||||
strings.Join(replies, " "),
|
strings.Join(replies, " "),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleVersion replies with the server version string.
|
|
||||||
func (c *Conn) handleVersion(ctx context.Context) {
|
|
||||||
_ = ctx
|
|
||||||
|
|
||||||
version := versionString()
|
|
||||||
|
|
||||||
c.sendNumeric(
|
|
||||||
irc.RplVersion,
|
|
||||||
version+".", c.cfg.ServerName,
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAdmin replies with server admin info.
|
|
||||||
func (c *Conn) handleAdmin(ctx context.Context) {
|
|
||||||
_ = ctx
|
|
||||||
|
|
||||||
srvName := c.cfg.ServerName
|
|
||||||
|
|
||||||
c.sendNumeric(
|
|
||||||
irc.RplAdminMe,
|
|
||||||
srvName, "Administrative info",
|
|
||||||
)
|
|
||||||
|
|
||||||
c.sendNumeric(
|
|
||||||
irc.RplAdminLoc1,
|
|
||||||
"neoirc server",
|
|
||||||
)
|
|
||||||
|
|
||||||
c.sendNumeric(
|
|
||||||
irc.RplAdminLoc2,
|
|
||||||
"IRC over HTTP",
|
|
||||||
)
|
|
||||||
|
|
||||||
c.sendNumeric(
|
|
||||||
irc.RplAdminEmail,
|
|
||||||
"admin@"+srvName,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleInfo replies with server software info.
|
|
||||||
func (c *Conn) handleInfo(ctx context.Context) {
|
|
||||||
_ = ctx
|
|
||||||
|
|
||||||
infoLines := []string{
|
|
||||||
"neoirc — IRC semantics over HTTP",
|
|
||||||
"Version: " + versionString(),
|
|
||||||
"Written in Go",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, line := range infoLines {
|
|
||||||
c.sendNumeric(irc.RplInfo, line)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.sendNumeric(
|
|
||||||
irc.RplEndOfInfo,
|
|
||||||
"End of /INFO list",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleTime replies with the server's current time.
|
|
||||||
func (c *Conn) handleTime(ctx context.Context) {
|
|
||||||
_ = ctx
|
|
||||||
|
|
||||||
srvName := c.cfg.ServerName
|
|
||||||
|
|
||||||
c.sendNumeric(
|
|
||||||
irc.RplTime,
|
|
||||||
srvName, time.Now().Format(time.RFC1123),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleKillCmd forcibly disconnects a target user (oper
|
|
||||||
// only).
|
|
||||||
func (c *Conn) handleKillCmd(
|
|
||||||
ctx context.Context,
|
|
||||||
msg *Message,
|
|
||||||
) {
|
|
||||||
isOper, err := c.database.IsSessionOper(
|
|
||||||
ctx, c.sessionID,
|
|
||||||
)
|
|
||||||
if err != nil || !isOper {
|
|
||||||
c.sendNumeric(
|
|
||||||
irc.ErrNoPrivileges,
|
|
||||||
"Permission Denied- "+
|
|
||||||
"You're not an IRC operator",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(msg.Params) < 1 {
|
|
||||||
c.sendNumeric(
|
|
||||||
irc.ErrNeedMoreParams,
|
|
||||||
"KILL", "Not enough parameters",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
targetNick := msg.Params[0]
|
|
||||||
|
|
||||||
reason := "KILLed"
|
|
||||||
if len(msg.Params) > 1 {
|
|
||||||
reason = msg.Params[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
if targetNick == c.nick {
|
|
||||||
c.sendNumeric(
|
|
||||||
irc.ErrCantKillServer,
|
|
||||||
"You cannot KILL yourself",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
targetSID, lookupErr := c.database.GetSessionByNick(
|
|
||||||
ctx, targetNick,
|
|
||||||
)
|
|
||||||
if lookupErr != nil {
|
|
||||||
c.sendNumeric(
|
|
||||||
irc.ErrNoSuchNick,
|
|
||||||
targetNick, "No such nick/channel",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
quitReason := "Killed (" + c.nick + " (" + reason + "))"
|
|
||||||
|
|
||||||
c.svc.BroadcastQuit(
|
|
||||||
ctx, targetSID, targetNick, quitReason,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleWallopsCmd broadcasts to all +w users (oper only).
|
|
||||||
func (c *Conn) handleWallopsCmd(
|
|
||||||
ctx context.Context,
|
|
||||||
msg *Message,
|
|
||||||
) {
|
|
||||||
isOper, err := c.database.IsSessionOper(
|
|
||||||
ctx, c.sessionID,
|
|
||||||
)
|
|
||||||
if err != nil || !isOper {
|
|
||||||
c.sendNumeric(
|
|
||||||
irc.ErrNoPrivileges,
|
|
||||||
"Permission Denied- "+
|
|
||||||
"You're not an IRC operator",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(msg.Params) < 1 {
|
|
||||||
c.sendNumeric(
|
|
||||||
irc.ErrNeedMoreParams,
|
|
||||||
"WALLOPS", "Not enough parameters",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
message := msg.Params[0]
|
|
||||||
|
|
||||||
wallopsSIDs, wallErr := c.database.
|
|
||||||
GetWallopsSessionIDs(ctx)
|
|
||||||
if wallErr != nil {
|
|
||||||
c.log.Error(
|
|
||||||
"get wallops sessions failed",
|
|
||||||
"error", wallErr,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(wallopsSIDs) > 0 {
|
|
||||||
body, mErr := json.Marshal([]string{message})
|
|
||||||
if mErr != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _, _ = c.svc.FanOut(
|
|
||||||
ctx, irc.CmdWallops, c.nick, "*",
|
|
||||||
nil, body, nil, wallopsSIDs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -131,12 +131,6 @@ func (c *Conn) buildCommandMap() map[string]cmdHandler {
|
|||||||
c.handleCAP(msg)
|
c.handleCAP(msg)
|
||||||
},
|
},
|
||||||
"USERHOST": c.handleUserhost,
|
"USERHOST": c.handleUserhost,
|
||||||
irc.CmdVersion: func(ctx context.Context, _ *Message) { c.handleVersion(ctx) },
|
|
||||||
irc.CmdAdmin: func(ctx context.Context, _ *Message) { c.handleAdmin(ctx) },
|
|
||||||
irc.CmdInfo: func(ctx context.Context, _ *Message) { c.handleInfo(ctx) },
|
|
||||||
irc.CmdTime: func(ctx context.Context, _ *Message) { c.handleTime(ctx) },
|
|
||||||
irc.CmdKill: c.handleKillCmd,
|
|
||||||
irc.CmdWallops: c.handleWallopsCmd,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -760,288 +760,6 @@ func TestIntegrationTwoClients(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tier 3 Utility Command Integration Tests ──────────
|
|
||||||
|
|
||||||
// TestIntegrationUserhost verifies the USERHOST command
|
|
||||||
// returns user@host info for connected nicks.
|
|
||||||
func TestIntegrationUserhost(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
alice := env.dial(t)
|
|
||||||
alice.register("alice")
|
|
||||||
|
|
||||||
bob := env.dial(t)
|
|
||||||
bob.register("bob")
|
|
||||||
|
|
||||||
// Query single nick.
|
|
||||||
alice.send("USERHOST bob")
|
|
||||||
|
|
||||||
aliceReply := alice.readUntil(func(l string) bool {
|
|
||||||
return strings.Contains(l, " 302 ")
|
|
||||||
})
|
|
||||||
assertContains(
|
|
||||||
t, aliceReply, " 302 ",
|
|
||||||
"RPL_USERHOST",
|
|
||||||
)
|
|
||||||
assertContains(
|
|
||||||
t, aliceReply, "bob",
|
|
||||||
"USERHOST contains queried nick",
|
|
||||||
)
|
|
||||||
|
|
||||||
// Query multiple nicks.
|
|
||||||
bob.send("USERHOST alice bob")
|
|
||||||
|
|
||||||
bobReply := bob.readUntil(func(l string) bool {
|
|
||||||
return strings.Contains(l, " 302 ")
|
|
||||||
})
|
|
||||||
assertContains(
|
|
||||||
t, bobReply, " 302 ",
|
|
||||||
"RPL_USERHOST multi-nick",
|
|
||||||
)
|
|
||||||
assertContains(
|
|
||||||
t, bobReply, "alice",
|
|
||||||
"USERHOST multi contains alice",
|
|
||||||
)
|
|
||||||
assertContains(
|
|
||||||
t, bobReply, "bob",
|
|
||||||
"USERHOST multi contains bob",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIntegrationVersion verifies the VERSION command
|
|
||||||
// returns the server version string.
|
|
||||||
func TestIntegrationVersion(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
alice := env.dial(t)
|
|
||||||
alice.register("alice")
|
|
||||||
|
|
||||||
alice.send("VERSION")
|
|
||||||
|
|
||||||
aliceReply := alice.readUntil(func(l string) bool {
|
|
||||||
return strings.Contains(l, " 351 ")
|
|
||||||
})
|
|
||||||
assertContains(
|
|
||||||
t, aliceReply, " 351 ",
|
|
||||||
"RPL_VERSION",
|
|
||||||
)
|
|
||||||
assertContains(
|
|
||||||
t, aliceReply, "neoirc",
|
|
||||||
"VERSION reply contains server name",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIntegrationAdmin verifies the ADMIN command returns
|
|
||||||
// server admin info (256–259 numerics).
|
|
||||||
func TestIntegrationAdmin(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
alice := env.dial(t)
|
|
||||||
alice.register("alice")
|
|
||||||
|
|
||||||
alice.send("ADMIN")
|
|
||||||
|
|
||||||
aliceReply := alice.readUntil(func(l string) bool {
|
|
||||||
return strings.Contains(l, " 259 ")
|
|
||||||
})
|
|
||||||
assertContains(
|
|
||||||
t, aliceReply, " 256 ",
|
|
||||||
"RPL_ADMINME",
|
|
||||||
)
|
|
||||||
assertContains(
|
|
||||||
t, aliceReply, " 257 ",
|
|
||||||
"RPL_ADMINLOC1",
|
|
||||||
)
|
|
||||||
assertContains(
|
|
||||||
t, aliceReply, " 258 ",
|
|
||||||
"RPL_ADMINLOC2",
|
|
||||||
)
|
|
||||||
assertContains(
|
|
||||||
t, aliceReply, " 259 ",
|
|
||||||
"RPL_ADMINEMAIL",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIntegrationInfo verifies the INFO command returns
|
|
||||||
// server information (371/374 numerics).
|
|
||||||
func TestIntegrationInfo(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
alice := env.dial(t)
|
|
||||||
alice.register("alice")
|
|
||||||
|
|
||||||
alice.send("INFO")
|
|
||||||
|
|
||||||
aliceReply := alice.readUntil(func(l string) bool {
|
|
||||||
return strings.Contains(l, " 374 ")
|
|
||||||
})
|
|
||||||
assertContains(
|
|
||||||
t, aliceReply, " 371 ",
|
|
||||||
"RPL_INFO",
|
|
||||||
)
|
|
||||||
assertContains(
|
|
||||||
t, aliceReply, " 374 ",
|
|
||||||
"RPL_ENDOFINFO",
|
|
||||||
)
|
|
||||||
assertContains(
|
|
||||||
t, aliceReply, "neoirc",
|
|
||||||
"INFO reply mentions server name",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIntegrationTime verifies the TIME command returns
|
|
||||||
// the server time (391 numeric).
|
|
||||||
func TestIntegrationTime(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
alice := env.dial(t)
|
|
||||||
alice.register("alice")
|
|
||||||
|
|
||||||
alice.send("TIME")
|
|
||||||
|
|
||||||
aliceReply := alice.readUntil(func(l string) bool {
|
|
||||||
return strings.Contains(l, " 391 ")
|
|
||||||
})
|
|
||||||
assertContains(
|
|
||||||
t, aliceReply, " 391 ",
|
|
||||||
"RPL_TIME",
|
|
||||||
)
|
|
||||||
assertContains(
|
|
||||||
t, aliceReply, "test.irc",
|
|
||||||
"TIME reply includes server name",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIntegrationKill verifies the KILL command: oper can
|
|
||||||
// kill a user, non-oper cannot.
|
|
||||||
func TestIntegrationKill(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnvWithOper(t)
|
|
||||||
|
|
||||||
alice := env.dial(t)
|
|
||||||
alice.register("alice")
|
|
||||||
|
|
||||||
bob := env.dial(t)
|
|
||||||
bob.register("bob")
|
|
||||||
|
|
||||||
// Both join a channel so KILL's QUIT is visible.
|
|
||||||
alice.joinAndDrain("#killtest")
|
|
||||||
bob.joinAndDrain("#killtest")
|
|
||||||
|
|
||||||
// Drain alice's view of bob's join.
|
|
||||||
alice.readUntil(func(l string) bool {
|
|
||||||
return strings.Contains(l, "JOIN") &&
|
|
||||||
strings.Contains(l, "bob")
|
|
||||||
})
|
|
||||||
|
|
||||||
// Non-oper KILL should fail.
|
|
||||||
alice.send("KILL bob :nope")
|
|
||||||
|
|
||||||
aliceKillFail := alice.readUntil(func(l string) bool {
|
|
||||||
return strings.Contains(l, " 481 ")
|
|
||||||
})
|
|
||||||
assertContains(
|
|
||||||
t, aliceKillFail, " 481 ",
|
|
||||||
"ERR_NOPRIVILEGES for non-oper KILL",
|
|
||||||
)
|
|
||||||
|
|
||||||
// alice becomes oper.
|
|
||||||
alice.send("OPER testoper testpass")
|
|
||||||
alice.readUntil(func(l string) bool {
|
|
||||||
return strings.Contains(l, " 381 ")
|
|
||||||
})
|
|
||||||
|
|
||||||
// Oper KILL should succeed.
|
|
||||||
alice.send("KILL bob :bad behavior")
|
|
||||||
|
|
||||||
// alice should see bob's QUIT relay.
|
|
||||||
aliceSeesQuit := alice.readUntil(func(l string) bool {
|
|
||||||
return strings.Contains(l, "QUIT") &&
|
|
||||||
strings.Contains(l, "bob")
|
|
||||||
})
|
|
||||||
assertContains(
|
|
||||||
t, aliceSeesQuit, "Killed",
|
|
||||||
"KILL reason in QUIT message",
|
|
||||||
)
|
|
||||||
|
|
||||||
// KILL nonexistent nick.
|
|
||||||
alice.send("KILL nobody123 :gone")
|
|
||||||
|
|
||||||
aliceNoSuch := alice.readUntil(func(l string) bool {
|
|
||||||
return strings.Contains(l, " 401 ")
|
|
||||||
})
|
|
||||||
assertContains(
|
|
||||||
t, aliceNoSuch, " 401 ",
|
|
||||||
"ERR_NOSUCHNICK for KILL missing target",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIntegrationWallops verifies the WALLOPS command:
|
|
||||||
// oper can broadcast to +w users.
|
|
||||||
func TestIntegrationWallops(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnvWithOper(t)
|
|
||||||
|
|
||||||
alice := env.dial(t)
|
|
||||||
alice.register("alice")
|
|
||||||
|
|
||||||
bob := env.dial(t)
|
|
||||||
bob.register("bob")
|
|
||||||
|
|
||||||
// Non-oper WALLOPS should fail.
|
|
||||||
alice.send("WALLOPS :test broadcast")
|
|
||||||
|
|
||||||
aliceWallopsFail := alice.readUntil(func(l string) bool {
|
|
||||||
return strings.Contains(l, " 481 ")
|
|
||||||
})
|
|
||||||
assertContains(
|
|
||||||
t, aliceWallopsFail, " 481 ",
|
|
||||||
"ERR_NOPRIVILEGES for non-oper WALLOPS",
|
|
||||||
)
|
|
||||||
|
|
||||||
// alice becomes oper.
|
|
||||||
alice.send("OPER testoper testpass")
|
|
||||||
alice.readUntil(func(l string) bool {
|
|
||||||
return strings.Contains(l, " 381 ")
|
|
||||||
})
|
|
||||||
|
|
||||||
// bob sets +w to receive wallops.
|
|
||||||
bob.send("MODE bob +w")
|
|
||||||
bob.readUntil(func(l string) bool {
|
|
||||||
return strings.Contains(l, " 221 ")
|
|
||||||
})
|
|
||||||
|
|
||||||
// alice sends WALLOPS.
|
|
||||||
alice.send("WALLOPS :important announcement")
|
|
||||||
|
|
||||||
// bob (who has +w) should receive it.
|
|
||||||
bobWallops := bob.readUntil(func(l string) bool {
|
|
||||||
return strings.Contains(
|
|
||||||
l, "important announcement",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
assertContains(
|
|
||||||
t, bobWallops, "important announcement",
|
|
||||||
"bob receives WALLOPS message",
|
|
||||||
)
|
|
||||||
assertContains(
|
|
||||||
t, bobWallops, "WALLOPS",
|
|
||||||
"message is WALLOPS command",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIntegrationModeSecret tests +s (secret) channel
|
// TestIntegrationModeSecret tests +s (secret) channel
|
||||||
// mode — verifies that +s can be set and the mode is
|
// mode — verifies that +s can be set and the mode is
|
||||||
// reflected in MODE queries.
|
// reflected in MODE queries.
|
||||||
|
|||||||
@@ -120,8 +120,6 @@ func (c *Conn) deliverIRCMessage(
|
|||||||
c.deliverKickMsg(msg, text)
|
c.deliverKickMsg(msg, text)
|
||||||
case command == "INVITE":
|
case command == "INVITE":
|
||||||
c.deliverInviteMsg(msg, text)
|
c.deliverInviteMsg(msg, text)
|
||||||
case command == irc.CmdWallops:
|
|
||||||
c.deliverWallops(msg, text)
|
|
||||||
case command == irc.CmdMode:
|
case command == irc.CmdMode:
|
||||||
c.deliverMode(msg, text)
|
c.deliverMode(msg, text)
|
||||||
case command == irc.CmdPing:
|
case command == irc.CmdPing:
|
||||||
@@ -307,18 +305,6 @@ func (c *Conn) deliverInviteMsg(
|
|||||||
c.sendFromServer("NOTICE", c.nick, text)
|
c.sendFromServer("NOTICE", c.nick, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// deliverWallops sends a WALLOPS notification.
|
|
||||||
func (c *Conn) deliverWallops(
|
|
||||||
msg *db.IRCMessage,
|
|
||||||
text string,
|
|
||||||
) {
|
|
||||||
prefix := msg.From + "!" + msg.From + "@*"
|
|
||||||
|
|
||||||
c.send(FormatMessage(
|
|
||||||
prefix, irc.CmdWallops, text,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
// deliverMode sends a MODE change notification.
|
// deliverMode sends a MODE change notification.
|
||||||
func (c *Conn) deliverMode(
|
func (c *Conn) deliverMode(
|
||||||
msg *db.IRCMessage,
|
msg *db.IRCMessage,
|
||||||
|
|||||||
@@ -112,87 +112,6 @@ func newTestEnv(t *testing.T) *testEnv {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newTestEnvWithOper creates a test environment with oper
|
|
||||||
// credentials configured.
|
|
||||||
func newTestEnvWithOper(t *testing.T) *testEnv {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
dsn := fmt.Sprintf(
|
|
||||||
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
|
|
||||||
t.Name(),
|
|
||||||
)
|
|
||||||
|
|
||||||
conn, err := sql.Open("sqlite", dsn)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open db: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.SetMaxOpenConns(1)
|
|
||||||
|
|
||||||
_, err = conn.ExecContext(
|
|
||||||
t.Context(), "PRAGMA foreign_keys = ON",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("pragma: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
database := db.NewTestDatabaseFromConn(conn)
|
|
||||||
|
|
||||||
err = database.RunMigrations(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("migrate: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
brk := broker.New()
|
|
||||||
|
|
||||||
cfg := &config.Config{ //nolint:exhaustruct
|
|
||||||
ServerName: "test.irc",
|
|
||||||
MOTD: "Welcome to test IRC",
|
|
||||||
OperName: "testoper",
|
|
||||||
OperPassword: "testpass",
|
|
||||||
}
|
|
||||||
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("listen: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
addr := listener.Addr().String()
|
|
||||||
|
|
||||||
err = listener.Close()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("close listener: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log := slog.New(slog.NewTextHandler(
|
|
||||||
os.Stderr,
|
|
||||||
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
|
|
||||||
))
|
|
||||||
|
|
||||||
srv := ircserver.NewTestServer(log, cfg, database, brk)
|
|
||||||
|
|
||||||
err = srv.Start(addr)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("start irc server: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
srv.Stop()
|
|
||||||
|
|
||||||
err := conn.Close()
|
|
||||||
if err != nil {
|
|
||||||
t.Logf("close db: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return &testEnv{
|
|
||||||
database: database,
|
|
||||||
brk: brk,
|
|
||||||
cfg: cfg,
|
|
||||||
srv: srv,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// dial connects to the test server.
|
// dial connects to the test server.
|
||||||
func (env *testEnv) dial(t *testing.T) *testClient {
|
func (env *testEnv) dial(t *testing.T) *testClient {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -791,109 +791,6 @@ func (s *Service) QueryChannelMode(
|
|||||||
return modes + modeParams
|
return modes + modeParams
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryUserMode returns the current user mode string for
|
|
||||||
// the given session (e.g. "+ow", "+w", "+").
|
|
||||||
func (s *Service) QueryUserMode(
|
|
||||||
ctx context.Context,
|
|
||||||
sessionID int64,
|
|
||||||
) string {
|
|
||||||
modes := "+"
|
|
||||||
|
|
||||||
isOper, err := s.db.IsSessionOper(ctx, sessionID)
|
|
||||||
if err == nil && isOper {
|
|
||||||
modes += "o"
|
|
||||||
}
|
|
||||||
|
|
||||||
isWallops, err := s.db.IsSessionWallops(
|
|
||||||
ctx, sessionID,
|
|
||||||
)
|
|
||||||
if err == nil && isWallops {
|
|
||||||
modes += "w"
|
|
||||||
}
|
|
||||||
|
|
||||||
return modes
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyUserMode parses a mode string character by
|
|
||||||
// character (e.g. "+wo", "-w") and applies each mode
|
|
||||||
// change to the session. Returns the resulting mode string
|
|
||||||
// after all changes, or an IRCError on failure.
|
|
||||||
func (s *Service) ApplyUserMode(
|
|
||||||
ctx context.Context,
|
|
||||||
sessionID int64,
|
|
||||||
modeStr string,
|
|
||||||
) (string, error) {
|
|
||||||
if len(modeStr) < 2 { //nolint:mnd // +/- prefix + ≥1 char
|
|
||||||
return "", &IRCError{
|
|
||||||
Code: irc.ErrUmodeUnknownFlag,
|
|
||||||
Params: nil,
|
|
||||||
Message: "Unknown MODE flag",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
adding := modeStr[0] == '+'
|
|
||||||
|
|
||||||
for _, ch := range modeStr[1:] {
|
|
||||||
if err := s.applySingleUserMode(
|
|
||||||
ctx, sessionID, ch, adding,
|
|
||||||
); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.QueryUserMode(ctx, sessionID), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// applySingleUserMode applies one user mode character.
|
|
||||||
func (s *Service) applySingleUserMode(
|
|
||||||
ctx context.Context,
|
|
||||||
sessionID int64,
|
|
||||||
modeChar rune,
|
|
||||||
adding bool,
|
|
||||||
) error {
|
|
||||||
switch modeChar {
|
|
||||||
case 'w':
|
|
||||||
err := s.db.SetSessionWallops(
|
|
||||||
ctx, sessionID, adding,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Error(
|
|
||||||
"set wallops mode failed", "error", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
return fmt.Errorf("set wallops: %w", err)
|
|
||||||
}
|
|
||||||
case 'o':
|
|
||||||
// +o cannot be set via MODE; use OPER command.
|
|
||||||
if adding {
|
|
||||||
return &IRCError{
|
|
||||||
Code: irc.ErrUmodeUnknownFlag,
|
|
||||||
Params: nil,
|
|
||||||
Message: "Unknown MODE flag",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := s.db.SetSessionOper(
|
|
||||||
ctx, sessionID, false,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Error(
|
|
||||||
"clear oper mode failed", "error", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
return fmt.Errorf("clear oper: %w", err)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return &IRCError{
|
|
||||||
Code: irc.ErrUmodeUnknownFlag,
|
|
||||||
Params: nil,
|
|
||||||
Message: "Unknown MODE flag",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// broadcastNickChange notifies channel peers of a nick
|
// broadcastNickChange notifies channel peers of a nick
|
||||||
// change.
|
// change.
|
||||||
func (s *Service) broadcastNickChange(
|
func (s *Service) broadcastNickChange(
|
||||||
|
|||||||
@@ -363,166 +363,3 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
|
|||||||
t.Errorf("operator should be able to send in moderated channel: %v", err)
|
t.Errorf("operator should be able to send in moderated channel: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryUserMode(t *testing.T) {
|
|
||||||
env := newTestEnv(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sid := createSession(ctx, t, env.db, "alice")
|
|
||||||
|
|
||||||
// Fresh session has no modes.
|
|
||||||
modes := env.svc.QueryUserMode(ctx, sid)
|
|
||||||
if modes != "+" {
|
|
||||||
t.Errorf("expected +, got %s", modes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set wallops.
|
|
||||||
_ = env.db.SetSessionWallops(ctx, sid, true)
|
|
||||||
|
|
||||||
modes = env.svc.QueryUserMode(ctx, sid)
|
|
||||||
if modes != "+w" {
|
|
||||||
t.Errorf("expected +w, got %s", modes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set oper.
|
|
||||||
_ = env.db.SetSessionOper(ctx, sid, true)
|
|
||||||
|
|
||||||
modes = env.svc.QueryUserMode(ctx, sid)
|
|
||||||
if modes != "+ow" {
|
|
||||||
t.Errorf("expected +ow, got %s", modes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyUserModeSingleChar(t *testing.T) {
|
|
||||||
env := newTestEnv(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sid := createSession(ctx, t, env.db, "alice")
|
|
||||||
|
|
||||||
// Apply +w.
|
|
||||||
result, err := env.svc.ApplyUserMode(ctx, sid, "+w")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("apply +w: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result != "+w" {
|
|
||||||
t.Errorf("expected +w, got %s", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply -w.
|
|
||||||
result, err = env.svc.ApplyUserMode(ctx, sid, "-w")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("apply -w: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result != "+" {
|
|
||||||
t.Errorf("expected +, got %s", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyUserModeMultiChar(t *testing.T) {
|
|
||||||
env := newTestEnv(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sid := createSession(ctx, t, env.db, "alice")
|
|
||||||
|
|
||||||
// Set oper first so we can test +wo (w applied, o
|
|
||||||
// rejected because +o is not allowed via MODE).
|
|
||||||
_ = env.db.SetSessionOper(ctx, sid, true)
|
|
||||||
|
|
||||||
// Apply +w alone should work.
|
|
||||||
result, err := env.svc.ApplyUserMode(ctx, sid, "+w")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("apply +w: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result != "+ow" {
|
|
||||||
t.Errorf("expected +ow, got %s", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset wallops.
|
|
||||||
_ = env.db.SetSessionWallops(ctx, sid, false)
|
|
||||||
|
|
||||||
// Multi-char -ow: should de-oper and remove wallops.
|
|
||||||
_ = env.db.SetSessionWallops(ctx, sid, true)
|
|
||||||
|
|
||||||
result, err = env.svc.ApplyUserMode(ctx, sid, "-ow")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("apply -ow: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result != "+" {
|
|
||||||
t.Errorf("expected +, got %s", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// +wo should fail because +o is not allowed.
|
|
||||||
_, err = env.svc.ApplyUserMode(ctx, sid, "+wo")
|
|
||||||
|
|
||||||
var ircErr *service.IRCError
|
|
||||||
if !errors.As(err, &ircErr) {
|
|
||||||
t.Fatalf("expected IRCError, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ircErr.Code != irc.ErrUmodeUnknownFlag {
|
|
||||||
t.Errorf(
|
|
||||||
"expected ErrUmodeUnknownFlag, got %d",
|
|
||||||
ircErr.Code,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyUserModeInvalidInput(t *testing.T) {
|
|
||||||
env := newTestEnv(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sid := createSession(ctx, t, env.db, "alice")
|
|
||||||
|
|
||||||
// Too short.
|
|
||||||
_, err := env.svc.ApplyUserMode(ctx, sid, "+")
|
|
||||||
|
|
||||||
var ircErr *service.IRCError
|
|
||||||
if !errors.As(err, &ircErr) {
|
|
||||||
t.Fatalf("expected IRCError for short input, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown flag.
|
|
||||||
_, err = env.svc.ApplyUserMode(ctx, sid, "+x")
|
|
||||||
if !errors.As(err, &ircErr) {
|
|
||||||
t.Fatalf("expected IRCError for unknown flag, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ircErr.Code != irc.ErrUmodeUnknownFlag {
|
|
||||||
t.Errorf(
|
|
||||||
"expected ErrUmodeUnknownFlag, got %d",
|
|
||||||
ircErr.Code,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyUserModeDeoper(t *testing.T) {
|
|
||||||
env := newTestEnv(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
sid := createSession(ctx, t, env.db, "alice")
|
|
||||||
|
|
||||||
// Make oper via DB directly.
|
|
||||||
_ = env.db.SetSessionOper(ctx, sid, true)
|
|
||||||
|
|
||||||
// -o should work.
|
|
||||||
result, err := env.svc.ApplyUserMode(ctx, sid, "-o")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("apply -o: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result != "+" {
|
|
||||||
t.Errorf("expected +, got %s", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// +o should fail.
|
|
||||||
_, err = env.svc.ApplyUserMode(ctx, sid, "+o")
|
|
||||||
|
|
||||||
var ircErr *service.IRCError
|
|
||||||
if !errors.As(err, &ircErr) {
|
|
||||||
t.Fatalf("expected IRCError for +o, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,13 +2,10 @@ package irc
|
|||||||
|
|
||||||
// IRC command names (RFC 1459 / RFC 2812).
|
// IRC command names (RFC 1459 / RFC 2812).
|
||||||
const (
|
const (
|
||||||
CmdAdmin = "ADMIN"
|
|
||||||
CmdAway = "AWAY"
|
CmdAway = "AWAY"
|
||||||
CmdInfo = "INFO"
|
|
||||||
CmdInvite = "INVITE"
|
CmdInvite = "INVITE"
|
||||||
CmdJoin = "JOIN"
|
CmdJoin = "JOIN"
|
||||||
CmdKick = "KICK"
|
CmdKick = "KICK"
|
||||||
CmdKill = "KILL"
|
|
||||||
CmdList = "LIST"
|
CmdList = "LIST"
|
||||||
CmdLusers = "LUSERS"
|
CmdLusers = "LUSERS"
|
||||||
CmdMode = "MODE"
|
CmdMode = "MODE"
|
||||||
@@ -23,12 +20,8 @@ const (
|
|||||||
CmdPong = "PONG"
|
CmdPong = "PONG"
|
||||||
CmdPrivmsg = "PRIVMSG"
|
CmdPrivmsg = "PRIVMSG"
|
||||||
CmdQuit = "QUIT"
|
CmdQuit = "QUIT"
|
||||||
CmdTime = "TIME"
|
|
||||||
CmdTopic = "TOPIC"
|
CmdTopic = "TOPIC"
|
||||||
CmdUser = "USER"
|
CmdUser = "USER"
|
||||||
CmdUserhost = "USERHOST"
|
|
||||||
CmdVersion = "VERSION"
|
|
||||||
CmdWallops = "WALLOPS"
|
|
||||||
CmdWho = "WHO"
|
CmdWho = "WHO"
|
||||||
CmdWhois = "WHOIS"
|
CmdWhois = "WHOIS"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user