check / check (push) Waiting to run
ADMIN's location and email lines come from service.AdminLines, and KillUser applies the default reason, so both transports send the same reply. The Disconnect test now allows a quarter of one write window, so a Disconnect that writes before returning fails it. QueryUserMode and UserhostReply are tested with a read that fails. The handler tests again cover USERHOST for a nick with no session, MODE +o on your own nick, and the status of a successful KILL. handleMode's comment says what it does. Model: opus-5-5
616 lines
12 KiB
Go
616 lines
12 KiB
Go
// Tests use a global viper instance for configuration,
|
|
// making parallel execution unsafe.
|
|
//
|
|
//nolint:paralleltest
|
|
package service_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"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()
|
|
|
|
dbURL := fmt.Sprintf(
|
|
"file:svc_test_%p?mode=memory&cache=shared",
|
|
t,
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
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",
|
|
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)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
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}
|
|
}
|