refactor: migrate HTTP handlers to shared service layer
All checks were successful
check / check (push) Successful in 54s
All checks were successful
check / check (push) Successful in 54s
- Migrate all HTTP command handlers (PRIVMSG, JOIN, PART, NICK, TOPIC, KICK, QUIT, AWAY, OPER, MODE) to use hdlr.svc.* service methods instead of direct database calls. Both HTTP and IRC transports now share the same business logic path. - Fix BroadcastQuit bug: was inserting N separate message rows (one per recipient); now uses FanOut pattern with 1 InsertMessage + N EnqueueToSession calls. - Fix README: IRC listener is enabled by default on :6667, not disabled. Remove redundant -e IRC_LISTEN_ADDR from Docker example. - Add EXPOSE 6667 to Dockerfile alongside existing HTTP port. - Add service layer unit tests (JoinChannel, PartChannel, SendChannelMessage, FanOut, BroadcastQuit, moderated channel). - Update handler test setup to provide Service instance. - Use constant-time comparison in Oper credential validation to prevent timing attacks. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -109,16 +110,19 @@ func excludeSession(
|
||||
|
||||
// SendChannelMessage validates membership and moderation,
|
||||
// then fans out a message to all channel members except
|
||||
// the sender.
|
||||
// the sender. Returns the database row ID, message UUID,
|
||||
// and any error. The dbID lets callers enqueue the same
|
||||
// message to the sender when echo is needed (HTTP
|
||||
// transport).
|
||||
func (s *Service) SendChannelMessage(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
nick, command, channel string,
|
||||
body, meta json.RawMessage,
|
||||
) (string, error) {
|
||||
) (int64, string, error) {
|
||||
chID, err := s.DB.GetChannelByName(ctx, channel)
|
||||
if err != nil {
|
||||
return "", &IRCError{
|
||||
return 0, "", &IRCError{
|
||||
irc.ErrNoSuchChannel,
|
||||
[]string{channel},
|
||||
"No such channel",
|
||||
@@ -129,7 +133,7 @@ func (s *Service) SendChannelMessage(
|
||||
ctx, chID, sessionID,
|
||||
)
|
||||
if !isMember {
|
||||
return "", &IRCError{
|
||||
return 0, "", &IRCError{
|
||||
irc.ErrCannotSendToChan,
|
||||
[]string{channel},
|
||||
"Cannot send to channel",
|
||||
@@ -146,7 +150,7 @@ func (s *Service) SendChannelMessage(
|
||||
)
|
||||
|
||||
if !isOp && !isVoiced {
|
||||
return "", &IRCError{
|
||||
return 0, "", &IRCError{
|
||||
irc.ErrCannotSendToChan,
|
||||
[]string{channel},
|
||||
"Cannot send to channel (+m)",
|
||||
@@ -157,15 +161,15 @@ func (s *Service) SendChannelMessage(
|
||||
memberIDs, _ := s.DB.GetChannelMemberIDs(ctx, chID)
|
||||
recipients := excludeSession(memberIDs, sessionID)
|
||||
|
||||
_, uuid, fanErr := s.FanOut(
|
||||
dbID, uuid, fanErr := s.FanOut(
|
||||
ctx, command, nick, channel,
|
||||
nil, body, meta, recipients,
|
||||
)
|
||||
if fanErr != nil {
|
||||
return "", fanErr
|
||||
return 0, "", fanErr
|
||||
}
|
||||
|
||||
return uuid, nil
|
||||
return dbID, uuid, nil
|
||||
}
|
||||
|
||||
// SendDirectMessage validates the target and sends a
|
||||
@@ -449,7 +453,9 @@ func (s *Service) ChangeNick(
|
||||
}
|
||||
|
||||
// BroadcastQuit broadcasts a QUIT to all channel peers,
|
||||
// parts all channels, and deletes the session.
|
||||
// parts all channels, and deletes the session. Uses the
|
||||
// FanOut pattern: one message row fanned out to all unique
|
||||
// peer sessions.
|
||||
func (s *Service) BroadcastQuit(
|
||||
ctx context.Context,
|
||||
sessionID int64,
|
||||
@@ -481,19 +487,18 @@ func (s *Service) BroadcastQuit(
|
||||
}
|
||||
}
|
||||
|
||||
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||
|
||||
for sid := range notified {
|
||||
dbID, _, insErr := s.DB.InsertMessage(
|
||||
ctx, irc.CmdQuit, nick, "",
|
||||
nil, body, nil,
|
||||
)
|
||||
if insErr != nil {
|
||||
continue
|
||||
if len(notified) > 0 {
|
||||
recipients := make([]int64, 0, len(notified))
|
||||
for sid := range notified {
|
||||
recipients = append(recipients, sid)
|
||||
}
|
||||
|
||||
_ = s.DB.EnqueueToSession(ctx, sid, dbID)
|
||||
s.Broker.Notify(sid)
|
||||
body, _ := json.Marshal([]string{reason}) //nolint:errchkjson
|
||||
|
||||
_, _, _ = s.FanOut(
|
||||
ctx, irc.CmdQuit, nick, "",
|
||||
nil, body, nil, recipients,
|
||||
)
|
||||
}
|
||||
|
||||
for _, ch := range channels {
|
||||
@@ -529,7 +534,16 @@ func (s *Service) Oper(
|
||||
cfgName := s.Config.OperName
|
||||
cfgPassword := s.Config.OperPassword
|
||||
|
||||
if cfgName == "" || cfgPassword == "" {
|
||||
// Use constant-time comparison and return the same
|
||||
// error for all failures to prevent information
|
||||
// leakage about valid operator names.
|
||||
if cfgName == "" || cfgPassword == "" ||
|
||||
subtle.ConstantTimeCompare(
|
||||
[]byte(name), []byte(cfgName),
|
||||
) != 1 ||
|
||||
subtle.ConstantTimeCompare(
|
||||
[]byte(password), []byte(cfgPassword),
|
||||
) != 1 {
|
||||
return &IRCError{
|
||||
irc.ErrNoOperHost,
|
||||
nil,
|
||||
@@ -537,14 +551,6 @@ func (s *Service) Oper(
|
||||
}
|
||||
}
|
||||
|
||||
if name != cfgName || password != cfgPassword {
|
||||
return &IRCError{
|
||||
irc.ErrPasswdMismatch,
|
||||
nil,
|
||||
"Password incorrect",
|
||||
}
|
||||
}
|
||||
|
||||
_ = s.DB.SetSessionOper(ctx, sessionID, true)
|
||||
|
||||
return nil
|
||||
|
||||
365
internal/service/service_test.go
Normal file
365
internal/service/service_test.go
Normal file
@@ -0,0 +1,365 @@
|
||||
// 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"
|
||||
|
||||
"git.eeqj.de/sneak/neoirc/internal/broker"
|
||||
"git.eeqj.de/sneak/neoirc/internal/config"
|
||||
"git.eeqj.de/sneak/neoirc/internal/db"
|
||||
"git.eeqj.de/sneak/neoirc/internal/globals"
|
||||
"git.eeqj.de/sneak/neoirc/internal/logger"
|
||||
"git.eeqj.de/sneak/neoirc/internal/service"
|
||||
"git.eeqj.de/sneak/neoirc/pkg/irc"
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user