check / check (push) Waiting to run
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
1387 lines
32 KiB
Go
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}
|
|
}
|