Some checks failed
check / check (push) Failing after 23s
Mode parser (internal/service/service.go): - Reject strings without leading + or - (e.g. "xw", "w", "") with ERR_UMODEUNKNOWNFLAG instead of silently treating them as "-". - Support multi-sign transitions: +w-o, -w+o, +o-w+w, -x+y, +y-x. The active sign flips each time + or - is seen; subsequent letters apply with the active sign. - Atomic from caller's perspective: parse the whole string to a list of ops first, reject the whole request on any unknown mode char, and only then apply ops to the DB. Partial application of +w before rejecting +o is gone. - HTTP and IRC still share the same ApplyUserMode entry point. Router race (internal/server/server.go): - The fx OnStart hook previously spawned serve() in a goroutine that called SetupRoutes asynchronously, while ServeHTTP delegated to srv.router. Test harnesses (httptest wrapping srv as Handler) raced against SetupRoutes writing srv.router vs ServeHTTP reading it, producing the race detector failures in CI on main. - SetupRoutes is now called synchronously inside OnStart before the serve goroutine starts, so srv.router is fully initialized before any request can reach ServeHTTP. Tests (internal/service/service_test.go): - Replaced the per-mode tests with a single table-driven TestApplyUserMode that asserts both the returned mode string and the persisted DB state (oper/wallops) for each case, including the malformed and multi-sign cases above. The +wz case seeds wallops=true to prove the whole string is rejected and +w is not partially applied.
653 lines
14 KiB
Go
653 lines
14 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)
|
|
}
|
|
}
|
|
|
|
// TestApplyUserMode is the rigorous table-driven suite for
|
|
// the shared user-mode parser. It covers every case listed
|
|
// in sneak's review of PR #96 plus a few adjacent ones.
|
|
// Each case asserts the resulting mode string AND the
|
|
// persisted session state, to prove that rejected input
|
|
// leaves no side effects.
|
|
func TestApplyUserMode(t *testing.T) {
|
|
type caseState struct {
|
|
oper bool
|
|
wallops bool
|
|
}
|
|
|
|
// Each case starts from initialState, calls
|
|
// ApplyUserMode(modeStr), and verifies either the
|
|
// returned mode string (wantModes) or an IRCError with
|
|
// wantErrCode. After the call, the DB must match
|
|
// wantState — proving that rejected input made no
|
|
// change.
|
|
cases := []struct {
|
|
name string
|
|
initialState caseState
|
|
modeStr string
|
|
wantModes string
|
|
wantErr bool
|
|
wantErrCode irc.IRCMessageType
|
|
wantState caseState
|
|
}{
|
|
// Happy path: single-char operations.
|
|
{
|
|
name: "+w from empty",
|
|
modeStr: "+w",
|
|
wantModes: "+w",
|
|
wantState: caseState{wallops: true},
|
|
},
|
|
{
|
|
name: "-w from +w",
|
|
initialState: caseState{wallops: true},
|
|
modeStr: "-w",
|
|
wantModes: "+",
|
|
wantState: caseState{},
|
|
},
|
|
{
|
|
name: "-o from +o",
|
|
initialState: caseState{oper: true},
|
|
modeStr: "-o",
|
|
wantModes: "+",
|
|
wantState: caseState{},
|
|
},
|
|
// Multi-char without sign transitions.
|
|
{
|
|
name: "-wo from +ow",
|
|
initialState: caseState{oper: true, wallops: true},
|
|
modeStr: "-wo",
|
|
wantModes: "+",
|
|
wantState: caseState{},
|
|
},
|
|
// Multi-char with sign transitions.
|
|
{
|
|
name: "+w-o from +o",
|
|
initialState: caseState{oper: true},
|
|
modeStr: "+w-o",
|
|
wantModes: "+w",
|
|
wantState: caseState{wallops: true},
|
|
},
|
|
{
|
|
name: "-w+o always rejects +o",
|
|
initialState: caseState{wallops: true, oper: true},
|
|
modeStr: "-w+o",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
// +o is rejected before any op applies; wallops
|
|
// stays set.
|
|
wantState: caseState{wallops: true, oper: true},
|
|
},
|
|
{
|
|
name: "+o-w+w rejects because of +o",
|
|
initialState: caseState{wallops: true, oper: true},
|
|
modeStr: "+o-w+w",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
// Wallops must NOT be cleared; oper must NOT be
|
|
// cleared. Rejection is fully atomic.
|
|
wantState: caseState{wallops: true, oper: true},
|
|
},
|
|
{
|
|
name: "-x+y rejects unknown -x",
|
|
modeStr: "-x+y",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
wantState: caseState{},
|
|
},
|
|
{
|
|
name: "+y-x rejects unknown +y",
|
|
modeStr: "+y-x",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
wantState: caseState{},
|
|
},
|
|
// Malformed prefix — must not silently treat as '-'.
|
|
{
|
|
name: "w no prefix rejects",
|
|
modeStr: "w",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
wantState: caseState{},
|
|
},
|
|
{
|
|
name: "xw no prefix rejects (would have been" +
|
|
" silently -w before)",
|
|
initialState: caseState{wallops: true},
|
|
modeStr: "xw",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
// Prove wallops is NOT cleared — the whole point
|
|
// of sneak's review.
|
|
wantState: caseState{wallops: true},
|
|
},
|
|
{
|
|
name: "empty string rejects",
|
|
modeStr: "",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
wantState: caseState{},
|
|
},
|
|
// Bare signs with no mode letters reject.
|
|
{
|
|
name: "bare + rejects",
|
|
modeStr: "+",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
wantState: caseState{},
|
|
},
|
|
{
|
|
name: "bare - rejects",
|
|
modeStr: "-",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
wantState: caseState{},
|
|
},
|
|
{
|
|
name: "+-+ rejects (no mode letters)",
|
|
modeStr: "+-+",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
wantState: caseState{},
|
|
},
|
|
// Unknown mode letters reject whole string.
|
|
{
|
|
name: "+z unknown mode rejects",
|
|
modeStr: "+z",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
wantState: caseState{},
|
|
},
|
|
{
|
|
name: "+wz rejects whole thing; +w side effect" +
|
|
" must NOT persist",
|
|
modeStr: "+wz",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
// Wallops must NOT be set.
|
|
wantState: caseState{},
|
|
},
|
|
{
|
|
name: "+wo rejects whole thing; +w side effect" +
|
|
" must NOT persist",
|
|
modeStr: "+wo",
|
|
wantErr: true,
|
|
wantErrCode: irc.ErrUmodeUnknownFlag,
|
|
wantState: caseState{},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ctx := t.Context()
|
|
sid := createSession(ctx, t, env.db, "alice")
|
|
|
|
if tc.initialState.oper {
|
|
if err := env.db.SetSessionOper(
|
|
ctx, sid, true,
|
|
); err != nil {
|
|
t.Fatalf("init oper: %v", err)
|
|
}
|
|
}
|
|
|
|
if tc.initialState.wallops {
|
|
if err := env.db.SetSessionWallops(
|
|
ctx, sid, true,
|
|
); err != nil {
|
|
t.Fatalf("init wallops: %v", err)
|
|
}
|
|
}
|
|
|
|
result, err := env.svc.ApplyUserMode(
|
|
ctx, sid, tc.modeStr,
|
|
)
|
|
|
|
if tc.wantErr {
|
|
var ircErr *service.IRCError
|
|
if !errors.As(err, &ircErr) {
|
|
t.Fatalf(
|
|
"expected IRCError, got %v", err,
|
|
)
|
|
}
|
|
|
|
if ircErr.Code != tc.wantErrCode {
|
|
t.Errorf(
|
|
"code: want %d got %d",
|
|
tc.wantErrCode, ircErr.Code,
|
|
)
|
|
}
|
|
} else {
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if result != tc.wantModes {
|
|
t.Errorf(
|
|
"modes: want %q got %q",
|
|
tc.wantModes, result,
|
|
)
|
|
}
|
|
}
|
|
|
|
// Whether or not the call errored, the persisted
|
|
// state must match wantState — this is the
|
|
// atomicity guarantee sneak demanded.
|
|
gotOper, err := env.db.IsSessionOper(ctx, sid)
|
|
if err != nil {
|
|
t.Fatalf("read oper: %v", err)
|
|
}
|
|
|
|
gotWallops, err := env.db.IsSessionWallops(
|
|
ctx, sid,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("read wallops: %v", err)
|
|
}
|
|
|
|
if gotOper != tc.wantState.oper {
|
|
t.Errorf(
|
|
"oper: want %v got %v",
|
|
tc.wantState.oper, gotOper,
|
|
)
|
|
}
|
|
|
|
if gotWallops != tc.wantState.wallops {
|
|
t.Errorf(
|
|
"wallops: want %v got %v",
|
|
tc.wantState.wallops, gotWallops,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|