USERHOST, VERSION, ADMIN, INFO, TIME, KILL and WALLOPS on both the HTTP API and the IRC listener, and user mode +w. Model: opus-5-5
This commit is contained in:
+322
-14
@@ -10,11 +10,14 @@ import (
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/neoirc/internal/broker"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
"sneak.berlin/go/neoirc/internal/db"
|
||||
"sneak.berlin/go/neoirc/internal/globals"
|
||||
"sneak.berlin/go/neoirc/internal/logger"
|
||||
"sneak.berlin/go/neoirc/pkg/irc"
|
||||
)
|
||||
@@ -33,23 +36,41 @@ type Params struct {
|
||||
Config *config.Config
|
||||
Database *db.Database
|
||||
Broker *broker.Broker
|
||||
Globals *globals.Globals
|
||||
}
|
||||
|
||||
// WireConn is a live client connection that a transport
|
||||
// registers with the service so that commands such as KILL
|
||||
// can reach it. The IRC wire server registers one per
|
||||
// registered connection; the HTTP transport has no
|
||||
// long-lived socket and registers nothing.
|
||||
type WireConn interface {
|
||||
// Disconnect terminates the connection, telling the
|
||||
// client why before closing the socket.
|
||||
Disconnect(reason string)
|
||||
}
|
||||
|
||||
// Service provides shared business logic for IRC commands.
|
||||
type Service struct {
|
||||
db *db.Database
|
||||
broker *broker.Broker
|
||||
config *config.Config
|
||||
log *slog.Logger
|
||||
db *db.Database
|
||||
broker *broker.Broker
|
||||
config *config.Config
|
||||
globals *globals.Globals
|
||||
log *slog.Logger
|
||||
|
||||
wireMu sync.Mutex
|
||||
wireConns map[int64]WireConn
|
||||
}
|
||||
|
||||
// New creates a new Service.
|
||||
func New(params Params) *Service {
|
||||
return &Service{
|
||||
db: params.Database,
|
||||
broker: params.Broker,
|
||||
config: params.Config,
|
||||
log: params.Logger.Get(),
|
||||
return &Service{ //nolint:exhaustruct // mutex zero value
|
||||
db: params.Database,
|
||||
broker: params.Broker,
|
||||
config: params.Config,
|
||||
globals: params.Globals,
|
||||
log: params.Logger.Get(),
|
||||
wireConns: make(map[int64]WireConn),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,13 +80,105 @@ func NewTestService(
|
||||
database *db.Database,
|
||||
brk *broker.Broker,
|
||||
cfg *config.Config,
|
||||
globs *globals.Globals,
|
||||
log *slog.Logger,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: database,
|
||||
broker: brk,
|
||||
config: cfg,
|
||||
log: log,
|
||||
return &Service{ //nolint:exhaustruct // mutex zero value
|
||||
db: database,
|
||||
broker: brk,
|
||||
config: cfg,
|
||||
globals: globs,
|
||||
log: log,
|
||||
wireConns: make(map[int64]WireConn),
|
||||
}
|
||||
}
|
||||
|
||||
// ServerVersion returns the canonical server version string
|
||||
// used by every transport, e.g. "neoirc-1.2.3". Both the
|
||||
// IRC wire protocol and the HTTP API must report the same
|
||||
// string, so this is the only place it is built.
|
||||
func (s *Service) ServerVersion() string {
|
||||
name := "neoirc"
|
||||
ver := "dev"
|
||||
|
||||
if s.globals != nil {
|
||||
if s.globals.Appname != "" {
|
||||
name = s.globals.Appname
|
||||
}
|
||||
|
||||
if s.globals.Version != "" {
|
||||
ver = s.globals.Version
|
||||
}
|
||||
}
|
||||
|
||||
return name + "-" + ver
|
||||
}
|
||||
|
||||
// InfoLines returns the RPL_INFO body. Both transports
|
||||
// send exactly these lines so that INFO does not diverge
|
||||
// between the wire protocol and the HTTP API.
|
||||
func (s *Service) InfoLines() []string {
|
||||
started := "unknown"
|
||||
if s.globals != nil && !s.globals.StartTime.IsZero() {
|
||||
started = s.globals.StartTime.Format(time.RFC1123)
|
||||
}
|
||||
|
||||
return []string{
|
||||
"neoirc — IRC semantics over HTTP",
|
||||
"Version: " + s.ServerVersion(),
|
||||
"Written in Go",
|
||||
"Started: " + started,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterWireConn associates a live wire connection with
|
||||
// its session ID so that KillSession can reach it.
|
||||
func (s *Service) RegisterWireConn(
|
||||
sessionID int64,
|
||||
conn WireConn,
|
||||
) {
|
||||
s.wireMu.Lock()
|
||||
defer s.wireMu.Unlock()
|
||||
|
||||
s.wireConns[sessionID] = conn
|
||||
}
|
||||
|
||||
// UnregisterWireConn removes the association created by
|
||||
// RegisterWireConn. It is a no-op if the session has
|
||||
// already been rebound to a different connection.
|
||||
func (s *Service) UnregisterWireConn(
|
||||
sessionID int64,
|
||||
conn WireConn,
|
||||
) {
|
||||
s.wireMu.Lock()
|
||||
defer s.wireMu.Unlock()
|
||||
|
||||
if s.wireConns[sessionID] == conn {
|
||||
delete(s.wireConns, sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// KillSession forcibly removes a user from the server: the
|
||||
// victim's channel peers are told via QUIT, the victim's
|
||||
// session is deleted, and any live wire connection it holds
|
||||
// is disconnected. Both the IRC KILL command and the HTTP
|
||||
// KILL endpoint route through here so the two transports
|
||||
// cannot diverge.
|
||||
func (s *Service) KillSession(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
nick, reason string,
|
||||
) {
|
||||
s.BroadcastQuit(ctx, sessionID, nick, reason)
|
||||
|
||||
// A session with no registered wire connection (an
|
||||
// HTTP-only client) has nothing left to disconnect.
|
||||
s.wireMu.Lock()
|
||||
conn := s.wireConns[sessionID]
|
||||
s.wireMu.Unlock()
|
||||
|
||||
if conn != nil {
|
||||
conn.Disconnect(reason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -792,6 +905,201 @@ func (s *Service) QueryChannelMode(
|
||||
return modes + modeParams
|
||||
}
|
||||
|
||||
// QueryUserMode returns the current user mode string for
|
||||
// the given session (e.g. "+ow", "+w", "+"). A database
|
||||
// failure is returned rather than being reported as an
|
||||
// unset flag: an unreadable mode is not the same as an
|
||||
// absent one, and reporting "+" for either would tell the
|
||||
// user they are de-opered when the truth is unknown.
|
||||
func (s *Service) QueryUserMode(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
) (string, error) {
|
||||
modes := "+"
|
||||
|
||||
isOper, err := s.db.IsSessionOper(ctx, sessionID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"query oper flag: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if isOper {
|
||||
modes += "o"
|
||||
}
|
||||
|
||||
isWallops, err := s.db.IsSessionWallops(
|
||||
ctx, sessionID,
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"query wallops flag: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if isWallops {
|
||||
modes += "w"
|
||||
}
|
||||
|
||||
return modes, nil
|
||||
}
|
||||
|
||||
// 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: parsing happens
|
||||
// before any write, and the writes themselves run inside a
|
||||
// single database transaction that is rolled back whole if
|
||||
// any statement fails. 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
|
||||
}
|
||||
|
||||
wallops, oper, err := collapseUserModeOps(ops)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := s.db.SetSessionUserModes(
|
||||
ctx, sessionID, wallops, oper,
|
||||
); err != nil {
|
||||
s.log.Error(
|
||||
"apply user modes failed", "error", err,
|
||||
)
|
||||
|
||||
return "", fmt.Errorf("apply user modes: %w", err)
|
||||
}
|
||||
|
||||
return s.QueryUserMode(ctx, sessionID)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// collapseUserModeOps reduces an already-parsed operation
|
||||
// list to the final desired value of each user mode flag.
|
||||
// A nil result for a flag means the mode string never
|
||||
// mentioned it, so it must be left untouched. Later
|
||||
// operations win over earlier ones for the same letter
|
||||
// (e.g. "+w-w" ends with wallops off), which matches the
|
||||
// left-to-right semantics of applying each op in turn.
|
||||
// parseUserModeString must have validated every character
|
||||
// and sign before this runs; the default branch here is
|
||||
// defence-in-depth only.
|
||||
func collapseUserModeOps(
|
||||
ops []userModeOp,
|
||||
) (*bool, *bool, error) {
|
||||
unknownFlag := &IRCError{
|
||||
Code: irc.ErrUmodeUnknownFlag,
|
||||
Params: nil,
|
||||
Message: "Unknown MODE flag",
|
||||
}
|
||||
|
||||
var wallops, oper *bool
|
||||
|
||||
for _, modeOp := range ops {
|
||||
switch modeOp.char {
|
||||
case 'w':
|
||||
val := modeOp.adding
|
||||
wallops = &val
|
||||
case 'o':
|
||||
if modeOp.adding {
|
||||
return nil, nil, unknownFlag
|
||||
}
|
||||
|
||||
val := false
|
||||
oper = &val
|
||||
default:
|
||||
return nil, nil, unknownFlag
|
||||
}
|
||||
}
|
||||
|
||||
return wallops, oper, nil
|
||||
}
|
||||
|
||||
// broadcastNickChange notifies channel peers of a nick
|
||||
// change.
|
||||
func (s *Service) broadcastNickChange(
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxtest"
|
||||
@@ -55,9 +56,10 @@ func newTestEnv(t *testing.T) *testEnv {
|
||||
app := fxtest.New(t,
|
||||
fx.Provide(
|
||||
func() *globals.Globals {
|
||||
return &globals.Globals{ //nolint:exhaustruct
|
||||
Appname: "neoirc-test",
|
||||
Version: "test",
|
||||
return &globals.Globals{
|
||||
Appname: "neoirc-test",
|
||||
Version: "test",
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
},
|
||||
logger.New,
|
||||
@@ -363,3 +365,450 @@ func TestSendChannelMessage_Moderated(t *testing.T) {
|
||||
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, err := env.svc.QueryUserMode(ctx, sid)
|
||||
if err != nil {
|
||||
t.Fatalf("query user mode: %v", err)
|
||||
}
|
||||
|
||||
if modes != "+" {
|
||||
t.Errorf("expected +, got %s", modes)
|
||||
}
|
||||
|
||||
// Set wallops.
|
||||
_ = env.db.SetSessionWallops(ctx, sid, true)
|
||||
|
||||
modes, err = env.svc.QueryUserMode(ctx, sid)
|
||||
if err != nil {
|
||||
t.Fatalf("query user mode: %v", err)
|
||||
}
|
||||
|
||||
if modes != "+w" {
|
||||
t.Errorf("expected +w, got %s", modes)
|
||||
}
|
||||
|
||||
// Set oper.
|
||||
_ = env.db.SetSessionOper(ctx, sid, true)
|
||||
|
||||
modes, err = env.svc.QueryUserMode(ctx, sid)
|
||||
if err != nil {
|
||||
t.Fatalf("query user mode: %v", err)
|
||||
}
|
||||
|
||||
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