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
1196 lines
28 KiB
Go
1196 lines
28 KiB
Go
package ircserver_test
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// TestIntegrationTwoClients is a comprehensive integration
|
||
// test that spawns the IRC server programmatically, connects
|
||
// two real TCP clients, and verifies all major IRC features
|
||
// including cross-client message delivery.
|
||
//
|
||
// The test runs sequentially through IRC features because
|
||
// both clients share the same channel state. Each section
|
||
// builds on the previous one (e.g. alice and bob must be
|
||
// JOINed before PRIVMSG can be tested).
|
||
//
|
||
//nolint:cyclop,funlen,maintidx // integration test
|
||
func TestIntegrationTwoClients(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
env := newTestEnv(t)
|
||
|
||
alice := env.dial(t)
|
||
bob := env.dial(t)
|
||
|
||
// ── Registration ──────────────────────────────────
|
||
|
||
aliceWelcome := alice.register("alice")
|
||
assertContains(
|
||
t, aliceWelcome, " 001 ", "RPL_WELCOME alice",
|
||
)
|
||
assertContains(
|
||
t, aliceWelcome, " 002 ", "RPL_YOURHOST alice",
|
||
)
|
||
assertContains(
|
||
t, aliceWelcome, " 003 ", "RPL_CREATED alice",
|
||
)
|
||
assertContains(
|
||
t, aliceWelcome, " 004 ", "RPL_MYINFO alice",
|
||
)
|
||
assertContains(
|
||
t, aliceWelcome, "alice",
|
||
"nick in welcome burst",
|
||
)
|
||
|
||
bobWelcome := bob.register("bob")
|
||
assertContains(
|
||
t, bobWelcome, " 001 ", "RPL_WELCOME bob",
|
||
)
|
||
assertContains(
|
||
t, bobWelcome, "bob",
|
||
"nick in welcome burst",
|
||
)
|
||
|
||
// ── JOIN and cross-client visibility ──────────────
|
||
|
||
alice.send("JOIN #integration")
|
||
aliceJoinLines := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 366 ")
|
||
})
|
||
assertContains(
|
||
t, aliceJoinLines, "JOIN",
|
||
"alice receives JOIN echo",
|
||
)
|
||
assertContains(
|
||
t, aliceJoinLines, " 366 ",
|
||
"RPL_ENDOFNAMES for alice",
|
||
)
|
||
|
||
bob.send("JOIN #integration")
|
||
bobJoinLines := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 366 ")
|
||
})
|
||
assertContains(
|
||
t, bobJoinLines, "JOIN",
|
||
"bob receives JOIN echo",
|
||
)
|
||
|
||
// Alice should see bob's JOIN via relay.
|
||
aliceSeesBob := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "JOIN") &&
|
||
strings.Contains(l, "bob")
|
||
})
|
||
assertContains(
|
||
t, aliceSeesBob, "bob",
|
||
"alice sees bob's JOIN",
|
||
)
|
||
|
||
// ── PRIVMSG (channel) — alice to bob ──────────────
|
||
|
||
alice.send("PRIVMSG #integration :hello from alice")
|
||
|
||
bobGetsMsg := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "hello from alice")
|
||
})
|
||
assertContains(
|
||
t, bobGetsMsg, "hello from alice",
|
||
"bob receives alice's channel message",
|
||
)
|
||
|
||
// ── PRIVMSG (channel) — bob to alice ──────────────
|
||
|
||
bob.send("PRIVMSG #integration :hello from bob")
|
||
|
||
aliceGetsMsg := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "hello from bob")
|
||
})
|
||
assertContains(
|
||
t, aliceGetsMsg, "hello from bob",
|
||
"alice receives bob's channel message",
|
||
)
|
||
|
||
// ── PRIVMSG (DM) — alice to bob ──────────────────
|
||
|
||
alice.send("PRIVMSG bob :secret message")
|
||
|
||
bobDM := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "secret message")
|
||
})
|
||
assertContains(
|
||
t, bobDM, "secret message",
|
||
"bob receives alice's DM",
|
||
)
|
||
assertContains(
|
||
t, bobDM, "alice",
|
||
"DM from field is alice",
|
||
)
|
||
|
||
// ── PRIVMSG (DM) — bob to alice ──────────────────
|
||
|
||
bob.send("PRIVMSG alice :reply to you")
|
||
|
||
aliceDM := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "reply to you")
|
||
})
|
||
assertContains(
|
||
t, aliceDM, "reply to you",
|
||
"alice receives bob's DM",
|
||
)
|
||
|
||
// ── NOTICE (channel) ──────────────────────────────
|
||
|
||
alice.send("NOTICE #integration :notice msg")
|
||
|
||
bobNotice := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "notice msg")
|
||
})
|
||
assertContains(
|
||
t, bobNotice, "NOTICE",
|
||
"bob receives NOTICE command",
|
||
)
|
||
assertContains(
|
||
t, bobNotice, "notice msg",
|
||
"bob receives NOTICE text",
|
||
)
|
||
|
||
// ── NOTICE (DM) ──────────────────────────────────
|
||
|
||
bob.send("NOTICE alice :dm notice")
|
||
|
||
aliceNotice := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "dm notice")
|
||
})
|
||
assertContains(
|
||
t, aliceNotice, "dm notice",
|
||
"alice receives DM NOTICE",
|
||
)
|
||
|
||
// ── TOPIC ─────────────────────────────────────────
|
||
// alice is the channel creator so she is +o.
|
||
|
||
alice.send("TOPIC #integration :Integration Test Topic")
|
||
|
||
aliceTopic := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(
|
||
l, "Integration Test Topic",
|
||
)
|
||
})
|
||
assertContains(
|
||
t, aliceTopic, "Integration Test Topic",
|
||
"alice sees TOPIC echo",
|
||
)
|
||
|
||
bobTopic := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(
|
||
l, "Integration Test Topic",
|
||
)
|
||
})
|
||
assertContains(
|
||
t, bobTopic, "Integration Test Topic",
|
||
"bob receives TOPIC change",
|
||
)
|
||
|
||
// ── MODE (query) ──────────────────────────────────
|
||
|
||
alice.send("MODE #integration")
|
||
|
||
aliceMode := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 324 ")
|
||
})
|
||
assertContains(
|
||
t, aliceMode, " 324 ",
|
||
"RPL_CHANNELMODEIS",
|
||
)
|
||
|
||
// ── MODE (+m moderated, then -m) ──────────────────
|
||
|
||
alice.send("MODE #integration +m")
|
||
|
||
aliceModeM := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "MODE") &&
|
||
strings.Contains(l, "+m")
|
||
})
|
||
assertContains(
|
||
t, aliceModeM, "+m",
|
||
"alice sees MODE +m echo",
|
||
)
|
||
|
||
bobModeM := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "+m")
|
||
})
|
||
assertContains(
|
||
t, bobModeM, "+m",
|
||
"bob sees MODE +m relay",
|
||
)
|
||
|
||
// Revert moderated mode.
|
||
alice.send("MODE #integration -m")
|
||
alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "-m")
|
||
})
|
||
bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "-m")
|
||
})
|
||
|
||
// ── MODE (+v voice, then -v) ──────────────────────
|
||
|
||
alice.send("MODE #integration +v bob")
|
||
|
||
aliceVoice := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "+v")
|
||
})
|
||
assertContains(
|
||
t, aliceVoice, "+v",
|
||
"alice sees +v echo",
|
||
)
|
||
|
||
bobVoice := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "+v")
|
||
})
|
||
assertContains(
|
||
t, bobVoice, "+v",
|
||
"bob receives +v relay",
|
||
)
|
||
|
||
// Remove voice.
|
||
alice.send("MODE #integration -v bob")
|
||
alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "-v")
|
||
})
|
||
bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "-v")
|
||
})
|
||
|
||
// ── NAMES ─────────────────────────────────────────
|
||
|
||
alice.send("NAMES #integration")
|
||
|
||
aliceNames := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 366 ")
|
||
})
|
||
assertContains(
|
||
t, aliceNames, " 353 ",
|
||
"RPL_NAMREPLY",
|
||
)
|
||
assertContains(
|
||
t, aliceNames, " 366 ",
|
||
"RPL_ENDOFNAMES",
|
||
)
|
||
|
||
// Both nicks should appear in the name list.
|
||
foundBothNames := false
|
||
for _, line := range aliceNames {
|
||
if strings.Contains(line, " 353 ") &&
|
||
strings.Contains(line, "alice") &&
|
||
strings.Contains(line, "bob") {
|
||
foundBothNames = true
|
||
|
||
break
|
||
}
|
||
}
|
||
|
||
if !foundBothNames {
|
||
t.Error("NAMES reply should list both alice and bob")
|
||
}
|
||
|
||
// ── LIST ──────────────────────────────────────────
|
||
|
||
alice.send("LIST")
|
||
|
||
aliceList := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 323 ")
|
||
})
|
||
assertContains(
|
||
t, aliceList, " 322 ",
|
||
"RPL_LIST entry",
|
||
)
|
||
assertContains(
|
||
t, aliceList, "#integration",
|
||
"LIST includes #integration",
|
||
)
|
||
assertContains(
|
||
t, aliceList, " 323 ", //nolint:misspell // IRC RPL_LISTEND
|
||
"RPL_LISTEND", //nolint:misspell // IRC term
|
||
)
|
||
|
||
// ── WHO ───────────────────────────────────────────
|
||
|
||
bob.send("WHO #integration")
|
||
|
||
bobWho := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 315 ")
|
||
})
|
||
assertContains(
|
||
t, bobWho, " 352 ",
|
||
"RPL_WHOREPLY",
|
||
)
|
||
assertContains(
|
||
t, bobWho, " 315 ",
|
||
"RPL_ENDOFWHO",
|
||
)
|
||
|
||
// ── WHOIS ─────────────────────────────────────────
|
||
|
||
alice.send("WHOIS bob")
|
||
|
||
aliceWhois := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 318 ")
|
||
})
|
||
assertContains(
|
||
t, aliceWhois, " 311 ",
|
||
"RPL_WHOISUSER",
|
||
)
|
||
assertContains(
|
||
t, aliceWhois, " 312 ",
|
||
"RPL_WHOISSERVER",
|
||
)
|
||
assertContains(
|
||
t, aliceWhois, " 318 ",
|
||
"RPL_ENDOFWHOIS",
|
||
)
|
||
|
||
// ── WHOIS with channels ───────────────────────────
|
||
|
||
bob.send("WHOIS alice")
|
||
|
||
bobWhois := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 318 ")
|
||
})
|
||
assertContains(
|
||
t, bobWhois, " 319 ",
|
||
"RPL_WHOISCHANNELS",
|
||
)
|
||
assertContains(
|
||
t, bobWhois, "#integration",
|
||
"WHOIS shows #integration channel",
|
||
)
|
||
|
||
// ── LUSERS ────────────────────────────────────────
|
||
|
||
alice.send("LUSERS")
|
||
|
||
aliceLusers := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 255 ")
|
||
})
|
||
assertContains(
|
||
t, aliceLusers, " 251 ",
|
||
"RPL_LUSERCLIENT",
|
||
)
|
||
assertContains(
|
||
t, aliceLusers, " 255 ",
|
||
"RPL_LUSERME",
|
||
)
|
||
|
||
// ── NICK change ───────────────────────────────────
|
||
|
||
bob.send("NICK bobby")
|
||
|
||
bobNick := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "NICK") &&
|
||
strings.Contains(l, "bobby")
|
||
})
|
||
assertContains(
|
||
t, bobNick, "bobby",
|
||
"bob sees NICK change to bobby",
|
||
)
|
||
|
||
// alice should see the nick change relayed.
|
||
aliceNick := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "bobby")
|
||
})
|
||
assertContains(
|
||
t, aliceNick, "NICK",
|
||
"alice sees NICK command",
|
||
)
|
||
assertContains(
|
||
t, aliceNick, "bobby",
|
||
"alice sees new nick bobby",
|
||
)
|
||
|
||
// Change it back for remaining tests.
|
||
bob.send("NICK bob")
|
||
bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "bob")
|
||
})
|
||
alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "NICK") &&
|
||
strings.Contains(l, "bob")
|
||
})
|
||
|
||
// ── Duplicate NICK ────────────────────────────────
|
||
|
||
bob.send("NICK alice")
|
||
|
||
bobDupNick := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 433 ")
|
||
})
|
||
assertContains(
|
||
t, bobDupNick, " 433 ",
|
||
"ERR_NICKNAMEINUSE",
|
||
)
|
||
|
||
// ── KICK ──────────────────────────────────────────
|
||
// alice is op; she kicks bob.
|
||
|
||
alice.send("KICK #integration bob :testing kick")
|
||
|
||
aliceKick := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "KICK")
|
||
})
|
||
assertContains(
|
||
t, aliceKick, "KICK",
|
||
"alice sees KICK echo",
|
||
)
|
||
assertContains(
|
||
t, aliceKick, "bob",
|
||
"KICK mentions bob",
|
||
)
|
||
|
||
bobKick := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "KICK")
|
||
})
|
||
assertContains(
|
||
t, bobKick, "KICK",
|
||
"bob receives KICK",
|
||
)
|
||
assertContains(
|
||
t, bobKick, "testing kick",
|
||
"KICK reason is relayed",
|
||
)
|
||
|
||
// bob rejoins.
|
||
bob.joinAndDrain("#integration")
|
||
|
||
// Drain alice's view of the rejoin.
|
||
alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "JOIN") &&
|
||
strings.Contains(l, "bob")
|
||
})
|
||
|
||
// ── KICK non-op should fail ───────────────────────
|
||
|
||
bob.send("KICK #integration alice :nope")
|
||
|
||
bobKickFail := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 482 ")
|
||
})
|
||
assertContains(
|
||
t, bobKickFail, " 482 ",
|
||
"ERR_CHANOPRIVSNEEDED",
|
||
)
|
||
|
||
// ── TOPIC lock (+t default) ───────────────────────
|
||
// +t is default, so bob should not be able to set
|
||
// topic.
|
||
|
||
bob.send("TOPIC #integration :bob tries topic")
|
||
|
||
bobTopicFail := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 482 ")
|
||
})
|
||
assertContains(
|
||
t, bobTopicFail, " 482 ",
|
||
"ERR_CHANOPRIVSNEEDED for topic",
|
||
)
|
||
|
||
// ── PING / PONG ───────────────────────────────────
|
||
|
||
alice.send("PING :testtoken")
|
||
|
||
alicePong := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "PONG")
|
||
})
|
||
assertContains(
|
||
t, alicePong, "PONG",
|
||
"PONG response received",
|
||
)
|
||
|
||
// ── Unknown command ───────────────────────────────
|
||
|
||
bob.send("FOOBAR")
|
||
|
||
bobUnknown := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 421 ")
|
||
})
|
||
assertContains(
|
||
t, bobUnknown, " 421 ",
|
||
"ERR_UNKNOWNCOMMAND",
|
||
)
|
||
|
||
// ── MOTD ──────────────────────────────────────────
|
||
|
||
alice.send("MOTD")
|
||
|
||
aliceMOTD := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 376 ")
|
||
})
|
||
assertContains(
|
||
t, aliceMOTD, " 376 ",
|
||
"RPL_ENDOFMOTD",
|
||
)
|
||
|
||
// ── AWAY (set, check via DM, clear) ───────────────
|
||
|
||
alice.send("AWAY :gone fishing")
|
||
|
||
aliceAway := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 306 ")
|
||
})
|
||
assertContains(
|
||
t, aliceAway, " 306 ",
|
||
"RPL_NOWAWAY",
|
||
)
|
||
|
||
// bob DMs alice — should get RPL_AWAY.
|
||
bob.send("PRIVMSG alice :are you there?")
|
||
|
||
bobAwayReply := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 301 ")
|
||
})
|
||
assertContains(
|
||
t, bobAwayReply, " 301 ",
|
||
"RPL_AWAY for bob when messaging alice",
|
||
)
|
||
assertContains(
|
||
t, bobAwayReply, "gone fishing",
|
||
"away message relayed",
|
||
)
|
||
|
||
// Clear away.
|
||
alice.send("AWAY")
|
||
alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 305 ")
|
||
})
|
||
|
||
// ── PASS (set password post-registration) ─────────
|
||
|
||
alice.send("PASS :mypassword123")
|
||
|
||
alicePass := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "Password set")
|
||
})
|
||
assertContains(
|
||
t, alicePass, "Password set",
|
||
"password set confirmation",
|
||
)
|
||
|
||
// ── MODE -t/+t topic lock toggle ──────────────────
|
||
|
||
alice.send("MODE #integration -t")
|
||
alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "-t")
|
||
})
|
||
bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "-t")
|
||
})
|
||
|
||
// Now bob should be able to set topic.
|
||
bob.send("TOPIC #integration :bob sets topic now")
|
||
|
||
bobTopicOK := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "bob sets topic now")
|
||
})
|
||
assertContains(
|
||
t, bobTopicOK, "bob sets topic now",
|
||
"bob can set topic after -t",
|
||
)
|
||
|
||
// alice sees the topic change.
|
||
aliceTopicRelay := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "bob sets topic now")
|
||
})
|
||
assertContains(
|
||
t, aliceTopicRelay, "bob sets topic now",
|
||
"alice sees bob's topic after -t",
|
||
)
|
||
|
||
// Restore +t.
|
||
alice.send("MODE #integration +t")
|
||
alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "+t")
|
||
})
|
||
bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "+t")
|
||
})
|
||
|
||
// ── DM to nonexistent nick ────────────────────────
|
||
|
||
alice.send("PRIVMSG nobody123 :hello")
|
||
|
||
aliceNoSuch := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 401 ")
|
||
})
|
||
assertContains(
|
||
t, aliceNoSuch, " 401 ",
|
||
"ERR_NOSUCHNICK",
|
||
)
|
||
|
||
// ── PART with reason ──────────────────────────────
|
||
|
||
bob.send("PART #integration :bye for now")
|
||
|
||
bobPart := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "PART")
|
||
})
|
||
assertContains(
|
||
t, bobPart, "PART",
|
||
"bob sees PART echo",
|
||
)
|
||
|
||
// alice sees bob PART via relay.
|
||
alicePart := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "PART") &&
|
||
strings.Contains(l, "bob")
|
||
})
|
||
assertContains(
|
||
t, alicePart, "bob",
|
||
"alice sees bob's PART",
|
||
)
|
||
assertContains(
|
||
t, alicePart, "bye for now",
|
||
"PART reason is relayed",
|
||
)
|
||
|
||
// bob rejoins for remaining tests.
|
||
bob.joinAndDrain("#integration")
|
||
alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "JOIN") &&
|
||
strings.Contains(l, "bob")
|
||
})
|
||
|
||
// ── PART non-existent channel ─────────────────────
|
||
|
||
bob.send("PART #nonexistent")
|
||
|
||
bobPartFail := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 403 ") ||
|
||
strings.Contains(l, " 442 ")
|
||
})
|
||
|
||
foundPartErr := false
|
||
for _, line := range bobPartFail {
|
||
if strings.Contains(line, " 403 ") ||
|
||
strings.Contains(line, " 442 ") {
|
||
foundPartErr = true
|
||
|
||
break
|
||
}
|
||
}
|
||
|
||
if !foundPartErr {
|
||
t.Error(
|
||
"expected ERR_NOSUCHCHANNEL or " +
|
||
"ERR_NOTONCHANNEL",
|
||
)
|
||
}
|
||
|
||
// ── User MODE query ───────────────────────────────
|
||
|
||
alice.send("MODE alice")
|
||
|
||
aliceUMode := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 221 ")
|
||
})
|
||
assertContains(
|
||
t, aliceUMode, " 221 ",
|
||
"RPL_UMODEIS",
|
||
)
|
||
|
||
// ── Multiple channel operation ────────────────────
|
||
|
||
alice.send("JOIN #second")
|
||
alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 366 ")
|
||
})
|
||
|
||
bob.send("JOIN #second")
|
||
bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 366 ")
|
||
})
|
||
|
||
// Drain alice seeing bob join.
|
||
alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "JOIN") &&
|
||
strings.Contains(l, "bob")
|
||
})
|
||
|
||
alice.send("PRIVMSG #second :cross-channel test")
|
||
|
||
bobCross := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "cross-channel test")
|
||
})
|
||
assertContains(
|
||
t, bobCross, "cross-channel test",
|
||
"bob receives message in #second",
|
||
)
|
||
|
||
// Clean up #second.
|
||
alice.send("PART #second")
|
||
alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "PART")
|
||
})
|
||
bob.send("PART #second")
|
||
bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "PART")
|
||
})
|
||
|
||
// ── QUIT ──────────────────────────────────────────
|
||
|
||
bob.send("QUIT :integration test done")
|
||
|
||
bobQuit := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "ERROR")
|
||
})
|
||
assertContains(
|
||
t, bobQuit, "integration test done",
|
||
"QUIT reason echoed",
|
||
)
|
||
|
||
// alice should see bob's QUIT via relay.
|
||
aliceQuit := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "QUIT") &&
|
||
strings.Contains(l, "bob")
|
||
})
|
||
assertContains(
|
||
t, aliceQuit, "bob",
|
||
"alice sees bob's QUIT",
|
||
)
|
||
}
|
||
|
||
// ── 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
|
||
// mode — verifies that +s can be set and the mode is
|
||
// reflected in MODE queries.
|
||
func TestIntegrationModeSecret(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
env := newTestEnv(t)
|
||
|
||
alice := env.dial(t)
|
||
alice.register("alice")
|
||
|
||
alice.joinAndDrain("#secretroom")
|
||
|
||
// Set +s.
|
||
alice.send("MODE #secretroom +s")
|
||
aliceLines := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "+s")
|
||
})
|
||
assertContains(
|
||
t, aliceLines, "+s",
|
||
"alice sees MODE +s confirmation",
|
||
)
|
||
|
||
// Verify mode is reflected in query.
|
||
alice.send("MODE #secretroom")
|
||
modeLines := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 324 ")
|
||
})
|
||
assertContains(
|
||
t, modeLines, "s",
|
||
"channel mode includes s",
|
||
)
|
||
}
|
||
|
||
// TestIntegrationModeModerated tests +m (moderated) mode
|
||
// — non-voiced users cannot send.
|
||
func TestIntegrationModeModerated(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
env := newTestEnv(t)
|
||
|
||
alice := env.dial(t)
|
||
alice.register("alice")
|
||
|
||
bob := env.dial(t)
|
||
bob.register("bob")
|
||
|
||
alice.joinAndDrain("#modtest")
|
||
bob.joinAndDrain("#modtest")
|
||
|
||
// Drain alice's view of bob's join.
|
||
alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "JOIN") &&
|
||
strings.Contains(l, "bob")
|
||
})
|
||
|
||
// Set +m.
|
||
alice.send("MODE #modtest +m")
|
||
alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "+m")
|
||
})
|
||
bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "+m")
|
||
})
|
||
|
||
// bob should get an error trying to send.
|
||
bob.send("PRIVMSG #modtest :should fail")
|
||
bobLines := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, " 404 ") ||
|
||
strings.Contains(l, " 482 ")
|
||
})
|
||
|
||
foundModErr := false
|
||
for _, line := range bobLines {
|
||
if strings.Contains(line, " 404 ") ||
|
||
strings.Contains(line, " 482 ") {
|
||
foundModErr = true
|
||
|
||
break
|
||
}
|
||
}
|
||
|
||
if !foundModErr {
|
||
t.Error(
|
||
"non-voiced user should not be able to send " +
|
||
"in +m channel",
|
||
)
|
||
}
|
||
|
||
// Grant +v to bob, then he should be able to send.
|
||
alice.send("MODE #modtest +v bob")
|
||
alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "+v")
|
||
})
|
||
bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "+v")
|
||
})
|
||
|
||
bob.send("PRIVMSG #modtest :voiced message")
|
||
aliceLines := alice.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "voiced message")
|
||
})
|
||
assertContains(
|
||
t, aliceLines, "voiced message",
|
||
"alice receives voiced bob's message",
|
||
)
|
||
}
|
||
|
||
// TestIntegrationThirdClientObserver verifies that a third
|
||
// client observing the same channel receives messages from
|
||
// the other two.
|
||
func TestIntegrationThirdClientObserver(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
env := newTestEnv(t)
|
||
|
||
alice := env.dial(t)
|
||
alice.register("alice")
|
||
|
||
bob := env.dial(t)
|
||
bob.register("bob")
|
||
|
||
carol := env.dial(t)
|
||
carol.register("carol")
|
||
|
||
alice.joinAndDrain("#trio")
|
||
bob.joinAndDrain("#trio")
|
||
carol.joinAndDrain("#trio")
|
||
|
||
// Drain join notifications.
|
||
time.Sleep(100 * time.Millisecond)
|
||
|
||
// alice sends; both bob and carol should receive.
|
||
alice.send("PRIVMSG #trio :hello trio")
|
||
|
||
bobLines := bob.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "hello trio")
|
||
})
|
||
assertContains(
|
||
t, bobLines, "hello trio",
|
||
"bob receives trio message",
|
||
)
|
||
|
||
carolLines := carol.readUntil(func(l string) bool {
|
||
return strings.Contains(l, "hello trio")
|
||
})
|
||
assertContains(
|
||
t, carolLines, "hello trio",
|
||
"carol receives trio message",
|
||
)
|
||
}
|