package abuseguard import ( "net" "path/filepath" "strconv" "testing" "gopkg.in/ini.v1" "mailgoserver/internal/db" ) func testCfg(t *testing.T, threshold int) *ini.File { t.Helper() cfg := ini.Empty() sec, _ := cfg.NewSection("Security") sec.NewKey("abuse_detection_enabled", "true") sec.NewKey("abuse_failure_threshold", strconv.Itoa(threshold)) sec.NewKey("abuse_detection_window_minutes", "10") sec.NewKey("abuse_blacklist_base_hours", "12") sec.NewKey("abuse_blacklist_max_hours", "168") return cfg } func openTestDB(t *testing.T) *db.DB { t.Helper() database, err := db.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { database.Close() }) return database } func TestRecordFailureAndMaybeBlacklistTripsThreshold(t *testing.T) { database := openTestDB(t) cfg := testCfg(t, 3) const ip = "203.0.113.50" for i := 0; i < 2; i++ { database.LogAuthAttempt("sender", "victim@example.com", ip, false, "bad password") RecordFailureAndMaybeBlacklist(database, cfg, nil, ip) } if blocked, _ := database.IsIPBlacklisted(ip); blocked { t.Fatal("should not be blacklisted before threshold") } database.LogAuthAttempt("sender", "victim@example.com", ip, false, "bad password") RecordFailureAndMaybeBlacklist(database, cfg, nil, ip) blocked, err := database.IsIPBlacklisted(ip) if err != nil { t.Fatal(err) } if !blocked { t.Fatal("expected IP to be blacklisted after hitting the threshold") } } func TestRecordFailureAndMaybeBlacklistSkipsWhitelisted(t *testing.T) { database := openTestDB(t) cfg := testCfg(t, 2) const ip = "203.0.113.51" if err := database.AddAbuseWhitelist(ip, "trusted"); err != nil { t.Fatal(err) } for i := 0; i < 5; i++ { database.LogAuthAttempt("sender", "victim@example.com", ip, false, "bad password") RecordFailureAndMaybeBlacklist(database, cfg, nil, ip) } if blocked, _ := database.IsIPBlacklisted(ip); blocked { t.Fatal("whitelisted IP should never be blacklisted") } } // fakeListener yields exactly one already-open in-memory connection pair, then EOF-like // closed errors, letting GuardListener's Accept loop be tested without real sockets. type fakeListener struct { conns chan net.Conn done chan struct{} } func newFakeListener(conns ...net.Conn) *fakeListener { ch := make(chan net.Conn, len(conns)) for _, c := range conns { ch <- c } return &fakeListener{conns: ch, done: make(chan struct{})} } func (f *fakeListener) Accept() (net.Conn, error) { select { case c := <-f.conns: return c, nil case <-f.done: return nil, net.ErrClosed } } func (f *fakeListener) Close() error { close(f.done); return nil } func (f *fakeListener) Addr() net.Addr { return dummyAddr{} } type dummyAddr struct{} func (dummyAddr) Network() string { return "tcp" } func (dummyAddr) String() string { return "0.0.0.0:0" } func TestGuardListenerRejectsBlacklistedIP(t *testing.T) { database := openTestDB(t) const blockedIP = "198.51.100.77" if err := database.AddManualBlacklistEntry(blockedIP, "test", 1); err != nil { t.Fatal(err) } blockedConn, blockedPeer := net.Pipe() defer blockedPeer.Close() inner := newFakeListener(&addrOverrideConn{Conn: blockedConn, remote: hostPortAddr(blockedIP)}) guarded := GuardListener(inner, database, nil) go func() { guarded.Accept() inner.Close() }() // The blocked connection's peer end should observe the connection close rather // than any protocol banner, since GuardListener closes it before returning it. buf := make([]byte, 1) if _, err := blockedPeer.Read(buf); err == nil { t.Fatal("expected blocked connection to be closed by GuardListener, got readable data instead") } } type addrOverrideConn struct { net.Conn remote net.Addr } func (c *addrOverrideConn) RemoteAddr() net.Addr { return c.remote } type hostPortAddr string func (hostPortAddr) Network() string { return "tcp" } func (a hostPortAddr) String() string { return string(a) + ":12345" }