Some checks failed
check / check (push) Failing after 2m28s
Both the HTTP API and IRC wire protocol handlers now call service.ApplyUserMode/service.QueryUserMode for all user mode operations. The service layer iterates mode strings character by character (the correct IRC approach), ensuring identical behavior regardless of transport. Removed duplicate mode logic from internal/handlers/utility.go (buildUserModeString, applyUserModeChange, applyModeChar) and internal/ircserver/commands.go (buildUmodeString, inline iteration). Added service-level tests for QueryUserMode, ApplyUserMode (single-char, multi-char, invalid input, de-oper, +o rejection).
529 lines
11 KiB
Go
529 lines
11 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"
|
|
"testing"
|
|
|
|
"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{ //nolint:exhaustruct
|
|
Appname: "neoirc-test",
|
|
Version: "test",
|
|
}
|
|
},
|
|
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")
|
|
|
|
// Fresh session has no modes.
|
|
modes := env.svc.QueryUserMode(ctx, sid)
|
|
if modes != "+" {
|
|
t.Errorf("expected +, got %s", modes)
|
|
}
|
|
|
|
// Set wallops.
|
|
_ = env.db.SetSessionWallops(ctx, sid, true)
|
|
|
|
modes = env.svc.QueryUserMode(ctx, sid)
|
|
if modes != "+w" {
|
|
t.Errorf("expected +w, got %s", modes)
|
|
}
|
|
|
|
// Set oper.
|
|
_ = env.db.SetSessionOper(ctx, sid, true)
|
|
|
|
modes = env.svc.QueryUserMode(ctx, sid)
|
|
if modes != "+ow" {
|
|
t.Errorf("expected +ow, got %s", modes)
|
|
}
|
|
}
|
|
|
|
func TestApplyUserModeSingleChar(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
sid := createSession(ctx, t, env.db, "alice")
|
|
|
|
// Apply +w.
|
|
result, err := env.svc.ApplyUserMode(ctx, sid, "+w")
|
|
if err != nil {
|
|
t.Fatalf("apply +w: %v", err)
|
|
}
|
|
|
|
if result != "+w" {
|
|
t.Errorf("expected +w, got %s", result)
|
|
}
|
|
|
|
// Apply -w.
|
|
result, err = env.svc.ApplyUserMode(ctx, sid, "-w")
|
|
if err != nil {
|
|
t.Fatalf("apply -w: %v", err)
|
|
}
|
|
|
|
if result != "+" {
|
|
t.Errorf("expected +, got %s", result)
|
|
}
|
|
}
|
|
|
|
func TestApplyUserModeMultiChar(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
sid := createSession(ctx, t, env.db, "alice")
|
|
|
|
// Set oper first so we can test +wo (w applied, o
|
|
// rejected because +o is not allowed via MODE).
|
|
_ = env.db.SetSessionOper(ctx, sid, true)
|
|
|
|
// Apply +w alone should work.
|
|
result, err := env.svc.ApplyUserMode(ctx, sid, "+w")
|
|
if err != nil {
|
|
t.Fatalf("apply +w: %v", err)
|
|
}
|
|
|
|
if result != "+ow" {
|
|
t.Errorf("expected +ow, got %s", result)
|
|
}
|
|
|
|
// Reset wallops.
|
|
_ = env.db.SetSessionWallops(ctx, sid, false)
|
|
|
|
// Multi-char -ow: should de-oper and remove wallops.
|
|
_ = env.db.SetSessionWallops(ctx, sid, true)
|
|
|
|
result, err = env.svc.ApplyUserMode(ctx, sid, "-ow")
|
|
if err != nil {
|
|
t.Fatalf("apply -ow: %v", err)
|
|
}
|
|
|
|
if result != "+" {
|
|
t.Errorf("expected +, got %s", result)
|
|
}
|
|
|
|
// +wo should fail because +o is not allowed.
|
|
_, err = env.svc.ApplyUserMode(ctx, sid, "+wo")
|
|
|
|
var ircErr *service.IRCError
|
|
if !errors.As(err, &ircErr) {
|
|
t.Fatalf("expected IRCError, got %v", err)
|
|
}
|
|
|
|
if ircErr.Code != irc.ErrUmodeUnknownFlag {
|
|
t.Errorf(
|
|
"expected ErrUmodeUnknownFlag, got %d",
|
|
ircErr.Code,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestApplyUserModeInvalidInput(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
sid := createSession(ctx, t, env.db, "alice")
|
|
|
|
// Too short.
|
|
_, err := env.svc.ApplyUserMode(ctx, sid, "+")
|
|
|
|
var ircErr *service.IRCError
|
|
if !errors.As(err, &ircErr) {
|
|
t.Fatalf("expected IRCError for short input, got %v", err)
|
|
}
|
|
|
|
// Unknown flag.
|
|
_, err = env.svc.ApplyUserMode(ctx, sid, "+x")
|
|
if !errors.As(err, &ircErr) {
|
|
t.Fatalf("expected IRCError for unknown flag, got %v", err)
|
|
}
|
|
|
|
if ircErr.Code != irc.ErrUmodeUnknownFlag {
|
|
t.Errorf(
|
|
"expected ErrUmodeUnknownFlag, got %d",
|
|
ircErr.Code,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestApplyUserModeDeoper(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
|
|
sid := createSession(ctx, t, env.db, "alice")
|
|
|
|
// Make oper via DB directly.
|
|
_ = env.db.SetSessionOper(ctx, sid, true)
|
|
|
|
// -o should work.
|
|
result, err := env.svc.ApplyUserMode(ctx, sid, "-o")
|
|
if err != nil {
|
|
t.Fatalf("apply -o: %v", err)
|
|
}
|
|
|
|
if result != "+" {
|
|
t.Errorf("expected +, got %s", result)
|
|
}
|
|
|
|
// +o should fail.
|
|
_, err = env.svc.ApplyUserMode(ctx, sid, "+o")
|
|
|
|
var ircErr *service.IRCError
|
|
if !errors.As(err, &ircErr) {
|
|
t.Fatalf("expected IRCError for +o, got %v", err)
|
|
}
|
|
}
|