fix: rebase onto main, add IRC wire handlers and integration tests for Tier 3 commands
Some checks failed
check / check (push) Failing after 2m10s
Some checks failed
check / check (push) Failing after 2m10s
Rebase onto main to resolve conflicts from module path rename (sneak.berlin/go/neoirc) and integration test addition. - Update import paths in utility.go to new module path - Add IRC wire protocol handlers for VERSION, ADMIN, INFO, TIME, KILL, and WALLOPS to ircserver/commands.go - Register all 6 new commands in the IRC command dispatch map - Implement proper user MODE +w/-w support for WALLOPS - Add WALLOPS relay delivery in relay.go - Add integration tests for all 7 Tier 3 commands: USERHOST, VERSION, ADMIN, INFO, TIME, KILL, WALLOPS - Add newTestEnvWithOper helper for oper-dependent tests
This commit is contained in:
@@ -8,8 +8,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
"sneak.berlin/go/neoirc/internal/db"
|
||||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
"sneak.berlin/go/neoirc/pkg/irc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxUserhostNicks is the maximum number of nicks allowed
|
// maxUserhostNicks is the maximum number of nicks allowed
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ func (c *Conn) handleMode(
|
|||||||
if strings.HasPrefix(target, "#") {
|
if strings.HasPrefix(target, "#") {
|
||||||
c.handleChannelMode(ctx, msg)
|
c.handleChannelMode(ctx, msg)
|
||||||
} else {
|
} else {
|
||||||
c.handleUserMode(msg)
|
c.handleUserMode(ctx, msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,7 +694,10 @@ func (c *Conn) applyChannelModes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleUserMode handles MODE for users.
|
// handleUserMode handles MODE for users.
|
||||||
func (c *Conn) handleUserMode(msg *Message) {
|
func (c *Conn) handleUserMode(
|
||||||
|
ctx context.Context,
|
||||||
|
msg *Message,
|
||||||
|
) {
|
||||||
target := msg.Params[0]
|
target := msg.Params[0]
|
||||||
|
|
||||||
if !strings.EqualFold(target, c.nick) {
|
if !strings.EqualFold(target, c.nick) {
|
||||||
@@ -706,8 +709,85 @@ func (c *Conn) handleUserMode(msg *Message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// We don't support user modes beyond the basics.
|
// Mode query (no mode string).
|
||||||
c.sendNumeric(irc.RplUmodeIs, "+")
|
if len(msg.Params) < 2 { //nolint:mnd
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplUmodeIs,
|
||||||
|
c.buildUmodeString(ctx),
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modeStr := msg.Params[1]
|
||||||
|
|
||||||
|
if len(modeStr) < 2 { //nolint:mnd
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrUmodeUnknownFlag,
|
||||||
|
"Unknown MODE flag",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
adding := modeStr[0] == '+'
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -1299,3 +1379,191 @@ func (c *Conn) handleUserhost(
|
|||||||
strings.Join(replies, " "),
|
strings.Join(replies, " "),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleVersion replies with the server version string.
|
||||||
|
func (c *Conn) handleVersion(ctx context.Context) {
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
version := "neoirc-0.1"
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplVersion,
|
||||||
|
version+".", c.cfg.ServerName,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdmin replies with server admin info.
|
||||||
|
func (c *Conn) handleAdmin(ctx context.Context) {
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
srvName := c.cfg.ServerName
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplAdminMe,
|
||||||
|
srvName, "Administrative info",
|
||||||
|
)
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplAdminLoc1,
|
||||||
|
"neoirc server",
|
||||||
|
)
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplAdminLoc2,
|
||||||
|
"IRC over HTTP",
|
||||||
|
)
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplAdminEmail,
|
||||||
|
"admin@"+srvName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleInfo replies with server software info.
|
||||||
|
func (c *Conn) handleInfo(ctx context.Context) {
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
infoLines := []string{
|
||||||
|
"neoirc — IRC semantics over HTTP",
|
||||||
|
"Version: neoirc-0.1",
|
||||||
|
"Written in Go",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range infoLines {
|
||||||
|
c.sendNumeric(irc.RplInfo, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplEndOfInfo,
|
||||||
|
"End of /INFO list",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTime replies with the server's current time.
|
||||||
|
func (c *Conn) handleTime(ctx context.Context) {
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
srvName := c.cfg.ServerName
|
||||||
|
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.RplTime,
|
||||||
|
srvName, time.Now().Format(time.RFC1123),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleKillCmd forcibly disconnects a target user (oper
|
||||||
|
// only).
|
||||||
|
func (c *Conn) handleKillCmd(
|
||||||
|
ctx context.Context,
|
||||||
|
msg *Message,
|
||||||
|
) {
|
||||||
|
isOper, err := c.database.IsSessionOper(
|
||||||
|
ctx, c.sessionID,
|
||||||
|
)
|
||||||
|
if err != nil || !isOper {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNoPrivileges,
|
||||||
|
"Permission Denied- "+
|
||||||
|
"You're not an IRC operator",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msg.Params) < 1 {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNeedMoreParams,
|
||||||
|
"KILL", "Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetNick := msg.Params[0]
|
||||||
|
|
||||||
|
reason := "KILLed"
|
||||||
|
if len(msg.Params) > 1 {
|
||||||
|
reason = msg.Params[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetNick == c.nick {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrCantKillServer,
|
||||||
|
"You cannot KILL yourself",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetSID, lookupErr := c.database.GetSessionByNick(
|
||||||
|
ctx, targetNick,
|
||||||
|
)
|
||||||
|
if lookupErr != nil {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNoSuchNick,
|
||||||
|
targetNick, "No such nick/channel",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
quitReason := "Killed (" + c.nick + " (" + reason + "))"
|
||||||
|
|
||||||
|
c.svc.BroadcastQuit(
|
||||||
|
ctx, targetSID, targetNick, quitReason,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWallopsCmd broadcasts to all +w users (oper only).
|
||||||
|
func (c *Conn) handleWallopsCmd(
|
||||||
|
ctx context.Context,
|
||||||
|
msg *Message,
|
||||||
|
) {
|
||||||
|
isOper, err := c.database.IsSessionOper(
|
||||||
|
ctx, c.sessionID,
|
||||||
|
)
|
||||||
|
if err != nil || !isOper {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNoPrivileges,
|
||||||
|
"Permission Denied- "+
|
||||||
|
"You're not an IRC operator",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msg.Params) < 1 {
|
||||||
|
c.sendNumeric(
|
||||||
|
irc.ErrNeedMoreParams,
|
||||||
|
"WALLOPS", "Not enough parameters",
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
message := msg.Params[0]
|
||||||
|
|
||||||
|
wallopsSIDs, wallErr := c.database.
|
||||||
|
GetWallopsSessionIDs(ctx)
|
||||||
|
if wallErr != nil {
|
||||||
|
c.log.Error(
|
||||||
|
"get wallops sessions failed",
|
||||||
|
"error", wallErr,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(wallopsSIDs) > 0 {
|
||||||
|
body, mErr := json.Marshal([]string{message})
|
||||||
|
if mErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, _ = c.svc.FanOut(
|
||||||
|
ctx, irc.CmdWallops, c.nick, "*",
|
||||||
|
nil, body, nil, wallopsSIDs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,6 +131,12 @@ func (c *Conn) buildCommandMap() map[string]cmdHandler {
|
|||||||
c.handleCAP(msg)
|
c.handleCAP(msg)
|
||||||
},
|
},
|
||||||
"USERHOST": c.handleUserhost,
|
"USERHOST": c.handleUserhost,
|
||||||
|
irc.CmdVersion: func(ctx context.Context, _ *Message) { c.handleVersion(ctx) },
|
||||||
|
irc.CmdAdmin: func(ctx context.Context, _ *Message) { c.handleAdmin(ctx) },
|
||||||
|
irc.CmdInfo: func(ctx context.Context, _ *Message) { c.handleInfo(ctx) },
|
||||||
|
irc.CmdTime: func(ctx context.Context, _ *Message) { c.handleTime(ctx) },
|
||||||
|
irc.CmdKill: c.handleKillCmd,
|
||||||
|
irc.CmdWallops: c.handleWallopsCmd,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -760,6 +760,288 @@ func TestIntegrationTwoClients(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tier 3 Utility Command Integration Tests ──────────
|
||||||
|
|
||||||
|
// TestIntegrationUserhost verifies the USERHOST command
|
||||||
|
// returns user@host info for connected nicks.
|
||||||
|
func TestIntegrationUserhost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
bob := env.dial(t)
|
||||||
|
bob.register("bob")
|
||||||
|
|
||||||
|
// Query single nick.
|
||||||
|
alice.send("USERHOST bob")
|
||||||
|
|
||||||
|
aliceReply := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 302 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 302 ",
|
||||||
|
"RPL_USERHOST",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, "bob",
|
||||||
|
"USERHOST contains queried nick",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Query multiple nicks.
|
||||||
|
bob.send("USERHOST alice bob")
|
||||||
|
|
||||||
|
bobReply := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 302 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobReply, " 302 ",
|
||||||
|
"RPL_USERHOST multi-nick",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, bobReply, "alice",
|
||||||
|
"USERHOST multi contains alice",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, bobReply, "bob",
|
||||||
|
"USERHOST multi contains bob",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationVersion verifies the VERSION command
|
||||||
|
// returns the server version string.
|
||||||
|
func TestIntegrationVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
alice.send("VERSION")
|
||||||
|
|
||||||
|
aliceReply := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 351 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 351 ",
|
||||||
|
"RPL_VERSION",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, "neoirc",
|
||||||
|
"VERSION reply contains server name",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationAdmin verifies the ADMIN command returns
|
||||||
|
// server admin info (256–259 numerics).
|
||||||
|
func TestIntegrationAdmin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
alice.send("ADMIN")
|
||||||
|
|
||||||
|
aliceReply := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 259 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 256 ",
|
||||||
|
"RPL_ADMINME",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 257 ",
|
||||||
|
"RPL_ADMINLOC1",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 258 ",
|
||||||
|
"RPL_ADMINLOC2",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 259 ",
|
||||||
|
"RPL_ADMINEMAIL",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationInfo verifies the INFO command returns
|
||||||
|
// server information (371/374 numerics).
|
||||||
|
func TestIntegrationInfo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
alice.send("INFO")
|
||||||
|
|
||||||
|
aliceReply := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 374 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 371 ",
|
||||||
|
"RPL_INFO",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 374 ",
|
||||||
|
"RPL_ENDOFINFO",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, "neoirc",
|
||||||
|
"INFO reply mentions server name",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationTime verifies the TIME command returns
|
||||||
|
// the server time (391 numeric).
|
||||||
|
func TestIntegrationTime(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
alice.send("TIME")
|
||||||
|
|
||||||
|
aliceReply := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 391 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, " 391 ",
|
||||||
|
"RPL_TIME",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, aliceReply, "test.irc",
|
||||||
|
"TIME reply includes server name",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationKill verifies the KILL command: oper can
|
||||||
|
// kill a user, non-oper cannot.
|
||||||
|
func TestIntegrationKill(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnvWithOper(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
bob := env.dial(t)
|
||||||
|
bob.register("bob")
|
||||||
|
|
||||||
|
// Both join a channel so KILL's QUIT is visible.
|
||||||
|
alice.joinAndDrain("#killtest")
|
||||||
|
bob.joinAndDrain("#killtest")
|
||||||
|
|
||||||
|
// Drain alice's view of bob's join.
|
||||||
|
alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "JOIN") &&
|
||||||
|
strings.Contains(l, "bob")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Non-oper KILL should fail.
|
||||||
|
alice.send("KILL bob :nope")
|
||||||
|
|
||||||
|
aliceKillFail := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 481 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceKillFail, " 481 ",
|
||||||
|
"ERR_NOPRIVILEGES for non-oper KILL",
|
||||||
|
)
|
||||||
|
|
||||||
|
// alice becomes oper.
|
||||||
|
alice.send("OPER testoper testpass")
|
||||||
|
alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 381 ")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Oper KILL should succeed.
|
||||||
|
alice.send("KILL bob :bad behavior")
|
||||||
|
|
||||||
|
// alice should see bob's QUIT relay.
|
||||||
|
aliceSeesQuit := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, "QUIT") &&
|
||||||
|
strings.Contains(l, "bob")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceSeesQuit, "Killed",
|
||||||
|
"KILL reason in QUIT message",
|
||||||
|
)
|
||||||
|
|
||||||
|
// KILL nonexistent nick.
|
||||||
|
alice.send("KILL nobody123 :gone")
|
||||||
|
|
||||||
|
aliceNoSuch := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 401 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceNoSuch, " 401 ",
|
||||||
|
"ERR_NOSUCHNICK for KILL missing target",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationWallops verifies the WALLOPS command:
|
||||||
|
// oper can broadcast to +w users.
|
||||||
|
func TestIntegrationWallops(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnvWithOper(t)
|
||||||
|
|
||||||
|
alice := env.dial(t)
|
||||||
|
alice.register("alice")
|
||||||
|
|
||||||
|
bob := env.dial(t)
|
||||||
|
bob.register("bob")
|
||||||
|
|
||||||
|
// Non-oper WALLOPS should fail.
|
||||||
|
alice.send("WALLOPS :test broadcast")
|
||||||
|
|
||||||
|
aliceWallopsFail := alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 481 ")
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, aliceWallopsFail, " 481 ",
|
||||||
|
"ERR_NOPRIVILEGES for non-oper WALLOPS",
|
||||||
|
)
|
||||||
|
|
||||||
|
// alice becomes oper.
|
||||||
|
alice.send("OPER testoper testpass")
|
||||||
|
alice.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 381 ")
|
||||||
|
})
|
||||||
|
|
||||||
|
// bob sets +w to receive wallops.
|
||||||
|
bob.send("MODE bob +w")
|
||||||
|
bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(l, " 221 ")
|
||||||
|
})
|
||||||
|
|
||||||
|
// alice sends WALLOPS.
|
||||||
|
alice.send("WALLOPS :important announcement")
|
||||||
|
|
||||||
|
// bob (who has +w) should receive it.
|
||||||
|
bobWallops := bob.readUntil(func(l string) bool {
|
||||||
|
return strings.Contains(
|
||||||
|
l, "important announcement",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
assertContains(
|
||||||
|
t, bobWallops, "important announcement",
|
||||||
|
"bob receives WALLOPS message",
|
||||||
|
)
|
||||||
|
assertContains(
|
||||||
|
t, bobWallops, "WALLOPS",
|
||||||
|
"message is WALLOPS command",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// TestIntegrationModeSecret tests +s (secret) channel
|
// TestIntegrationModeSecret tests +s (secret) channel
|
||||||
// mode — verifies that +s can be set and the mode is
|
// mode — verifies that +s can be set and the mode is
|
||||||
// reflected in MODE queries.
|
// reflected in MODE queries.
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ func (c *Conn) deliverIRCMessage(
|
|||||||
c.deliverKickMsg(msg, text)
|
c.deliverKickMsg(msg, text)
|
||||||
case command == "INVITE":
|
case command == "INVITE":
|
||||||
c.deliverInviteMsg(msg, text)
|
c.deliverInviteMsg(msg, text)
|
||||||
|
case command == irc.CmdWallops:
|
||||||
|
c.deliverWallops(msg, text)
|
||||||
case command == irc.CmdMode:
|
case command == irc.CmdMode:
|
||||||
c.deliverMode(msg, text)
|
c.deliverMode(msg, text)
|
||||||
case command == irc.CmdPing:
|
case command == irc.CmdPing:
|
||||||
@@ -305,6 +307,18 @@ func (c *Conn) deliverInviteMsg(
|
|||||||
c.sendFromServer("NOTICE", c.nick, text)
|
c.sendFromServer("NOTICE", c.nick, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deliverWallops sends a WALLOPS notification.
|
||||||
|
func (c *Conn) deliverWallops(
|
||||||
|
msg *db.IRCMessage,
|
||||||
|
text string,
|
||||||
|
) {
|
||||||
|
prefix := msg.From + "!" + msg.From + "@*"
|
||||||
|
|
||||||
|
c.send(FormatMessage(
|
||||||
|
prefix, irc.CmdWallops, text,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
// deliverMode sends a MODE change notification.
|
// deliverMode sends a MODE change notification.
|
||||||
func (c *Conn) deliverMode(
|
func (c *Conn) deliverMode(
|
||||||
msg *db.IRCMessage,
|
msg *db.IRCMessage,
|
||||||
|
|||||||
@@ -112,6 +112,87 @@ func newTestEnv(t *testing.T) *testEnv {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newTestEnvWithOper creates a test environment with oper
|
||||||
|
// credentials configured.
|
||||||
|
func newTestEnvWithOper(t *testing.T) *testEnv {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dsn := fmt.Sprintf(
|
||||||
|
"file:%s?mode=memory&cache=shared&_journal_mode=WAL",
|
||||||
|
t.Name(),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn, err := sql.Open("sqlite", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
_, err = conn.ExecContext(
|
||||||
|
t.Context(), "PRAGMA foreign_keys = ON",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pragma: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
database := db.NewTestDatabaseFromConn(conn)
|
||||||
|
|
||||||
|
err = database.RunMigrations(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
brk := broker.New()
|
||||||
|
|
||||||
|
cfg := &config.Config{ //nolint:exhaustruct
|
||||||
|
ServerName: "test.irc",
|
||||||
|
MOTD: "Welcome to test IRC",
|
||||||
|
OperName: "testoper",
|
||||||
|
OperPassword: "testpass",
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := listener.Addr().String()
|
||||||
|
|
||||||
|
err = listener.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close listener: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log := slog.New(slog.NewTextHandler(
|
||||||
|
os.Stderr,
|
||||||
|
&slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct
|
||||||
|
))
|
||||||
|
|
||||||
|
srv := ircserver.NewTestServer(log, cfg, database, brk)
|
||||||
|
|
||||||
|
err = srv.Start(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("start irc server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
srv.Stop()
|
||||||
|
|
||||||
|
err := conn.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("close db: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return &testEnv{
|
||||||
|
database: database,
|
||||||
|
brk: brk,
|
||||||
|
cfg: cfg,
|
||||||
|
srv: srv,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// dial connects to the test server.
|
// dial connects to the test server.
|
||||||
func (env *testEnv) dial(t *testing.T) *testClient {
|
func (env *testEnv) dial(t *testing.T) *testClient {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
Reference in New Issue
Block a user