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, 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 idle IRC // listener connection holds at most one broker wait channel // across several relay ticks, and none once it closes. func TestRelayKeepsOneWaitChannel(t *testing.T) { t.Parallel() const idleTicks = 5 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) } for tick := range idleTicks { 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) } } 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) } } // 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) } } } // 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) } } }