check / check (push) Waiting to run
An invite for an IRC listener user arrived as a server NOTICE that did not name the inviter, so no IRC client treated it as an invite. The relay now sends it as an INVITE line from the inviter, with the channel taken from the message body, and sends nothing when the body names no channel. The IRC listener now queues the INVITE with the channel as its body, as the HTTP API already did, so an invite from either transport has the same shape. The README's IRC listener section says how an invite arrives. Model: opus-5-5
394 lines
9.8 KiB
Go
394 lines
9.8 KiB
Go
package ircserver_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/neoirc/internal/ircserver"
|
|
)
|
|
|
|
// queueMessage queues a message for the user with nick
|
|
// recipient the way the HTTP API sends one: stored once, then
|
|
// queued to the recipient's session.
|
|
func (env *testEnv) queueMessage(
|
|
t *testing.T,
|
|
recipient, command, from, to string,
|
|
body []string,
|
|
) {
|
|
t.Helper()
|
|
|
|
ctx := t.Context()
|
|
|
|
bodyJSON, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("marshal body: %v", err)
|
|
}
|
|
|
|
msgID, _, err := env.database.InsertMessage(
|
|
ctx, command, from, to, 0, 0, nil, bodyJSON, nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("insert message: %v", err)
|
|
}
|
|
|
|
sessionID, err := env.database.GetSessionByNick(ctx, recipient)
|
|
if err != nil {
|
|
t.Fatalf("get session of %s: %v", recipient, err)
|
|
}
|
|
|
|
err = env.database.EnqueueToSession(ctx, sessionID, msgID)
|
|
if err != nil {
|
|
t.Fatalf("enqueue: %v", err)
|
|
}
|
|
|
|
env.brk.Notify(sessionID)
|
|
}
|
|
|
|
// TestRelayKeepsOneWaitChannel checks that an IRC listener
|
|
// connection holds at most one broker wait channel across
|
|
// several relay ticks, both while idle and after the relay
|
|
// has delivered it a message, and none once it closes.
|
|
func TestRelayKeepsOneWaitChannel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
client := env.dial(t)
|
|
client.register("idle")
|
|
|
|
sessionID, err := env.database.GetSessionByNick(t.Context(), "idle")
|
|
if err != nil {
|
|
t.Fatalf("get session of idle: %v", err)
|
|
}
|
|
|
|
env.checkOneWaitChannel(t, sessionID)
|
|
|
|
env.queueMessage(
|
|
t, "idle", "PRIVMSG", "carol", "idle", []string{"hello"},
|
|
)
|
|
|
|
client.readUntil(func(l string) bool {
|
|
return strings.Contains(l, "hello")
|
|
})
|
|
|
|
env.checkOneWaitChannel(t, sessionID)
|
|
|
|
err = client.conn.Close()
|
|
if err != nil {
|
|
t.Fatalf("close: %v", err)
|
|
}
|
|
|
|
deadline := time.Now().Add(testTimeout)
|
|
|
|
for env.brk.ListenerCount(sessionID) != 0 {
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("after close: got %d wait channels, want 0",
|
|
env.brk.ListenerCount(sessionID))
|
|
}
|
|
|
|
time.Sleep(relayCheckStep)
|
|
}
|
|
}
|
|
|
|
// checkOneWaitChannel fails the test if the session
|
|
// sessionID holds more than one broker wait channel at any of
|
|
// several relay ticks.
|
|
func (env *testEnv) checkOneWaitChannel(
|
|
t *testing.T,
|
|
sessionID int64,
|
|
) {
|
|
t.Helper()
|
|
|
|
const ticks = 5
|
|
|
|
for tick := range ticks {
|
|
time.Sleep(ircserver.PollInterval)
|
|
|
|
got := env.brk.ListenerCount(sessionID)
|
|
if got > 1 {
|
|
t.Fatalf("tick %d: got %d wait channels, want at most 1",
|
|
tick+1, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRelayMultiLineBody checks that each line of a PRIVMSG
|
|
// or NOTICE body sent over the HTTP API reaches an IRC
|
|
// listener user as its own line.
|
|
func TestRelayMultiLineBody(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
alice := env.dial(t)
|
|
alice.register("alice")
|
|
|
|
for _, command := range []string{"PRIVMSG", "NOTICE"} {
|
|
env.queueMessage(
|
|
t, "alice", command, "carol", "alice",
|
|
[]string{"first line", "second line"},
|
|
)
|
|
|
|
lines := alice.readUntil(func(l string) bool {
|
|
return strings.Contains(l, "second line")
|
|
})
|
|
|
|
want := []string{
|
|
":carol!carol@* " + command + " alice :first line",
|
|
":carol!carol@* " + command + " alice :second line",
|
|
}
|
|
|
|
got := lines[max(0, len(lines)-len(want)):]
|
|
if !slices.Equal(got, want) {
|
|
t.Errorf("%s: got %q, want %q", command, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRelayModeParameters checks that a MODE change reaches
|
|
// IRC listener members with its values as separate
|
|
// parameters, both for the bodies the HTTP API stores and
|
|
// for a change made on the IRC listener.
|
|
func TestRelayModeParameters(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
alice := env.dial(t)
|
|
alice.register("alice")
|
|
|
|
for _, step := range []struct {
|
|
body []string
|
|
want string
|
|
}{
|
|
{[]string{"+k", "secret"}, "+k secret"},
|
|
{[]string{"-k", "*"}, "-k *"},
|
|
{[]string{"+l", "50"}, "+l 50"},
|
|
{[]string{"-l"}, "-l"},
|
|
{[]string{"+b", "*!*@*.example.com"}, "+b *!*@*.example.com"},
|
|
{[]string{"-b", "*!*@*.example.com"}, "-b *!*@*.example.com"},
|
|
{[]string{"+o bob"}, "+o bob"},
|
|
{[]string{"+m"}, "+m"},
|
|
} {
|
|
env.queueMessage(t, "alice", "MODE", "op", "#modes", step.body)
|
|
|
|
lines := alice.readUntil(func(l string) bool {
|
|
return strings.Contains(l, " MODE ")
|
|
})
|
|
|
|
got := lines[len(lines)-1]
|
|
want := ":op!op@* MODE #modes " + step.want
|
|
|
|
if got != want {
|
|
t.Errorf("body %q: got %q, want %q", step.body, got, want)
|
|
}
|
|
}
|
|
|
|
bob := env.dial(t)
|
|
bob.register("bob")
|
|
|
|
alice.joinAndDrain("#modes")
|
|
bob.joinAndDrain("#modes")
|
|
|
|
alice.send("MODE #modes +v bob")
|
|
|
|
const change = " MODE #modes +v bob"
|
|
|
|
for name, client := range map[string]*testClient{
|
|
"alice": alice, "bob": bob,
|
|
} {
|
|
lines := client.readUntil(func(l string) bool {
|
|
return strings.Contains(l, " MODE ")
|
|
})
|
|
|
|
got := lines[len(lines)-1]
|
|
if !strings.HasSuffix(got, change) {
|
|
t.Errorf("%s: got %q, want it to end in %q", name, got, change)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRelayInviteWithoutChannel checks that an INVITE whose
|
|
// body names no channel sends the invited IRC listener user
|
|
// no INVITE line.
|
|
func TestRelayInviteWithoutChannel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
bob := env.dial(t)
|
|
bob.register("bob")
|
|
|
|
// A nil body is stored as JSON null, which is not an array.
|
|
for _, body := range [][]string{nil, {}, {""}} {
|
|
env.queueMessage(t, "bob", "INVITE", "carol", "bob", body)
|
|
}
|
|
|
|
// The relay sends a user's queue in order, so this arrives
|
|
// after anything sent for the INVITEs above.
|
|
env.queueMessage(t, "bob", "PRIVMSG", "carol", "bob",
|
|
[]string{"the end"})
|
|
|
|
lines := bob.readUntil(func(l string) bool {
|
|
return strings.HasSuffix(l, " :the end")
|
|
})
|
|
|
|
for _, line := range lines {
|
|
if strings.Contains(line, " INVITE ") {
|
|
t.Errorf("got %q, want no INVITE line", line)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRelayLineBreaks checks that a CR, LF or NUL in a stored
|
|
// body line, key or mask never reaches an IRC listener user
|
|
// as a line break, so no extra line arrives, and that a body
|
|
// line keeps its parameter and leading ':' once they are
|
|
// removed.
|
|
func TestRelayLineBreaks(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
bob := env.dial(t)
|
|
bob.register("bob")
|
|
|
|
env.queueMessage(t, "bob", "PRIVMSG", "carol", "bob", []string{
|
|
"one\r\n:admin!a@b PRIVMSG bob :hi",
|
|
"two\n:admin!a@b NOTICE bob :hi\x00",
|
|
})
|
|
env.queueMessage(t, "bob", "MODE", "op", "#breaks",
|
|
[]string{"+k", "key\r\nERROR :Closing link"})
|
|
env.queueMessage(t, "bob", "MODE", "op", "#breaks",
|
|
[]string{"+b", "*!*@*\r\n:admin!a@b KICK #breaks bob"})
|
|
env.queueMessage(t, "bob", "PRIVMSG", "carol", "bob",
|
|
[]string{"\r\n", "\x00:hi"})
|
|
env.queueMessage(t, "bob", "PRIVMSG", "carol", "bob",
|
|
[]string{"the end"})
|
|
|
|
lines := bob.readUntil(func(l string) bool {
|
|
return strings.HasSuffix(l, " :the end")
|
|
})
|
|
|
|
want := []string{
|
|
":carol!carol@* PRIVMSG bob :one:admin!a@b PRIVMSG bob :hi",
|
|
":carol!carol@* PRIVMSG bob :two:admin!a@b NOTICE bob :hi",
|
|
":op!op@* MODE #breaks +k :keyERROR :Closing link",
|
|
":op!op@* MODE #breaks +b :*!*@*:admin!a@b KICK #breaks bob",
|
|
":carol!carol@* PRIVMSG bob :",
|
|
":carol!carol@* PRIVMSG bob ::hi",
|
|
":carol!carol@* PRIVMSG bob :the end",
|
|
}
|
|
|
|
got := lines[max(0, len(lines)-len(want)):]
|
|
if !slices.Equal(got, want) {
|
|
t.Errorf("got %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// TestRelayNickDiffersOnlyInCase checks that an IRC listener
|
|
// user alice receives a JOIN, PRIVMSG, PART, NICK and QUIT
|
|
// from Alice in a channel they share: nicks are
|
|
// case-sensitive, so Alice is someone else.
|
|
func TestRelayNickDiffersOnlyInCase(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
alice := env.dial(t)
|
|
alice.register("alice")
|
|
alice.joinAndDrain("#case")
|
|
|
|
capitalAlice := env.dial(t)
|
|
capitalAlice.register("Alice")
|
|
|
|
for _, step := range []struct {
|
|
line, want string
|
|
}{
|
|
{"JOIN #case", ":Alice!Alice@* JOIN #case"},
|
|
{"PRIVMSG #case :hi there", ":Alice!Alice@* PRIVMSG #case :hi there"},
|
|
{"PART #case :see you", ":Alice!Alice@* PART #case :see you"},
|
|
{"JOIN #case", ":Alice!Alice@* JOIN #case"},
|
|
{"NICK Alicia", ":Alice!Alice@* NICK Alicia"},
|
|
{"NICK Alice", ":Alicia!Alicia@* NICK Alice"}, // back for QUIT
|
|
{"QUIT", ":Alice!Alice@* QUIT :Quit: Connection closed"},
|
|
} {
|
|
capitalAlice.send(step.line)
|
|
|
|
got := alice.readLine()
|
|
if got != step.want {
|
|
t.Errorf("%s: got %q, want %q", step.line, got, step.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestRelayOwnChangeOnce checks that an IRC listener user who
|
|
// sets a topic, changes a channel mode or kicks gets one TOPIC,
|
|
// MODE or KICK line for it, and so does the other member.
|
|
func TestRelayOwnChangeOnce(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, step := range []struct {
|
|
command, line string
|
|
}{
|
|
{"TOPIC", "TOPIC #dup :new topic"},
|
|
{"MODE", "MODE #dup +m"},
|
|
{"KICK", "KICK #dup carl :bye"},
|
|
} {
|
|
t.Run(step.command, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := newTestEnv(t)
|
|
|
|
alice := env.dial(t)
|
|
alice.register("alice")
|
|
alice.joinAndDrain("#dup")
|
|
|
|
carl := env.dial(t)
|
|
carl.register("carl")
|
|
carl.joinAndDrain("#dup")
|
|
|
|
alice.send(step.line)
|
|
|
|
// The reply to alice's next command comes after her
|
|
// command has finished, so her own copy comes before it.
|
|
beforePong := alice.sendAndExpect("PING :done", "PONG")
|
|
|
|
// The relay sends a user's queue in order, so a message
|
|
// queued now arrives after any relayed copy.
|
|
for nick, client := range map[string]*testClient{
|
|
"alice": alice, "carl": carl,
|
|
} {
|
|
env.queueMessage(
|
|
t, nick, "PRIVMSG", "dave", nick,
|
|
[]string{"after the change"},
|
|
)
|
|
|
|
lines := client.readUntil(func(l string) bool {
|
|
return strings.HasSuffix(l, " :after the change")
|
|
})
|
|
|
|
if client == alice {
|
|
lines = slices.Concat(beforePong, lines)
|
|
}
|
|
|
|
var changes []string
|
|
|
|
for _, line := range lines {
|
|
if strings.Contains(line, " "+step.command+" #dup ") {
|
|
changes = append(changes, line)
|
|
}
|
|
}
|
|
|
|
if len(changes) != 1 {
|
|
t.Errorf("%s got %q, want one %s line",
|
|
nick, changes, step.command)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|