Files
neoirc/internal/service/service_test.go
T
clawbot 0e6d06a7fc
check / check (push) Waiting to run
Spend a hashcash stamp only when the request succeeds (closes #158)
A session request refused with 400 or 409, or a +H channel message
refused for membership, a ban or +m, used to spend its stamp, so a
retry with the same stamp was refused as already used.

hashcash.Validator.Validate is split into Check, which spends
nothing, and Claim, which records the stamp; Release undoes a claim.
Session creation checks the stamp first, claims it just before
CreateSession and releases it if CreateSession fails.
SendChannelMessage checks the stamp after the membership, ban and +m
checks.

Model: opus-5-5
2026-10-21 04:02:06 +02:00

1387 lines
32 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/hashcash"
"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],
)
}
}
}
// TestJoinConcurrentWithLastQuit checks that a user who
// sends JOIN without the key to a +k channel, again after
// each ERR_BADCHANNELKEY, while the channel's only member
// quits, gets in only once the channel is deleted, into a
// new channel of that name with no key.
func TestJoinConcurrentWithLastQuit(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
for trial := range concurrentJoinTrials {
channel := fmt.Sprintf("#keyed%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)
created, err := env.svc.JoinChannel(
ctx, leaverSID, leaverNick, channel, "",
)
if err != nil {
t.Fatalf("join %s: %v", leaverNick, err)
}
err = env.db.SetChannelKey(ctx, created.ChannelID, "secret")
if err != nil {
t.Fatalf("set +k: %v", err)
}
var joined *service.JoinResult
errs := runConcurrently(
func() error {
env.svc.BroadcastQuit(ctx, leaverSID, leaverNick, "")
return nil
},
// The database has one connection, so retried
// JOINs take turns with the QUIT's statements
// and land between any two that are not in one
// transaction. A JOIN after the QUIT gets in, so
// the loop ends.
func() error {
for {
var (
joinErr error
ircErr *service.IRCError
)
joined, joinErr = env.svc.JoinChannel(
ctx, joinerSID, joinerNick, channel, "",
)
if !errors.As(joinErr, &ircErr) ||
ircErr.Code != irc.ErrBadChannelKey {
return joinErr
}
}
},
)
if errs[1] != nil {
t.Fatalf("trial %d: JOIN: %v", trial, errs[1])
}
key, _ := env.db.GetChannelKey(ctx, joined.ChannelID)
if key != "" {
t.Errorf(
"trial %d: %s got into +k %s without the key",
trial, joinerNick, channel,
)
}
}
}
// quitCrowdSize is how many sessions share a second channel
// with the quitting session in
// TestQuitQueuedAheadOfNewNickHoldersJoin.
const quitCrowdSize = 10
// TestQuitQueuedAheadOfNewNickHoldersJoin checks that when
// a new session takes a quitting session's nick and joins a
// channel the quitting session shared with a watcher, while
// the QUIT is going out, the watcher has the QUIT queued
// ahead of that JOIN. The quitting session is also on a
// channel with quitCrowdSize other sessions, made before the
// watcher's channel, so that its QUIT reaches the watcher
// last.
func TestQuitQueuedAheadOfNewNickHoldersJoin(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
for trial := range concurrentJoinTrials {
nick := fmt.Sprintf("reused%d", trial)
crowd := fmt.Sprintf("#crowd%d", trial)
shared := fmt.Sprintf("#shared%d", trial)
quitterSID := createSession(ctx, t, env.db, nick)
for _, channel := range []string{crowd, shared} {
_, err := env.svc.JoinChannel(ctx, quitterSID, nick, channel, "")
if err != nil {
t.Fatalf("join %s: %v", channel, err)
}
}
for member := range quitCrowdSize {
memberNick := fmt.Sprintf("crowd%dx%d", trial, member)
memberSID := createSession(ctx, t, env.db, memberNick)
_, err := env.svc.JoinChannel(ctx, memberSID, memberNick, crowd, "")
if err != nil {
t.Fatalf("join %s: %v", memberNick, err)
}
}
watcherNick := fmt.Sprintf("watcher%d", trial)
watcherSID, watcherClientID, _, err := env.db.CreateSession(
ctx, watcherNick, watcherNick, "localhost", "127.0.0.1",
)
if err != nil {
t.Fatalf("create session %s: %v", watcherNick, err)
}
_, err = env.svc.JoinChannel(ctx, watcherSID, watcherNick, shared, "")
if err != nil {
t.Fatalf("join %s: %v", watcherNick, err)
}
_, lastQueueID, err := env.db.PollMessages(ctx, watcherClientID, 0, 0)
if err != nil {
t.Fatalf("poll: %v", err)
}
errs := runConcurrently(
func() error {
env.svc.BroadcastQuit(ctx, quitterSID, nick, "")
return nil
},
func() error {
return takeNickAndJoin(ctx, env, nick, shared)
},
)
if errs[1] != nil {
t.Fatalf("trial %d: new session: %v", trial, errs[1])
}
msgs, _, err := env.db.PollMessages(
ctx, watcherClientID, lastQueueID, 0,
)
if err != nil {
t.Fatalf("poll: %v", err)
}
commands := commandsFrom(msgs, nick)
if !slices.Equal(commands, []string{irc.CmdQuit, irc.CmdJoin}) {
t.Errorf(
"trial %d: watcher got %v from %s, want QUIT then JOIN",
trial, commands, nick,
)
}
}
}
// takeNickAndJoin creates a session with nick, trying again
// while another session holds the nick, and joins it to
// channel.
func takeNickAndJoin(
ctx context.Context,
env *testEnv,
nick, channel string,
) error {
for {
sessionID, _, _, err := env.db.CreateSession(
ctx, nick, nick, "localhost", "127.0.0.1",
)
if db.IsUniqueConstraintError(err) {
continue
}
if err != nil {
return err
}
_, err = env.svc.JoinChannel(ctx, sessionID, nick, channel, "")
return err
}
}
// commandsFrom returns the commands of the messages in msgs
// that nick sent, in order.
func commandsFrom(msgs []db.IRCMessage, nick string) []string {
commands := make([]string, 0, len(msgs))
for _, msg := range msgs {
if msg.From == nick {
commands = append(commands, msg.Command)
}
}
return commands
}
// 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)
}
}
// TestRefusedChannelMessageLeavesStampUnspent checks that a
// PRIVMSG to a +H channel refused because the sender is not
// a member, is banned, or has no voice on a +m channel does
// not spend its hashcash stamp: the operator can then send
// the same message with it.
func TestRefusedChannelMessageLeavesStampUnspent(t *testing.T) {
const (
channel = "#paid"
bits = 2
)
refusals := []struct {
name string
// refuse stops bob from sending to the channel.
refuse func(ctx context.Context, env *testEnv, chID, bobSID int64) error
}{
{"not a member", func(ctx context.Context, env *testEnv, _, bobSID int64) error {
return env.svc.PartChannel(ctx, bobSID, "bob", channel, "")
}},
{"banned", func(ctx context.Context, env *testEnv, chID, _ int64) error {
return env.db.AddChannelBan(ctx, chID, "bob!*@*", "alice")
}},
{"moderated", func(ctx context.Context, env *testEnv, chID, _ int64) error {
return env.svc.SetChannelFlag(ctx, chID, 'm', true)
}},
}
body, _ := json.Marshal([]string{"hello"}) //nolint:errchkjson
for _, refusal := range refusals {
t.Run(refusal.name, func(t *testing.T) {
env := newTestEnv(t)
ctx := t.Context()
aliceSID := createSession(ctx, t, env.db, "alice")
bobSID := createSession(ctx, t, env.db, "bob")
joined, 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.db.SetChannelHashcashBits(ctx, joined.ChannelID, bits)
if err != nil {
t.Fatalf("set +H: %v", err)
}
err = refusal.refuse(ctx, env, joined.ChannelID, bobSID)
if err != nil {
t.Fatalf("refuse bob: %v", err)
}
stamp := hashcash.MintChannelStamp(
bits, channel, hashcash.BodyHash(body),
)
meta, _ := json.Marshal(map[string]string{"hashcash": stamp})
_, _, err = env.svc.SendChannelMessage(
ctx, bobSID, "bob", irc.CmdPrivmsg, channel, body, meta,
)
var ircErr *service.IRCError
if !errors.As(err, &ircErr) ||
ircErr.Code != irc.ErrCannotSendToChan {
t.Fatalf("bob: want ERR_CANNOTSENDTOCHAN, got %v", err)
}
_, _, err = env.svc.SendChannelMessage(
ctx, aliceSID, "alice", irc.CmdPrivmsg, channel, body, meta,
)
if err != nil {
t.Fatalf("alice with the same stamp: %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}
}