All checks were successful
check / check (push) Successful in 1m12s
Addresses the 2026-08-10 FAIL review (findings 1-7, 9, 10). KILL never terminated the victim's connection on either transport: both paths called BroadcastQuit, which deletes the session row and tells the victim's peers it quit, but leaves the victim holding a socket that looks alive and silently delivers nothing while its nick is freed for reuse. Service now owns a session-ID keyed registry of live wire connections that ircserver populates at registration, and both KILL paths go through the new Service.KillSession, which broadcasts the QUIT and then sends the victim a KILL and ERROR :Closing Link before closing its socket. The victim's relay goroutine is cancelled and its cleanup no longer re-broadcasts a QUIT for an already-deleted session. TestIntegrationKill now asserts the victim reads to EOF and is gone from NAMES and WHO, not just that an observer saw the QUIT relay. HTTP MODE <othernick> with no body answered with the requester's own modes, because the target check sat inside the mode-change branch. The check is hoisted above the query/change split, and both transports now compare nicks with EqualFold since IRC nicks are case-insensitive. Service.QueryUserMode returned "+" for a database failure, making an unreadable mode indistinguishable from an unset one; it now returns an error, and both callers surface it. db.GetUserhostInfo likewise treated every scan error as "nick not found"; only sql.ErrNoRows is skipped now. The four new unsynchronized c.nick reads this branch introduced are read through currentNick() under c.mu, and c.closed is now guarded everywhere because KILL writes it from another client's goroutine. Conn.send takes a write mutex, as a connection is now written to by three goroutines. server.Server.Run was left with no in-tree callers when its body was inlined into the fx OnStart hook; it is deleted rather than left to drift. INFO and VERSION had two implementations that had already diverged: the version string is now Service.ServerVersion and the INFO body is Service.InfoLines, used verbatim by both transports. The ctx parameters on handleVersion/handleAdmin/handleInfo/handleTime existed only to be discarded and are gone.
1072 lines
22 KiB
Go
1072 lines
22 KiB
Go
// Tests for Tier 3 utility IRC commands: USERHOST,
|
|
// VERSION, ADMIN, INFO, TIME, KILL, WALLOPS.
|
|
//
|
|
//nolint:paralleltest
|
|
package handlers_test
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// --- USERHOST ---
|
|
|
|
func TestUserhostSingleNick(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
token := tserver.createSession("alice")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "USERHOST",
|
|
bodyKey: []string{"alice"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
// Expect 302 RPL_USERHOST.
|
|
msg := findNumericWithParams(msgs, "302")
|
|
if msg == nil {
|
|
t.Fatalf(
|
|
"expected RPL_USERHOST (302), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
// Body should contain "alice" with the
|
|
// nick=+user@host format.
|
|
body := getNumericBody(msg)
|
|
if !strings.Contains(body, "alice") {
|
|
t.Fatalf(
|
|
"expected body to contain 'alice', got %q",
|
|
body,
|
|
)
|
|
}
|
|
|
|
// '+' means not away.
|
|
if !strings.Contains(body, "=+") {
|
|
t.Fatalf(
|
|
"expected not-away prefix '=+', got %q",
|
|
body,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestUserhostMultipleNicks(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
token1 := tserver.createSession("bob")
|
|
token2 := tserver.createSession("carol")
|
|
|
|
_ = token2
|
|
|
|
_, lastID := tserver.pollMessages(token1, 0)
|
|
|
|
tserver.sendCommand(token1, map[string]any{
|
|
commandKey: "USERHOST",
|
|
bodyKey: []string{"bob", "carol"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token1, lastID)
|
|
|
|
msg := findNumericWithParams(msgs, "302")
|
|
if msg == nil {
|
|
t.Fatalf(
|
|
"expected RPL_USERHOST (302), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
body := getNumericBody(msg)
|
|
if !strings.Contains(body, "bob") {
|
|
t.Fatalf(
|
|
"expected body to contain 'bob', got %q",
|
|
body,
|
|
)
|
|
}
|
|
|
|
if !strings.Contains(body, "carol") {
|
|
t.Fatalf(
|
|
"expected body to contain 'carol', got %q",
|
|
body,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestUserhostNonexistentNick(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
token := tserver.createSession("dave")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "USERHOST",
|
|
bodyKey: []string{"nobody"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
// Should still get 302 but with empty body.
|
|
msg := findNumericWithParams(msgs, "302")
|
|
if msg == nil {
|
|
t.Fatalf(
|
|
"expected RPL_USERHOST (302), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestUserhostNoParams(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
token := tserver.createSession("eve")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "USERHOST",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
// Expect 461 ERR_NEEDMOREPARAMS.
|
|
if !findNumeric(msgs, "461") {
|
|
t.Fatalf(
|
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestUserhostShowsOper(t *testing.T) {
|
|
tserver := newTestServerWithOper(t)
|
|
|
|
token := tserver.createSession("opernick")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Authenticate as oper.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "OPER",
|
|
bodyKey: []string{testOperName, testOperPassword},
|
|
})
|
|
|
|
_, lastID = tserver.pollMessages(token, lastID)
|
|
|
|
// USERHOST should show '*' for oper.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "USERHOST",
|
|
bodyKey: []string{"opernick"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
msg := findNumericWithParams(msgs, "302")
|
|
if msg == nil {
|
|
t.Fatalf(
|
|
"expected RPL_USERHOST (302), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
body := getNumericBody(msg)
|
|
if !strings.Contains(body, "opernick*=") {
|
|
t.Fatalf(
|
|
"expected oper '*' in reply, got %q",
|
|
body,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestUserhostShowsAway(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
token := tserver.createSession("awaynick")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Set away.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "AWAY",
|
|
bodyKey: []string{"gone fishing"},
|
|
})
|
|
|
|
_, lastID = tserver.pollMessages(token, lastID)
|
|
|
|
// USERHOST should show '-' for away.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "USERHOST",
|
|
bodyKey: []string{"awaynick"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
msg := findNumericWithParams(msgs, "302")
|
|
if msg == nil {
|
|
t.Fatalf(
|
|
"expected RPL_USERHOST (302), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
body := getNumericBody(msg)
|
|
if !strings.Contains(body, "=-") {
|
|
t.Fatalf(
|
|
"expected away prefix '=-' in reply, got %q",
|
|
body,
|
|
)
|
|
}
|
|
}
|
|
|
|
// --- VERSION ---
|
|
|
|
func TestVersion(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
token := tserver.createSession("frank")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "VERSION",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
// Expect 351 RPL_VERSION.
|
|
msg := findNumericWithParams(msgs, "351")
|
|
if msg == nil {
|
|
t.Fatalf(
|
|
"expected RPL_VERSION (351), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
params := getNumericParams(msg)
|
|
if len(params) == 0 {
|
|
t.Fatal("expected VERSION params, got none")
|
|
}
|
|
|
|
// First param should contain version string.
|
|
if !strings.Contains(params[0], "test") {
|
|
t.Fatalf(
|
|
"expected version to contain 'test', got %q",
|
|
params[0],
|
|
)
|
|
}
|
|
}
|
|
|
|
// --- ADMIN ---
|
|
|
|
func TestAdmin(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
token := tserver.createSession("grace")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "ADMIN",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
// Expect 256 RPL_ADMINME.
|
|
if !findNumeric(msgs, "256") {
|
|
t.Fatalf(
|
|
"expected RPL_ADMINME (256), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
// Expect 257 RPL_ADMINLOC1.
|
|
if !findNumeric(msgs, "257") {
|
|
t.Fatalf(
|
|
"expected RPL_ADMINLOC1 (257), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
// Expect 258 RPL_ADMINLOC2.
|
|
if !findNumeric(msgs, "258") {
|
|
t.Fatalf(
|
|
"expected RPL_ADMINLOC2 (258), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
// Expect 259 RPL_ADMINEMAIL.
|
|
if !findNumeric(msgs, "259") {
|
|
t.Fatalf(
|
|
"expected RPL_ADMINEMAIL (259), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// --- INFO ---
|
|
|
|
func TestInfo(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
token := tserver.createSession("hank")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "INFO",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
// Expect 371 RPL_INFO (at least one).
|
|
if !findNumeric(msgs, "371") {
|
|
t.Fatalf(
|
|
"expected RPL_INFO (371), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
// Expect 374 RPL_ENDOFINFO.
|
|
if !findNumeric(msgs, "374") {
|
|
t.Fatalf(
|
|
"expected RPL_ENDOFINFO (374), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// --- TIME ---
|
|
|
|
func TestTime(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
token := tserver.createSession("iris")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "TIME",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
// Expect 391 RPL_TIME.
|
|
msg := findNumericWithParams(msgs, "391")
|
|
if msg == nil {
|
|
t.Fatalf(
|
|
"expected RPL_TIME (391), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// --- KILL ---
|
|
|
|
func TestKillSuccess(t *testing.T) {
|
|
tserver := newTestServerWithOper(t)
|
|
|
|
// Create the victim first.
|
|
victimToken := tserver.createSession("victim")
|
|
_ = victimToken
|
|
|
|
// Create oper user.
|
|
operToken := tserver.createSession("killer")
|
|
_, lastID := tserver.pollMessages(operToken, 0)
|
|
|
|
// Authenticate as oper.
|
|
tserver.sendCommand(operToken, map[string]any{
|
|
commandKey: "OPER",
|
|
bodyKey: []string{testOperName, testOperPassword},
|
|
})
|
|
|
|
_, lastID = tserver.pollMessages(operToken, lastID)
|
|
|
|
// Kill the victim.
|
|
status, result := tserver.sendCommand(
|
|
operToken, map[string]any{
|
|
commandKey: "KILL",
|
|
bodyKey: []string{"victim", "go away"},
|
|
},
|
|
)
|
|
|
|
if status != 200 {
|
|
t.Fatalf("expected 200, got %d: %v", status, result)
|
|
}
|
|
|
|
resultStatus, _ := result[statusKey].(string)
|
|
if resultStatus != "ok" {
|
|
t.Fatalf(
|
|
"expected status ok, got %v",
|
|
result,
|
|
)
|
|
}
|
|
|
|
// Verify the victim's session is gone by trying
|
|
// to WHOIS them.
|
|
tserver.sendCommand(operToken, map[string]any{
|
|
commandKey: "WHOIS",
|
|
toKey: "victim",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(operToken, lastID)
|
|
|
|
// Should get 401 ERR_NOSUCHNICK.
|
|
if !findNumeric(msgs, "401") {
|
|
t.Fatalf(
|
|
"expected victim to be gone (401), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestKillNotOper(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
_ = tserver.createSession("target")
|
|
|
|
token := tserver.createSession("notoper")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "KILL",
|
|
bodyKey: []string{"target", "no reason"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
// Expect 481 ERR_NOPRIVILEGES.
|
|
if !findNumeric(msgs, "481") {
|
|
t.Fatalf(
|
|
"expected ERR_NOPRIVILEGES (481), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestKillNoParams(t *testing.T) {
|
|
tserver := newTestServerWithOper(t)
|
|
|
|
token := tserver.createSession("opertest")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "OPER",
|
|
bodyKey: []string{testOperName, testOperPassword},
|
|
})
|
|
|
|
_, lastID = tserver.pollMessages(token, lastID)
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "KILL",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
// Expect 461 ERR_NEEDMOREPARAMS.
|
|
if !findNumeric(msgs, "461") {
|
|
t.Fatalf(
|
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// sendOperKillCommand is a helper that creates an oper
|
|
// session, authenticates, then sends KILL with the given
|
|
// target nick, and returns the resulting messages.
|
|
func sendOperKillCommand(
|
|
t *testing.T,
|
|
tserver *testServer,
|
|
operNick, targetNick string,
|
|
) []map[string]any {
|
|
t.Helper()
|
|
|
|
token := tserver.createSession(operNick)
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "OPER",
|
|
bodyKey: []string{testOperName, testOperPassword},
|
|
})
|
|
|
|
_, lastID = tserver.pollMessages(token, lastID)
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "KILL",
|
|
bodyKey: []string{targetNick},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
return msgs
|
|
}
|
|
|
|
func TestKillNonexistentUser(t *testing.T) {
|
|
tserver := newTestServerWithOper(t)
|
|
|
|
msgs := sendOperKillCommand(
|
|
t, tserver, "opertest2", "ghost",
|
|
)
|
|
|
|
// Expect 401 ERR_NOSUCHNICK.
|
|
if !findNumeric(msgs, "401") {
|
|
t.Fatalf(
|
|
"expected ERR_NOSUCHNICK (401), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestKillSelf(t *testing.T) {
|
|
tserver := newTestServerWithOper(t)
|
|
|
|
msgs := sendOperKillCommand(
|
|
t, tserver, "selfkiller", "selfkiller",
|
|
)
|
|
|
|
// Expect 483 ERR_CANTKILLSERVER.
|
|
if !findNumeric(msgs, "483") {
|
|
t.Fatalf(
|
|
"expected ERR_CANTKILLSERVER (483), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestKillBroadcastsQuit(t *testing.T) {
|
|
tserver := newTestServerWithOper(t)
|
|
|
|
// Create victim and join a channel.
|
|
victimToken := tserver.createSession("vuser")
|
|
|
|
tserver.sendCommand(victimToken, map[string]any{
|
|
commandKey: joinCmd,
|
|
toKey: "#killtest",
|
|
})
|
|
|
|
// Create observer and join same channel.
|
|
observerToken := tserver.createSession("observer")
|
|
|
|
tserver.sendCommand(observerToken, map[string]any{
|
|
commandKey: joinCmd,
|
|
toKey: "#killtest",
|
|
})
|
|
|
|
_, lastObs := tserver.pollMessages(observerToken, 0)
|
|
|
|
// Create oper.
|
|
operToken := tserver.createSession("theoper2")
|
|
|
|
tserver.pollMessages(operToken, 0)
|
|
|
|
tserver.sendCommand(operToken, map[string]any{
|
|
commandKey: "OPER",
|
|
bodyKey: []string{testOperName, testOperPassword},
|
|
})
|
|
|
|
tserver.pollMessages(operToken, 0)
|
|
|
|
// Kill the victim.
|
|
tserver.sendCommand(operToken, map[string]any{
|
|
commandKey: "KILL",
|
|
bodyKey: []string{"vuser", "testing kill"},
|
|
})
|
|
|
|
// Observer should see a QUIT message.
|
|
msgs, _ := tserver.pollMessages(observerToken, lastObs)
|
|
|
|
foundQuit := false
|
|
|
|
for _, msg := range msgs {
|
|
cmd, _ := msg["command"].(string)
|
|
if cmd == "QUIT" {
|
|
from, _ := msg["from"].(string)
|
|
if from == "vuser" {
|
|
foundQuit = true
|
|
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if !foundQuit {
|
|
t.Fatalf(
|
|
"expected QUIT from vuser, got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// --- WALLOPS ---
|
|
|
|
func TestWallopsSuccess(t *testing.T) {
|
|
tserver := newTestServerWithOper(t)
|
|
|
|
// Create receiver with +w.
|
|
receiverToken := tserver.createSession("receiver")
|
|
|
|
tserver.sendCommand(receiverToken, map[string]any{
|
|
commandKey: "MODE",
|
|
toKey: "receiver",
|
|
bodyKey: []string{"+w"},
|
|
})
|
|
|
|
_, lastRecv := tserver.pollMessages(receiverToken, 0)
|
|
|
|
// Create oper.
|
|
operToken := tserver.createSession("walloper")
|
|
|
|
tserver.pollMessages(operToken, 0)
|
|
|
|
tserver.sendCommand(operToken, map[string]any{
|
|
commandKey: "OPER",
|
|
bodyKey: []string{testOperName, testOperPassword},
|
|
})
|
|
|
|
tserver.pollMessages(operToken, 0)
|
|
|
|
// Also set +w on oper so they receive it too.
|
|
tserver.sendCommand(operToken, map[string]any{
|
|
commandKey: "MODE",
|
|
toKey: "walloper",
|
|
bodyKey: []string{"+w"},
|
|
})
|
|
|
|
tserver.pollMessages(operToken, 0)
|
|
|
|
// Send WALLOPS.
|
|
tserver.sendCommand(operToken, map[string]any{
|
|
commandKey: "WALLOPS",
|
|
bodyKey: []string{"server going down"},
|
|
})
|
|
|
|
// Receiver should get the WALLOPS message.
|
|
msgs, _ := tserver.pollMessages(receiverToken, lastRecv)
|
|
|
|
foundWallops := false
|
|
|
|
for _, msg := range msgs {
|
|
cmd, _ := msg["command"].(string)
|
|
if cmd == "WALLOPS" {
|
|
foundWallops = true
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if !foundWallops {
|
|
t.Fatalf(
|
|
"expected WALLOPS message, got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestWallopsNotOper(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
token := tserver.createSession("notoper2")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "WALLOPS",
|
|
bodyKey: []string{"hello"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
// Expect 481 ERR_NOPRIVILEGES.
|
|
if !findNumeric(msgs, "481") {
|
|
t.Fatalf(
|
|
"expected ERR_NOPRIVILEGES (481), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestWallopsNoParams(t *testing.T) {
|
|
tserver := newTestServerWithOper(t)
|
|
|
|
token := tserver.createSession("operempty")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "OPER",
|
|
bodyKey: []string{testOperName, testOperPassword},
|
|
})
|
|
|
|
_, lastID = tserver.pollMessages(token, lastID)
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "WALLOPS",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
// Expect 461 ERR_NEEDMOREPARAMS.
|
|
if !findNumeric(msgs, "461") {
|
|
t.Fatalf(
|
|
"expected ERR_NEEDMOREPARAMS (461), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestWallopsNotReceivedWithoutW(t *testing.T) {
|
|
tserver := newTestServerWithOper(t)
|
|
|
|
// Create receiver WITHOUT +w.
|
|
receiverToken := tserver.createSession("nowallops")
|
|
_, lastRecv := tserver.pollMessages(receiverToken, 0)
|
|
|
|
// Create oper.
|
|
operToken := tserver.createSession("walloper2")
|
|
|
|
tserver.pollMessages(operToken, 0)
|
|
|
|
tserver.sendCommand(operToken, map[string]any{
|
|
commandKey: "OPER",
|
|
bodyKey: []string{testOperName, testOperPassword},
|
|
})
|
|
|
|
tserver.pollMessages(operToken, 0)
|
|
|
|
// Send WALLOPS.
|
|
tserver.sendCommand(operToken, map[string]any{
|
|
commandKey: "WALLOPS",
|
|
bodyKey: []string{"secret message"},
|
|
})
|
|
|
|
// Receiver should NOT get the WALLOPS message.
|
|
msgs, _ := tserver.pollMessages(receiverToken, lastRecv)
|
|
|
|
for _, msg := range msgs {
|
|
cmd, _ := msg["command"].(string)
|
|
if cmd == "WALLOPS" {
|
|
t.Fatalf(
|
|
"did not expect WALLOPS for user "+
|
|
"without +w, got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- User Mode +w ---
|
|
|
|
func TestUserModeSetW(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
token := tserver.createSession("wmoder")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Set +w.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "MODE",
|
|
toKey: "wmoder",
|
|
bodyKey: []string{"+w"},
|
|
})
|
|
|
|
msgs, lastID := tserver.pollMessages(token, lastID)
|
|
|
|
// Expect 221 RPL_UMODEIS with "+w".
|
|
msg := findNumericWithParams(msgs, "221")
|
|
if msg == nil {
|
|
t.Fatalf(
|
|
"expected RPL_UMODEIS (221), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
body := getNumericBody(msg)
|
|
if !strings.Contains(body, "w") {
|
|
t.Fatalf(
|
|
"expected mode string to contain 'w', got %q",
|
|
body,
|
|
)
|
|
}
|
|
|
|
// Now query mode.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "MODE",
|
|
toKey: "wmoder",
|
|
})
|
|
|
|
msgs, _ = tserver.pollMessages(token, lastID)
|
|
|
|
msg = findNumericWithParams(msgs, "221")
|
|
if msg == nil {
|
|
t.Fatalf(
|
|
"expected RPL_UMODEIS (221) on query, got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
body = getNumericBody(msg)
|
|
if !strings.Contains(body, "w") {
|
|
t.Fatalf(
|
|
"expected mode '+w' in query, got %q",
|
|
body,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestUserModeUnsetW(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
token := tserver.createSession("wunsetter")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Set +w first.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "MODE",
|
|
toKey: "wunsetter",
|
|
bodyKey: []string{"+w"},
|
|
})
|
|
|
|
_, lastID = tserver.pollMessages(token, lastID)
|
|
|
|
// Unset -w.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "MODE",
|
|
toKey: "wunsetter",
|
|
bodyKey: []string{"-w"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
msg := findNumericWithParams(msgs, "221")
|
|
if msg == nil {
|
|
t.Fatalf(
|
|
"expected RPL_UMODEIS (221), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
body := getNumericBody(msg)
|
|
if strings.Contains(body, "w") {
|
|
t.Fatalf(
|
|
"expected 'w' to be removed, got %q",
|
|
body,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestUserModeUnknownFlag(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
token := tserver.createSession("badmode")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "MODE",
|
|
toKey: "badmode",
|
|
bodyKey: []string{"+z"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
// Expect 501 ERR_UMODEUNKNOWNFLAG.
|
|
if !findNumeric(msgs, "501") {
|
|
t.Fatalf(
|
|
"expected ERR_UMODEUNKNOWNFLAG (501), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestUserModeCannotSetO(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
token := tserver.createSession("tryoper")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Try to set +o via MODE (should fail).
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "MODE",
|
|
toKey: "tryoper",
|
|
bodyKey: []string{"+o"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
// Expect 501 ERR_UMODEUNKNOWNFLAG.
|
|
if !findNumeric(msgs, "501") {
|
|
t.Fatalf(
|
|
"expected ERR_UMODEUNKNOWNFLAG (501), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestUserModeDeoper(t *testing.T) {
|
|
tserver := newTestServerWithOper(t)
|
|
|
|
token := tserver.createSession("deoper")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Authenticate as oper.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "OPER",
|
|
bodyKey: []string{testOperName, testOperPassword},
|
|
})
|
|
|
|
_, lastID = tserver.pollMessages(token, lastID)
|
|
|
|
// Use MODE -o to de-oper.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "MODE",
|
|
toKey: "deoper",
|
|
bodyKey: []string{"-o"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
msg := findNumericWithParams(msgs, "221")
|
|
if msg == nil {
|
|
t.Fatalf(
|
|
"expected RPL_UMODEIS (221), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
body := getNumericBody(msg)
|
|
if strings.Contains(body, "o") {
|
|
t.Fatalf(
|
|
"expected 'o' to be removed, got %q",
|
|
body,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestUserModeCannotChangeOtherUser(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
_ = tserver.createSession("other")
|
|
|
|
token := tserver.createSession("changer")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Try to change another user's mode.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "MODE",
|
|
toKey: "other",
|
|
bodyKey: []string{"+w"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
// Expect 502 ERR_USERSDONTMATCH.
|
|
if !findNumeric(msgs, "502") {
|
|
t.Fatalf(
|
|
"expected ERR_USERSDONTMATCH (502), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestUserModeCannotQueryOtherUser covers the MODE query
|
|
// form (no body). Without a body there is no mode change
|
|
// to reject, so a missing target check here silently
|
|
// answers with the *requester's* own modes.
|
|
func TestUserModeCannotQueryOtherUser(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
// Give the other user a mode the querier does not have,
|
|
// so leaking their modes would be visible.
|
|
otherToken := tserver.createSession("target")
|
|
_, otherLast := tserver.pollMessages(otherToken, 0)
|
|
|
|
tserver.sendCommand(otherToken, map[string]any{
|
|
commandKey: "MODE",
|
|
toKey: "target",
|
|
bodyKey: []string{"+w"},
|
|
})
|
|
tserver.pollMessages(otherToken, otherLast)
|
|
|
|
token := tserver.createSession("querier")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
// Query another user's modes — no body.
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "MODE",
|
|
toKey: "target",
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
// Expect 502 ERR_USERSDONTMATCH.
|
|
if !findNumeric(msgs, "502") {
|
|
t.Fatalf(
|
|
"expected ERR_USERSDONTMATCH (502), got %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
// And definitely not a mode reply.
|
|
if findNumeric(msgs, "221") {
|
|
t.Fatalf(
|
|
"MODE query for another user leaked "+
|
|
"RPL_UMODEIS (221): %v",
|
|
msgs,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestUserModeOwnNickIsCaseInsensitive covers the same-nick
|
|
// comparison. IRC nicks are case-insensitive, so a user
|
|
// must be able to address their own modes in any case —
|
|
// and the wire path already allows it.
|
|
func TestUserModeOwnNickIsCaseInsensitive(t *testing.T) {
|
|
tserver := newTestServer(t)
|
|
|
|
token := tserver.createSession("mixedcase")
|
|
_, lastID := tserver.pollMessages(token, 0)
|
|
|
|
tserver.sendCommand(token, map[string]any{
|
|
commandKey: "MODE",
|
|
toKey: "MixedCase",
|
|
bodyKey: []string{"+w"},
|
|
})
|
|
|
|
msgs, _ := tserver.pollMessages(token, lastID)
|
|
|
|
if findNumeric(msgs, "502") {
|
|
t.Fatalf(
|
|
"own nick in different case rejected with "+
|
|
"ERR_USERSDONTMATCH (502): %v",
|
|
msgs,
|
|
)
|
|
}
|
|
|
|
msg := findNumericWithParams(msgs, "221")
|
|
if msg == nil {
|
|
t.Fatalf(
|
|
"expected RPL_UMODEIS (221), got %v", msgs,
|
|
)
|
|
}
|
|
|
|
body := getNumericBody(msg)
|
|
if !strings.Contains(body, "w") {
|
|
t.Fatalf(
|
|
"expected 'w' to be set, got %q", body,
|
|
)
|
|
}
|
|
}
|
|
|
|
// getNumericBody extracts the body text from a numeric
|
|
// message. The body is stored as a JSON array; this
|
|
// returns the first element.
|
|
func getNumericBody(msg map[string]any) string {
|
|
raw, exists := msg["body"]
|
|
if !exists || raw == nil {
|
|
return ""
|
|
}
|
|
|
|
arr, isArr := raw.([]any)
|
|
if !isArr || len(arr) == 0 {
|
|
return ""
|
|
}
|
|
|
|
str, isStr := arr[0].(string)
|
|
if !isStr {
|
|
return ""
|
|
}
|
|
|
|
return str
|
|
}
|