4 Commits

Author SHA1 Message Date
clawbot
f24e33a310 fix: resolve 43 lint findings blocking CI
All checks were successful
check / check (push) Successful in 56s
- server.go: drop unused (*Server).serve int return (unparam) and
  remove the dead exitCode field so cleanShutdown no longer writes
  to a field nothing reads.
- service.go: rename range var ch -> modeChar in parseUserModeString
  and the isKnownUserModeChar parameter (varnamelen).
- service_test.go: rename tc -> testCase (varnamelen); lift the
  inline struct and caseState to package-level named types
  (applyUserModeCase, applyUserModeCaseState) with every field
  set explicitly (exhaustruct); split the 167-line case table into
  four categorised helpers (funlen); extract the per-case runner
  and outcome/state verifiers into helpers so TestApplyUserMode
  drops below gocognit 30 and flattens the wantErr nestif block.

No changes to .golangci.yml, Makefile, Dockerfile, or CI config.
No //nolint was used to silence any of these findings.

docker build --no-cache . passes clean: 0 lint issues, all tests
pass with -race, binary compiles.
2026-04-17 14:37:14 +00:00
clawbot
93611dad67 fix: rigorous atomic user mode parser and fix router race in server
Some checks failed
check / check (push) Failing after 23s
Mode parser (internal/service/service.go):
- Reject strings without leading + or - (e.g. "xw", "w", "") with
  ERR_UMODEUNKNOWNFLAG instead of silently treating them as "-".
- Support multi-sign transitions: +w-o, -w+o, +o-w+w, -x+y, +y-x. The
  active sign flips each time + or - is seen; subsequent letters apply
  with the active sign.
- Atomic from caller's perspective: parse the whole string to a list of
  ops first, reject the whole request on any unknown mode char, and only
  then apply ops to the DB. Partial application of +w before rejecting
  +o is gone.
- HTTP and IRC still share the same ApplyUserMode entry point.

Router race (internal/server/server.go):
- The fx OnStart hook previously spawned serve() in a goroutine that
  called SetupRoutes asynchronously, while ServeHTTP delegated to
  srv.router. Test harnesses (httptest wrapping srv as Handler) raced
  against SetupRoutes writing srv.router vs ServeHTTP reading it,
  producing the race detector failures in CI on main.
- SetupRoutes is now called synchronously inside OnStart before the
  serve goroutine starts, so srv.router is fully initialized before any
  request can reach ServeHTTP.

Tests (internal/service/service_test.go):
- Replaced the per-mode tests with a single table-driven TestApplyUserMode
  that asserts both the returned mode string and the persisted DB state
  (oper/wallops) for each case, including the malformed and multi-sign
  cases above. The +wz case seeds wallops=true to prove the whole string
  is rejected and +w is not partially applied.
2026-04-17 10:46:24 +00:00
clawbot
abe0cc2c30 refactor: unify user mode processing into shared service layer
Some checks failed
check / check (push) Failing after 2m28s
Both the HTTP API and IRC wire protocol handlers now call
service.ApplyUserMode/service.QueryUserMode for all user
mode operations. The service layer iterates mode strings
character by character (the correct IRC approach), ensuring
identical behavior regardless of transport.

Removed duplicate mode logic from internal/handlers/utility.go
(buildUserModeString, applyUserModeChange, applyModeChar) and
internal/ircserver/commands.go (buildUmodeString, inline iteration).

Added service-level tests for QueryUserMode, ApplyUserMode
(single-char, multi-char, invalid input, de-oper, +o rejection).
2026-04-02 06:48:55 -07:00
clawbot
327ff37059 fix: address review findings — dynamic version, deduplicate KILL, update README
All checks were successful
check / check (push) Successful in 1m3s
2026-04-01 14:44:27 -07:00
6 changed files with 723 additions and 303 deletions

View File

@@ -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
(256259), 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+)

View File

@@ -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
}

View File

@@ -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",
} }

View File

@@ -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,
) )

View File

@@ -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(

View File

@@ -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,
)
}
}