check / check (push) Waiting to run
JoinChannel made the channel, counted its members, ran the join checks and inserted the member as separate statements, so two requests could each decide on a count the other was about to change: +l was exceeded, two first joiners both became operator, and a JOIN racing the last PART failed its foreign key and answered 500. db.CreateOrJoinChannel now does all of that, and uses up the invite, in one transaction; the join checks moved into it, and the service turns its refusals into the same IRC errors as before. PART and KICK remove the member and delete an empty channel in one transaction (db.PartChannelAndDeleteIfEmpty). The db functions this leaves with no caller are removed. Model: opus-5-5
1102 lines
24 KiB
Go
1102 lines
24 KiB
Go
// Tests use a global viper instance for configuration,
|
|
// making parallel execution unsafe.
|
|
//
|
|
//nolint:paralleltest
|
|
package service_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/fx"
|
|
"go.uber.org/fx/fxtest"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"sneak.berlin/go/neoirc/internal/broker"
|
|
"sneak.berlin/go/neoirc/internal/config"
|
|
"sneak.berlin/go/neoirc/internal/db"
|
|
"sneak.berlin/go/neoirc/internal/globals"
|
|
"sneak.berlin/go/neoirc/internal/logger"
|
|
"sneak.berlin/go/neoirc/internal/service"
|
|
"sneak.berlin/go/neoirc/pkg/irc"
|
|
)
|
|
|
|
func TestMain(m *testing.M) {
|
|
db.SetBcryptCost(bcrypt.MinCost)
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
// testEnv holds all dependencies for a service test.
|
|
type testEnv struct {
|
|
svc *service.Service
|
|
db *db.Database
|
|
broker *broker.Broker
|
|
app *fxtest.App
|
|
}
|
|
|
|
func newTestEnv(t *testing.T) *testEnv {
|
|
t.Helper()
|
|
|
|
// A random name, so that no other test can share it, not
|
|
// even one that has finished but still holds its database
|
|
// open.
|
|
dbURL := fmt.Sprintf(
|
|
"file:%s?mode=memory&cache=shared",
|
|
rand.Text(),
|
|
)
|
|
|
|
var (
|
|
database *db.Database
|
|
svc *service.Service
|
|
)
|
|
|
|
brk := broker.New()
|
|
|
|
app := fxtest.New(t,
|
|
fx.Provide(
|
|
func() *globals.Globals {
|
|
return &globals.Globals{
|
|
Appname: "neoirc-test",
|
|
Version: "test",
|
|
StartTime: time.Now(),
|
|
}
|
|
},
|
|
logger.New,
|
|
func(
|
|
lifecycle fx.Lifecycle,
|
|
globs *globals.Globals,
|
|
log *logger.Logger,
|
|
) (*config.Config, error) {
|
|
cfg, err := config.New(
|
|
lifecycle, config.Params{ //nolint:exhaustruct
|
|
Globals: globs, Logger: log,
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"test config: %w", err,
|
|
)
|
|
}
|
|
|
|
cfg.DBURL = dbURL
|
|
cfg.Port = 0
|
|
cfg.OperName = "admin"
|
|
cfg.OperPassword = "secret"
|
|
|
|
return cfg, nil
|
|
},
|
|
func(
|
|
lifecycle fx.Lifecycle,
|
|
log *logger.Logger,
|
|
cfg *config.Config,
|
|
) (*db.Database, error) {
|
|
return db.New(lifecycle, db.Params{ //nolint:exhaustruct
|
|
Logger: log, Config: cfg,
|
|
})
|
|
},
|
|
func() *broker.Broker { return brk },
|
|
service.New,
|
|
),
|
|
fx.Populate(&database, &svc),
|
|
)
|
|
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(func() {
|
|
app.RequireStop()
|
|
})
|
|
|
|
return &testEnv{
|
|
svc: svc,
|
|
db: database,
|
|
broker: brk,
|
|
app: app,
|
|
}
|
|
}
|
|
|
|
// createSession is a test helper that creates a session
|
|
// and returns the session ID.
|
|
func createSession(
|
|
ctx context.Context,
|
|
t *testing.T,
|
|
database *db.Database,
|
|
nick string,
|
|
) int64 {
|
|
t.Helper()
|
|
|
|
sessionID, _, _, err := database.CreateSession(
|
|
ctx, nick, nick, "localhost", "127.0.0.1",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("create session %s: %v", nick, err)
|
|
}
|
|
|
|
return sessionID
|
|
}
|
|
|
|
// TestEachTestEnvHasItsOwnDatabase checks that a test
|
|
// environment starts on an empty database while another one
|
|
// is still open.
|
|
func TestEachTestEnvHasItsOwnDatabase(t *testing.T) {
|
|
ctx := t.Context()
|
|
|
|
first := newTestEnv(t)
|
|
createSession(ctx, t, first.db, "samenick")
|
|
|
|
second := newTestEnv(t)
|
|
createSession(ctx, t, second.db, "samenick")
|
|
}
|
|
|
|
func TestFanOut(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
sid1 := createSession(ctx, t, env.db, "alice")
|
|
sid2 := createSession(ctx, t, env.db, "bob")
|
|
|
|
body, _ := json.Marshal([]string{"hello"}) //nolint:errchkjson
|
|
|
|
dbID, uuid, err := env.svc.FanOut(
|
|
ctx, irc.CmdPrivmsg, "alice", "#test", 0, 0,
|
|
nil, body, nil,
|
|
[]int64{sid1, sid2},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("FanOut: %v", err)
|
|
}
|
|
|
|
if dbID == 0 {
|
|
t.Error("expected non-zero dbID")
|
|
}
|
|
|
|
if uuid == "" {
|
|
t.Error("expected non-empty UUID")
|
|
}
|
|
}
|
|
|
|
func TestJoinChannel(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
sid := createSession(ctx, t, env.db, "alice")
|
|
|
|
result, err := env.svc.JoinChannel(
|
|
ctx, sid, "alice", "#general", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("JoinChannel: %v", err)
|
|
}
|
|
|
|
if result.ChannelID == 0 {
|
|
t.Error("expected non-zero channel ID")
|
|
}
|
|
|
|
if !result.IsCreator {
|
|
t.Error("first joiner should be creator")
|
|
}
|
|
|
|
// Second user joins — not creator.
|
|
sid2 := createSession(ctx, t, env.db, "bob")
|
|
|
|
result2, err := env.svc.JoinChannel(
|
|
ctx, sid2, "bob", "#general", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("JoinChannel bob: %v", err)
|
|
}
|
|
|
|
if result2.IsCreator {
|
|
t.Error("second joiner should not be creator")
|
|
}
|
|
|
|
if result2.ChannelID != result.ChannelID {
|
|
t.Error("both should join the same channel")
|
|
}
|
|
}
|
|
|
|
func TestPartChannel(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
sid := createSession(ctx, t, env.db, "alice")
|
|
|
|
_, err := env.svc.JoinChannel(
|
|
ctx, sid, "alice", "#general", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("JoinChannel: %v", err)
|
|
}
|
|
|
|
err = env.svc.PartChannel(
|
|
ctx, sid, "alice", "#general", "bye",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("PartChannel: %v", err)
|
|
}
|
|
|
|
// Parting a non-existent channel returns error.
|
|
err = env.svc.PartChannel(
|
|
ctx, sid, "alice", "#nonexistent", "",
|
|
)
|
|
if err == nil {
|
|
t.Error("expected error for non-existent channel")
|
|
}
|
|
|
|
var ircErr *service.IRCError
|
|
if !errors.As(err, &ircErr) {
|
|
t.Errorf("expected IRCError, got %T", err)
|
|
}
|
|
}
|
|
|
|
// concurrentJoinTrials is how many times each test of
|
|
// concurrent JOINs repeats its race. Before a JOIN ran as
|
|
// one transaction, ten trials lost each race every time.
|
|
const concurrentJoinTrials = 10
|
|
|
|
// runConcurrently runs each of funcs in its own goroutine,
|
|
// releasing them all at once, and returns their errors in
|
|
// the order of funcs.
|
|
func runConcurrently(funcs ...func() error) []error {
|
|
start := make(chan struct{})
|
|
errs := make([]error, len(funcs))
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
for i, run := range funcs {
|
|
wg.Add(1)
|
|
|
|
go func() {
|
|
defer wg.Done()
|
|
|
|
<-start
|
|
|
|
errs[i] = run()
|
|
}()
|
|
}
|
|
|
|
close(start)
|
|
wg.Wait()
|
|
|
|
return errs
|
|
}
|
|
|
|
// joinConcurrently creates a session for each nick and has
|
|
// them all JOIN channel at once. It returns the result and
|
|
// error of each JOIN, in the order of nicks.
|
|
func joinConcurrently(
|
|
ctx context.Context,
|
|
t *testing.T,
|
|
env *testEnv,
|
|
channel string,
|
|
nicks ...string,
|
|
) ([]*service.JoinResult, []error) {
|
|
t.Helper()
|
|
|
|
results := make([]*service.JoinResult, len(nicks))
|
|
joins := make([]func() error, len(nicks))
|
|
|
|
for i, nick := range nicks {
|
|
sessionID := createSession(ctx, t, env.db, nick)
|
|
|
|
joins[i] = func() error {
|
|
result, err := env.svc.JoinChannel(
|
|
ctx, sessionID, nick, channel, "",
|
|
)
|
|
results[i] = result
|
|
|
|
return err
|
|
}
|
|
}
|
|
|
|
return results, runConcurrently(joins...)
|
|
}
|
|
|
|
// TestConcurrentJoinsRespectUserLimit checks that of two
|
|
// concurrent JOINs of a +l 2 channel with one member, one
|
|
// gets in and the other gets ERR_CHANNELISFULL.
|
|
func TestConcurrentJoinsRespectUserLimit(t *testing.T) {
|
|
const userLimit = 2
|
|
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
for trial := range concurrentJoinTrials {
|
|
channel := fmt.Sprintf("#full%d", trial)
|
|
opNick := fmt.Sprintf("op%d", trial)
|
|
opSID := createSession(ctx, t, env.db, opNick)
|
|
|
|
created, err := env.svc.JoinChannel(
|
|
ctx, opSID, opNick, channel, "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("join %s: %v", opNick, err)
|
|
}
|
|
|
|
err = env.db.SetChannelUserLimit(
|
|
ctx, created.ChannelID, userLimit,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("set +l: %v", err)
|
|
}
|
|
|
|
_, errs := joinConcurrently(
|
|
ctx, t, env, channel,
|
|
fmt.Sprintf("a%d", trial), fmt.Sprintf("b%d", trial),
|
|
)
|
|
|
|
refused := 0
|
|
|
|
for _, joinErr := range errs {
|
|
var ircErr *service.IRCError
|
|
|
|
switch {
|
|
case joinErr == nil:
|
|
case errors.As(joinErr, &ircErr) &&
|
|
ircErr.Code == irc.ErrChannelIsFull:
|
|
refused++
|
|
default:
|
|
t.Errorf("trial %d: JOIN: %v", trial, joinErr)
|
|
}
|
|
}
|
|
|
|
members, err := env.db.ChannelMembers(ctx, created.ChannelID)
|
|
if err != nil {
|
|
t.Fatalf("channel members: %v", err)
|
|
}
|
|
|
|
if len(members) != userLimit || refused != 1 {
|
|
t.Errorf(
|
|
"trial %d: %d members and %d refused, want %d and 1",
|
|
trial, len(members), refused, userLimit,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestConcurrentFirstJoinsMakeOneOperator checks that of
|
|
// two concurrent JOINs that create a channel, exactly one
|
|
// makes its session the operator.
|
|
func TestConcurrentFirstJoinsMakeOneOperator(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
for trial := range concurrentJoinTrials {
|
|
results, errs := joinConcurrently(
|
|
ctx, t, env, fmt.Sprintf("#new%d", trial),
|
|
fmt.Sprintf("first%d", trial),
|
|
fmt.Sprintf("second%d", trial),
|
|
)
|
|
|
|
creators := 0
|
|
|
|
for i, err := range errs {
|
|
if err != nil {
|
|
t.Fatalf("trial %d: JOIN: %v", trial, err)
|
|
}
|
|
|
|
if results[i].IsCreator {
|
|
creators++
|
|
}
|
|
}
|
|
|
|
members, err := env.db.ChannelMembers(
|
|
ctx, results[0].ChannelID,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("channel members: %v", err)
|
|
}
|
|
|
|
operators := 0
|
|
|
|
for _, member := range members {
|
|
if member.IsOperator {
|
|
operators++
|
|
}
|
|
}
|
|
|
|
if creators != 1 || operators != 1 {
|
|
t.Errorf(
|
|
"trial %d: %d creators and %d operators, want 1 each",
|
|
trial, creators, operators,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestJoinConcurrentWithLastPart checks that a JOIN
|
|
// concurrent with the PART of a channel's only member
|
|
// either joins a channel that still exists or gets
|
|
// ERR_NOSUCHCHANNEL, and never fails otherwise.
|
|
func TestJoinConcurrentWithLastPart(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
for trial := range concurrentJoinTrials {
|
|
channel := fmt.Sprintf("#last%d", trial)
|
|
leaverNick := fmt.Sprintf("leaver%d", trial)
|
|
leaverSID := createSession(ctx, t, env.db, leaverNick)
|
|
joinerNick := fmt.Sprintf("joiner%d", trial)
|
|
joinerSID := createSession(ctx, t, env.db, joinerNick)
|
|
|
|
_, err := env.svc.JoinChannel(
|
|
ctx, leaverSID, leaverNick, channel, "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("join %s: %v", leaverNick, err)
|
|
}
|
|
|
|
errs := runConcurrently(
|
|
func() error {
|
|
return env.svc.PartChannel(
|
|
ctx, leaverSID, leaverNick, channel, "",
|
|
)
|
|
},
|
|
func() error {
|
|
_, joinErr := env.svc.JoinChannel(
|
|
ctx, joinerSID, joinerNick, channel, "",
|
|
)
|
|
|
|
return joinErr
|
|
},
|
|
)
|
|
if errs[0] != nil {
|
|
t.Fatalf("trial %d: PART: %v", trial, errs[0])
|
|
}
|
|
|
|
var ircErr *service.IRCError
|
|
|
|
switch {
|
|
case errs[1] == nil:
|
|
chID, lookupErr := env.db.GetChannelByName(ctx, channel)
|
|
isMember, _ := env.db.IsChannelMember(ctx, chID, joinerSID)
|
|
|
|
if lookupErr != nil || !isMember {
|
|
t.Errorf(
|
|
"trial %d: JOIN succeeded, but %s is not on %s",
|
|
trial, joinerNick, channel,
|
|
)
|
|
}
|
|
case errors.As(errs[1], &ircErr) &&
|
|
ircErr.Code == irc.ErrNoSuchChannel:
|
|
default:
|
|
t.Errorf(
|
|
"trial %d: JOIN got %v, want success or 403",
|
|
trial, errs[1],
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestJoinUsesUpInvite checks that the JOIN an invite let
|
|
// into a +i channel uses the invite up, so that a JOIN
|
|
// after leaving gets ERR_INVITEONLYCHAN.
|
|
func TestJoinUsesUpInvite(t *testing.T) {
|
|
const channel = "#club"
|
|
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
opSID := createSession(ctx, t, env.db, "op")
|
|
guestSID := createSession(ctx, t, env.db, "guest")
|
|
|
|
created, err := env.svc.JoinChannel(ctx, opSID, "op", channel, "")
|
|
if err != nil {
|
|
t.Fatalf("join op: %v", err)
|
|
}
|
|
|
|
err = env.db.SetChannelInviteOnly(ctx, created.ChannelID, true)
|
|
if err != nil {
|
|
t.Fatalf("set +i: %v", err)
|
|
}
|
|
|
|
_, err = env.svc.InviteUser(ctx, opSID, "op", "guest", channel)
|
|
if err != nil {
|
|
t.Fatalf("invite guest: %v", err)
|
|
}
|
|
|
|
_, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "")
|
|
if err != nil {
|
|
t.Fatalf("invited join: %v", err)
|
|
}
|
|
|
|
err = env.svc.PartChannel(ctx, guestSID, "guest", channel, "")
|
|
if err != nil {
|
|
t.Fatalf("part guest: %v", err)
|
|
}
|
|
|
|
_, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "")
|
|
|
|
var ircErr *service.IRCError
|
|
if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrInviteOnlyChan {
|
|
t.Fatalf("second join: want ERR_INVITEONLYCHAN, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestJoinRefusedByHostmaskBan checks that a ban naming a
|
|
// session's nick, username and hostname refuses its JOIN
|
|
// with ERR_BANNEDFROMCHAN.
|
|
func TestJoinRefusedByHostmaskBan(t *testing.T) {
|
|
const channel = "#club"
|
|
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
opSID := createSession(ctx, t, env.db, "op")
|
|
|
|
created, err := env.svc.JoinChannel(ctx, opSID, "op", channel, "")
|
|
if err != nil {
|
|
t.Fatalf("join op: %v", err)
|
|
}
|
|
|
|
err = env.db.AddChannelBan(
|
|
ctx, created.ChannelID, "guest!user@host.example", "op",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("add ban: %v", err)
|
|
}
|
|
|
|
guestSID, _, _, err := env.db.CreateSession(
|
|
ctx, "guest", "user", "host.example", "127.0.0.1",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("create session guest: %v", err)
|
|
}
|
|
|
|
_, err = env.svc.JoinChannel(ctx, guestSID, "guest", channel, "")
|
|
|
|
var ircErr *service.IRCError
|
|
if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrBannedFromChan {
|
|
t.Fatalf("want ERR_BANNEDFROMCHAN, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestPartDeletesChannelOnlyWhenEmpty checks that a PART
|
|
// keeps a channel other members are still on and deletes
|
|
// the channel when its last member parts.
|
|
func TestPartDeletesChannelOnlyWhenEmpty(t *testing.T) {
|
|
const channel = "#room"
|
|
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
aliceSID := createSession(ctx, t, env.db, "alice")
|
|
bobSID := createSession(ctx, t, env.db, "bob")
|
|
|
|
_, err := env.svc.JoinChannel(ctx, aliceSID, "alice", channel, "")
|
|
if err != nil {
|
|
t.Fatalf("join alice: %v", err)
|
|
}
|
|
|
|
_, err = env.svc.JoinChannel(ctx, bobSID, "bob", channel, "")
|
|
if err != nil {
|
|
t.Fatalf("join bob: %v", err)
|
|
}
|
|
|
|
err = env.svc.PartChannel(ctx, aliceSID, "alice", channel, "")
|
|
if err != nil {
|
|
t.Fatalf("part alice: %v", err)
|
|
}
|
|
|
|
chID, err := env.db.GetChannelByName(ctx, channel)
|
|
if err != nil {
|
|
t.Fatalf("channel gone while bob is on it: %v", err)
|
|
}
|
|
|
|
isMember, _ := env.db.IsChannelMember(ctx, chID, bobSID)
|
|
if !isMember {
|
|
t.Fatal("bob is not on the channel after alice parted")
|
|
}
|
|
|
|
err = env.svc.PartChannel(ctx, bobSID, "bob", channel, "")
|
|
if err != nil {
|
|
t.Fatalf("part bob: %v", err)
|
|
}
|
|
|
|
_, err = env.db.GetChannelByName(ctx, channel)
|
|
if err == nil {
|
|
t.Fatal("channel still exists after its last member parted")
|
|
}
|
|
}
|
|
|
|
// TestKickOfLastMemberDeletesChannel checks that a KICK
|
|
// that leaves a channel with no members deletes it.
|
|
func TestKickOfLastMemberDeletesChannel(t *testing.T) {
|
|
const channel = "#room"
|
|
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
sid := createSession(ctx, t, env.db, "alice")
|
|
|
|
_, err := env.svc.JoinChannel(ctx, sid, "alice", channel, "")
|
|
if err != nil {
|
|
t.Fatalf("join alice: %v", err)
|
|
}
|
|
|
|
err = env.svc.KickUser(ctx, sid, "alice", channel, "alice", "")
|
|
if err != nil {
|
|
t.Fatalf("kick alice: %v", err)
|
|
}
|
|
|
|
_, err = env.db.GetChannelByName(ctx, channel)
|
|
if err == nil {
|
|
t.Fatal("channel still exists after its last member was kicked")
|
|
}
|
|
}
|
|
|
|
func TestSendChannelMessage(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
sid1 := createSession(ctx, t, env.db, "alice")
|
|
sid2 := createSession(ctx, t, env.db, "bob")
|
|
|
|
_, err := env.svc.JoinChannel(
|
|
ctx, sid1, "alice", "#chat", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("join alice: %v", err)
|
|
}
|
|
|
|
_, err = env.svc.JoinChannel(
|
|
ctx, sid2, "bob", "#chat", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("join bob: %v", err)
|
|
}
|
|
|
|
body, _ := json.Marshal([]string{"hello world"}) //nolint:errchkjson
|
|
|
|
dbID, uuid, err := env.svc.SendChannelMessage(
|
|
ctx, sid1, "alice",
|
|
irc.CmdPrivmsg, "#chat", body, nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("SendChannelMessage: %v", err)
|
|
}
|
|
|
|
if dbID == 0 {
|
|
t.Error("expected non-zero dbID")
|
|
}
|
|
|
|
if uuid == "" {
|
|
t.Error("expected non-empty UUID")
|
|
}
|
|
|
|
// Non-member cannot send.
|
|
sid3 := createSession(ctx, t, env.db, "charlie")
|
|
|
|
_, _, err = env.svc.SendChannelMessage(
|
|
ctx, sid3, "charlie",
|
|
irc.CmdPrivmsg, "#chat", body, nil,
|
|
)
|
|
if err == nil {
|
|
t.Error("expected error for non-member send")
|
|
}
|
|
}
|
|
|
|
func TestBroadcastQuit(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
sid1 := createSession(ctx, t, env.db, "alice")
|
|
sid2 := createSession(ctx, t, env.db, "bob")
|
|
|
|
_, err := env.svc.JoinChannel(
|
|
ctx, sid1, "alice", "#room", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("join alice: %v", err)
|
|
}
|
|
|
|
_, err = env.svc.JoinChannel(
|
|
ctx, sid2, "bob", "#room", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("join bob: %v", err)
|
|
}
|
|
|
|
// BroadcastQuit should not panic and should clean up.
|
|
env.svc.BroadcastQuit(
|
|
ctx, sid1, "alice", "Goodbye",
|
|
)
|
|
|
|
// Session should be deleted.
|
|
_, lookupErr := env.db.GetSessionByNick(ctx, "alice")
|
|
if lookupErr == nil {
|
|
t.Error("expected session to be deleted after quit")
|
|
}
|
|
}
|
|
|
|
// TestSendDirectMessageTargetQuits checks that a DM whose
|
|
// target quits after its nick is looked up gets the same
|
|
// error as a DM to an unknown nick. The trigger deletes the
|
|
// target's session just before the DM is stored.
|
|
func TestSendDirectMessageTargetQuits(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
sid := createSession(ctx, t, env.db, "alice")
|
|
createSession(ctx, t, env.db, "bob")
|
|
|
|
_, err := env.db.GetDB().ExecContext(ctx,
|
|
`CREATE TRIGGER delete_recipient
|
|
BEFORE INSERT ON messages
|
|
BEGIN
|
|
DELETE FROM sessions WHERE id = NEW.to_session_id;
|
|
END`)
|
|
if err != nil {
|
|
t.Fatalf("create trigger: %v", err)
|
|
}
|
|
|
|
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson
|
|
|
|
_, err = env.svc.SendDirectMessage(
|
|
ctx, sid, "alice", irc.CmdPrivmsg, "bob", body, nil,
|
|
)
|
|
|
|
var ircErr *service.IRCError
|
|
if !errors.As(err, &ircErr) || ircErr.Code != irc.ErrNoSuchNick {
|
|
t.Fatalf("want ERR_NOSUCHNICK, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestSendChannelMessageChannelDeleted checks that a channel
|
|
// message whose channel is deleted after it is looked up gets
|
|
// the same error as a message to an unknown channel. The
|
|
// trigger deletes the channel just before the message is
|
|
// stored.
|
|
func TestSendChannelMessageChannelDeleted(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
sid := createSession(ctx, t, env.db, "alice")
|
|
|
|
_, err := env.svc.JoinChannel(ctx, sid, "alice", "#gone", "")
|
|
if err != nil {
|
|
t.Fatalf("join alice: %v", err)
|
|
}
|
|
|
|
_, err = env.db.GetDB().ExecContext(ctx,
|
|
`CREATE TRIGGER delete_recipient
|
|
BEFORE INSERT ON messages
|
|
BEGIN
|
|
DELETE FROM channels WHERE id = NEW.to_channel_id;
|
|
END`)
|
|
if err != nil {
|
|
t.Fatalf("create trigger: %v", err)
|
|
}
|
|
|
|
body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson
|
|
|
|
_, _, err = env.svc.SendChannelMessage(
|
|
ctx, sid, "alice", irc.CmdPrivmsg, "#gone", body, nil,
|
|
)
|
|
|
|
var ircErr *service.IRCError
|
|
if !errors.As(err, &ircErr) ||
|
|
ircErr.Code != irc.ErrNoSuchChannel {
|
|
t.Fatalf("want ERR_NOSUCHCHANNEL, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSendChannelMessage_Moderated(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
sid1 := createSession(ctx, t, env.db, "alice")
|
|
sid2 := createSession(ctx, t, env.db, "bob")
|
|
|
|
result, err := env.svc.JoinChannel(
|
|
ctx, sid1, "alice", "#modchat", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("join alice: %v", err)
|
|
}
|
|
|
|
_, err = env.svc.JoinChannel(
|
|
ctx, sid2, "bob", "#modchat", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("join bob: %v", err)
|
|
}
|
|
|
|
// Set channel to moderated.
|
|
chID := result.ChannelID
|
|
_ = env.svc.SetChannelFlag(ctx, chID, 'm', true)
|
|
|
|
body, _ := json.Marshal([]string{"test"}) //nolint:errchkjson
|
|
|
|
// Bob (non-op, non-voiced) should fail to send.
|
|
_, _, err = env.svc.SendChannelMessage(
|
|
ctx, sid2, "bob",
|
|
irc.CmdPrivmsg, "#modchat", body, nil,
|
|
)
|
|
if err == nil {
|
|
t.Error("expected error for non-voiced user in moderated channel")
|
|
}
|
|
|
|
// Alice (operator) should succeed.
|
|
_, _, err = env.svc.SendChannelMessage(
|
|
ctx, sid1, "alice",
|
|
irc.CmdPrivmsg, "#modchat", body, nil,
|
|
)
|
|
if err != nil {
|
|
t.Errorf("operator should be able to send in moderated channel: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestQueryUserMode(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
sid := createSession(ctx, t, env.db, "alice")
|
|
|
|
modes, err := env.svc.QueryUserMode(ctx, sid)
|
|
if err != nil {
|
|
t.Fatalf("query user mode: %v", err)
|
|
}
|
|
|
|
if modes != "+" {
|
|
t.Errorf("expected +, got %s", modes)
|
|
}
|
|
|
|
setUserModes(ctx, t, env.db, sid, userModes{wallops: true})
|
|
|
|
modes, err = env.svc.QueryUserMode(ctx, sid)
|
|
if err != nil {
|
|
t.Fatalf("query user mode: %v", err)
|
|
}
|
|
|
|
if modes != "+w" {
|
|
t.Errorf("expected +w, got %s", modes)
|
|
}
|
|
|
|
setUserModes(
|
|
ctx, t, env.db, sid, userModes{oper: true, wallops: true},
|
|
)
|
|
|
|
modes, err = env.svc.QueryUserMode(ctx, sid)
|
|
if err != nil {
|
|
t.Fatalf("query user mode: %v", err)
|
|
}
|
|
|
|
if modes != "+ow" {
|
|
t.Errorf("expected +ow, got %s", modes)
|
|
}
|
|
|
|
// A flag that cannot be read is an error, not a flag
|
|
// that is unset.
|
|
cancelled, cancel := context.WithCancel(ctx)
|
|
cancel()
|
|
|
|
_, err = env.svc.QueryUserMode(cancelled, sid)
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("expected the read's error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUserhostReply(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
createSession(ctx, t, env.db, "alice")
|
|
bobID := createSession(ctx, t, env.db, "bob")
|
|
|
|
// No username or hostname, and an operator.
|
|
operID, _, _, err := env.db.CreateSession(
|
|
ctx, "oper", "", "", "",
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
setUserModes(ctx, t, env.db, operID, userModes{oper: true})
|
|
|
|
_, err = env.svc.SetAway(ctx, bobID, "gone fishing")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
reply, err := env.svc.UserhostReply(ctx, []string{
|
|
"alice", "bob", "nobody", "oper",
|
|
}, "srv")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
want := "alice=+alice@localhost bob=-bob@localhost " +
|
|
"oper*=+oper@srv"
|
|
if reply != want {
|
|
t.Errorf("want %q, got %q", want, reply)
|
|
}
|
|
|
|
reply, err = env.svc.UserhostReply(
|
|
ctx, slices.Repeat([]string{"bob"}, 6), "srv",
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if strings.Count(reply, "bob=") != 5 {
|
|
t.Errorf("want 5 entries for 6 nicks, got %q", reply)
|
|
}
|
|
|
|
// A nick that cannot be looked up is an error, not a nick
|
|
// with no session.
|
|
cancelled, cancel := context.WithCancel(ctx)
|
|
cancel()
|
|
|
|
_, err = env.svc.UserhostReply(cancelled, []string{"alice"}, "srv")
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("want the lookup's error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// userModes is the stored state of a session's user mode
|
|
// flags.
|
|
type userModes struct {
|
|
oper bool
|
|
wallops bool
|
|
}
|
|
|
|
// applyUserModeTest is one mode string, the flags stored
|
|
// before it is applied, and the expected outcome. An empty
|
|
// wantModes means the string is rejected with
|
|
// ERR_UMODEUNKNOWNFLAG, and then after must equal before.
|
|
type applyUserModeTest struct {
|
|
modeStr string
|
|
before userModes
|
|
wantModes string
|
|
after userModes
|
|
}
|
|
|
|
func applyUserModeTests() []applyUserModeTest {
|
|
none := userModes{}
|
|
oper := userModes{oper: true}
|
|
wallops := userModes{wallops: true}
|
|
both := userModes{oper: true, wallops: true}
|
|
|
|
return []applyUserModeTest{
|
|
{"+w", none, "+w", wallops},
|
|
{"-w", wallops, "+", none},
|
|
{"-o", oper, "+", none},
|
|
{"-wo", both, "+", none},
|
|
{"+w-o", oper, "+w", wallops},
|
|
{"-w+w", none, "+w", wallops},
|
|
{"+w-w", wallops, "+", none},
|
|
{"-w+o", both, "", both},
|
|
{"+o-w+w", both, "", both},
|
|
{"+wo", none, "", none},
|
|
{"+wz", none, "", none},
|
|
{"+z", none, "", none},
|
|
{"-x+y", none, "", none},
|
|
{"+y-x", none, "", none},
|
|
{"w", none, "", none},
|
|
{"xw", wallops, "", wallops},
|
|
{"", none, "", none},
|
|
{"+", none, "", none},
|
|
{"-", none, "", none},
|
|
{"+-+", none, "", none},
|
|
}
|
|
}
|
|
|
|
func TestApplyUserMode(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
|
|
for i, test := range applyUserModeTests() {
|
|
t.Run(test.modeStr, func(t *testing.T) {
|
|
ctx := t.Context()
|
|
sid := createSession(
|
|
ctx, t, env.db, fmt.Sprintf("user%d", i),
|
|
)
|
|
setUserModes(ctx, t, env.db, sid, test.before)
|
|
|
|
modes, err := env.svc.ApplyUserMode(
|
|
ctx, sid, test.modeStr,
|
|
)
|
|
checkApplyUserModeResult(t, test.wantModes, modes, err)
|
|
|
|
got := getUserModes(ctx, t, env.db, sid)
|
|
if got != test.after {
|
|
t.Errorf(
|
|
"stored modes: want %+v, got %+v",
|
|
test.after, got,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func checkApplyUserModeResult(
|
|
t *testing.T,
|
|
wantModes, modes string,
|
|
err error,
|
|
) {
|
|
t.Helper()
|
|
|
|
if wantModes == "" {
|
|
var ircErr *service.IRCError
|
|
if !errors.As(err, &ircErr) ||
|
|
ircErr.Code != irc.ErrUmodeUnknownFlag {
|
|
t.Fatalf("want ERR_UMODEUNKNOWNFLAG, got %v", err)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if modes != wantModes {
|
|
t.Errorf("modes: want %q, got %q", wantModes, modes)
|
|
}
|
|
}
|
|
|
|
func setUserModes(
|
|
ctx context.Context,
|
|
t *testing.T,
|
|
database *db.Database,
|
|
sid int64,
|
|
modes userModes,
|
|
) {
|
|
t.Helper()
|
|
|
|
err := database.SetSessionUserModes(
|
|
ctx, sid, &modes.wallops, &modes.oper,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("set user modes: %v", err)
|
|
}
|
|
}
|
|
|
|
func getUserModes(
|
|
ctx context.Context,
|
|
t *testing.T,
|
|
database *db.Database,
|
|
sid int64,
|
|
) userModes {
|
|
t.Helper()
|
|
|
|
oper, err := database.IsSessionOper(ctx, sid)
|
|
if err != nil {
|
|
t.Fatalf("read oper: %v", err)
|
|
}
|
|
|
|
wallops, err := database.IsSessionWallops(ctx, sid)
|
|
if err != nil {
|
|
t.Fatalf("read wallops: %v", err)
|
|
}
|
|
|
|
return userModes{oper: oper, wallops: wallops}
|
|
}
|