Compare commits
4 Commits
17479c4f44
...
feature/87
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f24e33a310 | ||
|
|
93611dad67 | ||
|
|
abe0cc2c30 | ||
|
|
327ff37059 |
@@ -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` |
|
| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY`, `USERHOST`, `VERSION`, `ADMIN`, `INFO`, `TIME` |
|
||||||
| Operator | `OPER` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) |
|
| Operator | `OPER`, `KILL`, `WALLOPS` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) |
|
||||||
|
|
||||||
### Protocol Details
|
### Protocol Details
|
||||||
|
|
||||||
@@ -2820,6 +2820,10 @@ 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+)
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/neoirc/internal/db"
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
|
"sneak.berlin/go/neoirc/internal/service"
|
||||||
"sneak.berlin/go/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -331,18 +331,12 @@ func (hdlr *Handlers) handleKill(
|
|||||||
}
|
}
|
||||||
|
|
||||||
lines := bodyLines()
|
lines := bodyLines()
|
||||||
if len(lines) == 0 {
|
|
||||||
hdlr.respondIRCError(
|
|
||||||
writer, request, clientID, sessionID,
|
|
||||||
irc.ErrNeedMoreParams, nick,
|
|
||||||
[]string{irc.CmdKill},
|
|
||||||
"Not enough parameters",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
var targetNick string
|
||||||
|
if len(lines) > 0 {
|
||||||
|
targetNick = strings.TrimSpace(lines[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
targetNick := strings.TrimSpace(lines[0])
|
|
||||||
if targetNick == "" {
|
if targetNick == "" {
|
||||||
hdlr.respondIRCError(
|
hdlr.respondIRCError(
|
||||||
writer, request, clientID, sessionID,
|
writer, request, clientID, sessionID,
|
||||||
@@ -383,8 +377,11 @@ func (hdlr *Handlers) handleKill(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.executeKillUser(
|
quitReason := "Killed (" + nick + " (" + reason + "))"
|
||||||
request, targetSID, targetNick, nick, reason,
|
|
||||||
|
hdlr.svc.BroadcastQuit(
|
||||||
|
request.Context(), targetSID,
|
||||||
|
targetNick, quitReason,
|
||||||
)
|
)
|
||||||
|
|
||||||
hdlr.respondJSON(writer, request,
|
hdlr.respondJSON(writer, request,
|
||||||
@@ -392,71 +389,6 @@ func (hdlr *Handlers) handleKill(
|
|||||||
http.StatusOK)
|
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.
|
// handleWallops handles the WALLOPS command.
|
||||||
// Broadcasts a message to all users with +w usermode
|
// Broadcasts a message to all users with +w usermode
|
||||||
// (oper only).
|
// (oper only).
|
||||||
@@ -539,7 +471,10 @@ func (hdlr *Handlers) handleWallops(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleUserMode handles user mode queries and changes
|
// handleUserMode handles user mode queries and changes
|
||||||
// (e.g., MODE nick, MODE nick +w).
|
// (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(
|
func (hdlr *Handlers) handleUserMode(
|
||||||
writer http.ResponseWriter,
|
writer http.ResponseWriter,
|
||||||
request *http.Request,
|
request *http.Request,
|
||||||
@@ -564,16 +499,46 @@ func (hdlr *Handlers) handleUserMode(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hdlr.applyUserModeChange(
|
newModes, err := hdlr.svc.ApplyUserMode(
|
||||||
writer, request,
|
ctx, sessionID, lines[0],
|
||||||
sessionID, clientID, nick, 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode query — build the current mode string.
|
// Mode query — delegate to shared service.
|
||||||
modeStr := hdlr.buildUserModeString(ctx, sessionID)
|
modeStr := hdlr.svc.QueryUserMode(ctx, sessionID)
|
||||||
|
|
||||||
hdlr.enqueueNumeric(
|
hdlr.enqueueNumeric(
|
||||||
ctx, clientID, irc.RplUmodeIs, nick, nil,
|
ctx, clientID, irc.RplUmodeIs, nick, nil,
|
||||||
@@ -584,144 +549,3 @@ func (hdlr *Handlers) handleUserMode(
|
|||||||
map[string]string{"status": "ok"},
|
map[string]string{"status": "ok"},
|
||||||
http.StatusOK)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,10 +8,29 @@ 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) {
|
||||||
@@ -711,17 +730,23 @@ func (c *Conn) handleUserMode(
|
|||||||
|
|
||||||
// Mode query (no mode string).
|
// Mode query (no mode string).
|
||||||
if len(msg.Params) < 2 { //nolint:mnd
|
if len(msg.Params) < 2 { //nolint:mnd
|
||||||
c.sendNumeric(
|
modes := c.svc.QueryUserMode(ctx, c.sessionID)
|
||||||
irc.RplUmodeIs,
|
c.sendNumeric(irc.RplUmodeIs, modes)
|
||||||
c.buildUmodeString(ctx),
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
modeStr := msg.Params[1]
|
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
|
||||||
|
}
|
||||||
|
|
||||||
if len(modeStr) < 2 { //nolint:mnd
|
|
||||||
c.sendNumeric(
|
c.sendNumeric(
|
||||||
irc.ErrUmodeUnknownFlag,
|
irc.ErrUmodeUnknownFlag,
|
||||||
"Unknown MODE flag",
|
"Unknown MODE flag",
|
||||||
@@ -730,64 +755,7 @@ func (c *Conn) handleUserMode(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
adding := modeStr[0] == '+'
|
c.sendNumeric(irc.RplUmodeIs, newModes)
|
||||||
|
|
||||||
for _, ch := range modeStr[1:] {
|
|
||||||
switch ch {
|
|
||||||
case 'w':
|
|
||||||
_ = c.database.SetSessionWallops(
|
|
||||||
ctx, c.sessionID, adding,
|
|
||||||
)
|
|
||||||
case 'o':
|
|
||||||
if adding {
|
|
||||||
c.sendNumeric(
|
|
||||||
irc.ErrUmodeUnknownFlag,
|
|
||||||
"Unknown MODE flag",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = c.database.SetSessionOper(
|
|
||||||
ctx, c.sessionID, false,
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
c.sendNumeric(
|
|
||||||
irc.ErrUmodeUnknownFlag,
|
|
||||||
"Unknown MODE flag",
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.sendNumeric(
|
|
||||||
irc.RplUmodeIs,
|
|
||||||
c.buildUmodeString(ctx),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildUmodeString returns the current user mode string.
|
|
||||||
func (c *Conn) buildUmodeString(
|
|
||||||
ctx context.Context,
|
|
||||||
) string {
|
|
||||||
modes := "+"
|
|
||||||
|
|
||||||
isOper, err := c.database.IsSessionOper(
|
|
||||||
ctx, c.sessionID,
|
|
||||||
)
|
|
||||||
if err == nil && isOper {
|
|
||||||
modes += "o"
|
|
||||||
}
|
|
||||||
|
|
||||||
isWallops, err := c.database.IsSessionWallops(
|
|
||||||
ctx, c.sessionID,
|
|
||||||
)
|
|
||||||
if err == nil && isWallops {
|
|
||||||
modes += "w"
|
|
||||||
}
|
|
||||||
|
|
||||||
return modes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleNames replies with channel member list.
|
// handleNames replies with channel member list.
|
||||||
@@ -1384,7 +1352,7 @@ func (c *Conn) handleUserhost(
|
|||||||
func (c *Conn) handleVersion(ctx context.Context) {
|
func (c *Conn) handleVersion(ctx context.Context) {
|
||||||
_ = ctx
|
_ = ctx
|
||||||
|
|
||||||
version := "neoirc-0.1"
|
version := versionString()
|
||||||
|
|
||||||
c.sendNumeric(
|
c.sendNumeric(
|
||||||
irc.RplVersion,
|
irc.RplVersion,
|
||||||
@@ -1426,7 +1394,7 @@ func (c *Conn) handleInfo(ctx context.Context) {
|
|||||||
|
|
||||||
infoLines := []string{
|
infoLines := []string{
|
||||||
"neoirc — IRC semantics over HTTP",
|
"neoirc — IRC semantics over HTTP",
|
||||||
"Version: neoirc-0.1",
|
"Version: " + versionString(),
|
||||||
"Written in Go",
|
"Written in Go",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ type Params struct {
|
|||||||
// It manages routing, middleware, and lifecycle.
|
// It manages routing, middleware, and lifecycle.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
startupTime time.Time
|
startupTime time.Time
|
||||||
exitCode int
|
|
||||||
sentryEnabled bool
|
sentryEnabled bool
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
ctx context.Context //nolint:containedctx // signal handling pattern
|
ctx context.Context //nolint:containedctx // signal handling pattern
|
||||||
@@ -71,7 +70,17 @@ func New(
|
|||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(_ context.Context) error {
|
||||||
srv.startupTime = time.Now()
|
srv.startupTime = time.Now()
|
||||||
go srv.Run() //nolint:contextcheck
|
// Configure, enable Sentry, and build the router
|
||||||
|
// synchronously so that srv.router is fully initialized
|
||||||
|
// before OnStart returns. Any HTTP traffic (including
|
||||||
|
// httptest harnesses that wrap srv as a handler) is
|
||||||
|
// therefore guaranteed to see an initialized router,
|
||||||
|
// eliminating the previous race between SetupRoutes
|
||||||
|
// and ServeHTTP.
|
||||||
|
srv.configure()
|
||||||
|
srv.enableSentry()
|
||||||
|
srv.SetupRoutes()
|
||||||
|
go srv.serve() //nolint:contextcheck
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -83,10 +92,16 @@ func New(
|
|||||||
return srv, nil
|
return srv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the server configuration, Sentry, and begins serving.
|
// Run configures the server and begins serving. It blocks
|
||||||
|
// until shutdown is signalled. Kept for external callers
|
||||||
|
// that embed the server outside fx. The fx lifecycle now
|
||||||
|
// performs setup synchronously in OnStart and invokes
|
||||||
|
// serve directly in a goroutine, so this is only used when
|
||||||
|
// the server is driven by hand.
|
||||||
func (srv *Server) Run() {
|
func (srv *Server) Run() {
|
||||||
srv.configure()
|
srv.configure()
|
||||||
srv.enableSentry()
|
srv.enableSentry()
|
||||||
|
srv.SetupRoutes()
|
||||||
srv.serve()
|
srv.serve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +142,7 @@ func (srv *Server) enableSentry() {
|
|||||||
srv.sentryEnabled = true
|
srv.sentryEnabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Server) serve() int {
|
func (srv *Server) serve() {
|
||||||
srv.ctx, srv.cancelFunc = context.WithCancel(
|
srv.ctx, srv.cancelFunc = context.WithCancel(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
)
|
)
|
||||||
@@ -152,8 +167,6 @@ func (srv *Server) serve() int {
|
|||||||
<-srv.ctx.Done()
|
<-srv.ctx.Done()
|
||||||
|
|
||||||
srv.cleanShutdown()
|
srv.cleanShutdown()
|
||||||
|
|
||||||
return srv.exitCode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Server) cleanupForExit() {
|
func (srv *Server) cleanupForExit() {
|
||||||
@@ -161,8 +174,6 @@ func (srv *Server) cleanupForExit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Server) cleanShutdown() {
|
func (srv *Server) cleanShutdown() {
|
||||||
srv.exitCode = 0
|
|
||||||
|
|
||||||
ctxShutdown, shutdownCancel := context.WithTimeout(
|
ctxShutdown, shutdownCancel := context.WithTimeout(
|
||||||
context.Background(), shutdownTimeout,
|
context.Background(), shutdownTimeout,
|
||||||
)
|
)
|
||||||
@@ -202,8 +213,6 @@ func (srv *Server) serveUntilShutdown() {
|
|||||||
Handler: srv,
|
Handler: srv,
|
||||||
}
|
}
|
||||||
|
|
||||||
srv.SetupRoutes()
|
|
||||||
|
|
||||||
srv.log.Info(
|
srv.log.Info(
|
||||||
"http begin listen", "listenaddr", listenAddr,
|
"http begin listen", "listenaddr", listenAddr,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -791,6 +791,186 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// userModeOp is a single parsed user-mode change collected
|
||||||
|
// by parseUserModeString before any DB writes happen.
|
||||||
|
type userModeOp struct {
|
||||||
|
char rune
|
||||||
|
adding bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyUserMode parses an IRC user-mode string and applies
|
||||||
|
// the resulting changes atomically. It supports multiple
|
||||||
|
// sign transitions (e.g. "+w-o", "-w+o", "+o-w+w") and
|
||||||
|
// rejects malformed input (empty string, no leading sign,
|
||||||
|
// bare sign with no mode letters, unknown mode letters,
|
||||||
|
// +o which must be set via OPER) with an IRCError. On
|
||||||
|
// failure, no persistent change is made. On success, the
|
||||||
|
// resulting mode string is returned.
|
||||||
|
func (s *Service) ApplyUserMode(
|
||||||
|
ctx context.Context,
|
||||||
|
sessionID int64,
|
||||||
|
modeStr string,
|
||||||
|
) (string, error) {
|
||||||
|
ops, err := parseUserModeString(modeStr)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, op := range ops {
|
||||||
|
if err := s.applySingleUserMode(
|
||||||
|
ctx, sessionID, op.char, op.adding,
|
||||||
|
); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.QueryUserMode(ctx, sessionID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseUserModeString validates and parses a user-mode
|
||||||
|
// string into a list of operations. The string must begin
|
||||||
|
// with '+' or '-'; subsequent '+' / '-' characters flip the
|
||||||
|
// active sign, and letters between them are applied with
|
||||||
|
// the current sign. Every letter must be a recognized user
|
||||||
|
// mode for this server, and '+o' is never allowed via MODE
|
||||||
|
// (use OPER to become operator). If any character is
|
||||||
|
// invalid, no operations are returned and an IRCError with
|
||||||
|
// ERR_UMODEUNKNOWNFLAG (501) is returned.
|
||||||
|
func parseUserModeString(
|
||||||
|
modeStr string,
|
||||||
|
) ([]userModeOp, error) {
|
||||||
|
unknownFlag := &IRCError{
|
||||||
|
Code: irc.ErrUmodeUnknownFlag,
|
||||||
|
Params: nil,
|
||||||
|
Message: "Unknown MODE flag",
|
||||||
|
}
|
||||||
|
|
||||||
|
if modeStr == "" {
|
||||||
|
return nil, unknownFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
first := modeStr[0]
|
||||||
|
if first != '+' && first != '-' {
|
||||||
|
return nil, unknownFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
ops := make([]userModeOp, 0, len(modeStr)-1)
|
||||||
|
adding := true
|
||||||
|
|
||||||
|
for _, modeChar := range modeStr {
|
||||||
|
switch modeChar {
|
||||||
|
case '+':
|
||||||
|
adding = true
|
||||||
|
case '-':
|
||||||
|
adding = false
|
||||||
|
default:
|
||||||
|
if !isKnownUserModeChar(modeChar) {
|
||||||
|
return nil, unknownFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
if modeChar == 'o' && adding {
|
||||||
|
return nil, unknownFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
ops = append(ops, userModeOp{
|
||||||
|
char: modeChar, adding: adding,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ops) == 0 {
|
||||||
|
return nil, unknownFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isKnownUserModeChar reports whether the character is a
|
||||||
|
// recognized user mode letter.
|
||||||
|
func isKnownUserModeChar(modeChar rune) bool {
|
||||||
|
switch modeChar {
|
||||||
|
case 'w', 'o':
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applySingleUserMode applies one already-validated user
|
||||||
|
// mode character to the session. parseUserModeString must
|
||||||
|
// have validated the character and sign before this runs;
|
||||||
|
// the default branch here is defence-in-depth only.
|
||||||
|
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':
|
||||||
|
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,3 +363,438 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyUserModeCaseState is the subset of session user-mode
|
||||||
|
// state the rigorous TestApplyUserMode suite asserts on. It
|
||||||
|
// mirrors the columns (oper, wallops) that the parser is
|
||||||
|
// permitted to mutate.
|
||||||
|
type applyUserModeCaseState struct {
|
||||||
|
oper bool
|
||||||
|
wallops bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyUserModeCase describes one rigor-suite case for
|
||||||
|
// Service.ApplyUserMode: the pre-call DB state, the mode
|
||||||
|
// string input, and the expected post-call observable state
|
||||||
|
// (mode string on success, IRC error code on rejection, and
|
||||||
|
// persisted session state either way).
|
||||||
|
type applyUserModeCase struct {
|
||||||
|
name string
|
||||||
|
initialState applyUserModeCaseState
|
||||||
|
modeStr string
|
||||||
|
wantModes string
|
||||||
|
wantErr bool
|
||||||
|
wantErrCode irc.IRCMessageType
|
||||||
|
wantState applyUserModeCaseState
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyUserModeCases returns every case listed in sneak's
|
||||||
|
// review of PR #96 plus a few adjacent ones. Split across
|
||||||
|
// helpers by category so each stays under funlen.
|
||||||
|
func applyUserModeCases() []applyUserModeCase {
|
||||||
|
cases := applyUserModeHappyPathCases()
|
||||||
|
cases = append(cases, applyUserModeSignTransitionCases()...)
|
||||||
|
cases = append(cases, applyUserModeMalformedCases()...)
|
||||||
|
cases = append(cases, applyUserModeUnknownLetterCases()...)
|
||||||
|
|
||||||
|
return cases
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyUserModeHappyPathCases covers valid single-char and
|
||||||
|
// multi-char-without-sign-transition mode operations.
|
||||||
|
func applyUserModeHappyPathCases() []applyUserModeCase {
|
||||||
|
return []applyUserModeCase{
|
||||||
|
{
|
||||||
|
name: "+w from empty",
|
||||||
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
modeStr: "+w",
|
||||||
|
wantModes: "+w",
|
||||||
|
wantErr: false,
|
||||||
|
wantErrCode: 0,
|
||||||
|
wantState: applyUserModeCaseState{oper: false, wallops: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "-w from +w",
|
||||||
|
initialState: applyUserModeCaseState{oper: false, wallops: true},
|
||||||
|
modeStr: "-w",
|
||||||
|
wantModes: "+",
|
||||||
|
wantErr: false,
|
||||||
|
wantErrCode: 0,
|
||||||
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "-o from +o",
|
||||||
|
initialState: applyUserModeCaseState{oper: true, wallops: false},
|
||||||
|
modeStr: "-o",
|
||||||
|
wantModes: "+",
|
||||||
|
wantErr: false,
|
||||||
|
wantErrCode: 0,
|
||||||
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "-wo from +ow",
|
||||||
|
initialState: applyUserModeCaseState{oper: true, wallops: true},
|
||||||
|
modeStr: "-wo",
|
||||||
|
wantModes: "+",
|
||||||
|
wantErr: false,
|
||||||
|
wantErrCode: 0,
|
||||||
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyUserModeSignTransitionCases covers multi-char mode
|
||||||
|
// strings where '+' and '-' flip partway through. +o is
|
||||||
|
// never legal via MODE, so strings containing it must be
|
||||||
|
// rejected atomically.
|
||||||
|
func applyUserModeSignTransitionCases() []applyUserModeCase {
|
||||||
|
return []applyUserModeCase{
|
||||||
|
{
|
||||||
|
name: "+w-o from +o",
|
||||||
|
initialState: applyUserModeCaseState{oper: true, wallops: false},
|
||||||
|
modeStr: "+w-o",
|
||||||
|
wantModes: "+w",
|
||||||
|
wantErr: false,
|
||||||
|
wantErrCode: 0,
|
||||||
|
wantState: applyUserModeCaseState{oper: false, wallops: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// +o is rejected before any op applies; wallops
|
||||||
|
// stays set.
|
||||||
|
name: "-w+o always rejects +o",
|
||||||
|
initialState: applyUserModeCaseState{oper: true, wallops: true},
|
||||||
|
modeStr: "-w+o",
|
||||||
|
wantModes: "",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
wantState: applyUserModeCaseState{oper: true, wallops: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Wallops must NOT be cleared; oper must NOT be
|
||||||
|
// cleared. Rejection is fully atomic.
|
||||||
|
name: "+o-w+w rejects because of +o",
|
||||||
|
initialState: applyUserModeCaseState{oper: true, wallops: true},
|
||||||
|
modeStr: "+o-w+w",
|
||||||
|
wantModes: "",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
wantState: applyUserModeCaseState{oper: true, wallops: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyUserModeMalformedCases covers inputs that lack a
|
||||||
|
// leading '+' or '-' and inputs that consist of bare signs
|
||||||
|
// without mode letters. All must be rejected with no side
|
||||||
|
// effects.
|
||||||
|
func applyUserModeMalformedCases() []applyUserModeCase {
|
||||||
|
return []applyUserModeCase{
|
||||||
|
{
|
||||||
|
name: "w no prefix rejects",
|
||||||
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
modeStr: "w",
|
||||||
|
wantModes: "",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Prove wallops is NOT cleared — the whole point
|
||||||
|
// of sneak's review.
|
||||||
|
name: "xw no prefix rejects (would have been" +
|
||||||
|
" silently -w before)",
|
||||||
|
initialState: applyUserModeCaseState{oper: false, wallops: true},
|
||||||
|
modeStr: "xw",
|
||||||
|
wantModes: "",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
wantState: applyUserModeCaseState{oper: false, wallops: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string rejects",
|
||||||
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
modeStr: "",
|
||||||
|
wantModes: "",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare + rejects",
|
||||||
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
modeStr: "+",
|
||||||
|
wantModes: "",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare - rejects",
|
||||||
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
modeStr: "-",
|
||||||
|
wantModes: "",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "+-+ rejects (no mode letters)",
|
||||||
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
modeStr: "+-+",
|
||||||
|
wantModes: "",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyUserModeUnknownLetterCases covers well-formed prefix
|
||||||
|
// strings that contain unknown mode letters. Rejection must
|
||||||
|
// be atomic: any valid letters before the invalid one must
|
||||||
|
// not persist.
|
||||||
|
func applyUserModeUnknownLetterCases() []applyUserModeCase {
|
||||||
|
return []applyUserModeCase{
|
||||||
|
{
|
||||||
|
name: "-x+y rejects unknown -x",
|
||||||
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
modeStr: "-x+y",
|
||||||
|
wantModes: "",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "+y-x rejects unknown +y",
|
||||||
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
modeStr: "+y-x",
|
||||||
|
wantModes: "",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "+z unknown mode rejects",
|
||||||
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
modeStr: "+z",
|
||||||
|
wantModes: "",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Wallops must NOT be set.
|
||||||
|
name: "+wz rejects whole thing; +w side effect" +
|
||||||
|
" must NOT persist",
|
||||||
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
modeStr: "+wz",
|
||||||
|
wantModes: "",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "+wo rejects whole thing; +w side effect" +
|
||||||
|
" must NOT persist",
|
||||||
|
initialState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
modeStr: "+wo",
|
||||||
|
wantModes: "",
|
||||||
|
wantErr: true,
|
||||||
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
||||||
|
wantState: applyUserModeCaseState{oper: false, wallops: false},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestApplyUserMode is the rigorous table-driven suite for
|
||||||
|
// the shared user-mode parser. It covers every case listed
|
||||||
|
// in sneak's review of PR #96 plus a few adjacent ones.
|
||||||
|
// Each case asserts the resulting mode string AND the
|
||||||
|
// persisted session state, to prove that rejected input
|
||||||
|
// leaves no side effects.
|
||||||
|
func TestApplyUserMode(t *testing.T) {
|
||||||
|
for _, testCase := range applyUserModeCases() {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
runApplyUserModeCase(t, testCase)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runApplyUserModeCase executes one applyUserModeCase: seed
|
||||||
|
// the session state, invoke ApplyUserMode, and verify both
|
||||||
|
// the returned value and the post-call persisted state.
|
||||||
|
func runApplyUserModeCase(
|
||||||
|
t *testing.T, testCase applyUserModeCase,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
sid := createSession(ctx, t, env.db, "alice")
|
||||||
|
|
||||||
|
seedApplyUserModeState(ctx, t, env.db, sid, testCase.initialState)
|
||||||
|
|
||||||
|
result, err := env.svc.ApplyUserMode(
|
||||||
|
ctx, sid, testCase.modeStr,
|
||||||
|
)
|
||||||
|
|
||||||
|
verifyApplyUserModeOutcome(t, testCase, result, err)
|
||||||
|
verifyApplyUserModeState(ctx, t, env.db, sid, testCase.wantState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedApplyUserModeState installs the pre-call session
|
||||||
|
// state described by initialState.
|
||||||
|
func seedApplyUserModeState(
|
||||||
|
ctx context.Context,
|
||||||
|
t *testing.T,
|
||||||
|
database *db.Database,
|
||||||
|
sid int64,
|
||||||
|
initialState applyUserModeCaseState,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if initialState.oper {
|
||||||
|
if err := database.SetSessionOper(
|
||||||
|
ctx, sid, true,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("init oper: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if initialState.wallops {
|
||||||
|
if err := database.SetSessionWallops(
|
||||||
|
ctx, sid, true,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("init wallops: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyApplyUserModeOutcome asserts the direct return
|
||||||
|
// value of ApplyUserMode. It dispatches to the error- or
|
||||||
|
// success-specific verifier based on wantErr.
|
||||||
|
func verifyApplyUserModeOutcome(
|
||||||
|
t *testing.T,
|
||||||
|
testCase applyUserModeCase,
|
||||||
|
result string,
|
||||||
|
err error,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if testCase.wantErr {
|
||||||
|
verifyApplyUserModeError(t, testCase, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyApplyUserModeSuccess(t, testCase, result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyApplyUserModeError checks that err is a
|
||||||
|
// *service.IRCError whose code matches wantErrCode.
|
||||||
|
func verifyApplyUserModeError(
|
||||||
|
t *testing.T, testCase applyUserModeCase, err error,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var ircErr *service.IRCError
|
||||||
|
if !errors.As(err, &ircErr) {
|
||||||
|
t.Fatalf("expected IRCError, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ircErr.Code != testCase.wantErrCode {
|
||||||
|
t.Errorf(
|
||||||
|
"code: want %d got %d",
|
||||||
|
testCase.wantErrCode, ircErr.Code,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyApplyUserModeSuccess checks that err is nil and the
|
||||||
|
// returned mode string matches wantModes.
|
||||||
|
func verifyApplyUserModeSuccess(
|
||||||
|
t *testing.T,
|
||||||
|
testCase applyUserModeCase,
|
||||||
|
result string,
|
||||||
|
err error,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != testCase.wantModes {
|
||||||
|
t.Errorf(
|
||||||
|
"modes: want %q got %q",
|
||||||
|
testCase.wantModes, result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyApplyUserModeState asserts the post-call persisted
|
||||||
|
// session state. This is the atomicity guarantee sneak
|
||||||
|
// demanded: whether the call succeeded or was rejected, the
|
||||||
|
// DB must match wantState exactly.
|
||||||
|
func verifyApplyUserModeState(
|
||||||
|
ctx context.Context,
|
||||||
|
t *testing.T,
|
||||||
|
database *db.Database,
|
||||||
|
sid int64,
|
||||||
|
wantState applyUserModeCaseState,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
gotOper, err := database.IsSessionOper(ctx, sid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read oper: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotWallops, err := database.IsSessionWallops(ctx, sid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read wallops: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotOper != wantState.oper {
|
||||||
|
t.Errorf(
|
||||||
|
"oper: want %v got %v",
|
||||||
|
wantState.oper, gotOper,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotWallops != wantState.wallops {
|
||||||
|
t.Errorf(
|
||||||
|
"wallops: want %v got %v",
|
||||||
|
wantState.wallops, gotWallops,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user