#12: implement Antispam & Antivirus scanning pipeline (Rspamd & ClamAV)
This commit is contained in:
1 parent
4c2e0b9563
commit
54d1ea5810
15 files changed
+825
-5
No files matched your search
@@ -0,0 +1,218 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||
gosmtp "github.com/emersion/go-smtp"
|
||||
"github.com/google/uuid"
|
||||
|
||||
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||
"lambdamail/protocols/internal/infrastructure/clamav"
|
||||
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||
"lambdamail/protocols/internal/infrastructure/rspamd"
|
||||
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
|
||||
)
|
||||
|
||||
func TestAntispamAntivirusEndToEnd(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("e2e test skipped in -short mode")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
runtimeDir := t.TempDir()
|
||||
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().
|
||||
Port(54338).
|
||||
RuntimePath(runtimeDir).
|
||||
StartTimeout(120 * time.Second))
|
||||
if err := pg.Start(); err != nil {
|
||||
t.Fatalf("start embedded postgres: %v", err)
|
||||
}
|
||||
defer pg.Stop()
|
||||
|
||||
dbURL := "postgres://postgres:postgres@localhost:54338/postgres?sslmode=disable"
|
||||
pool, err := postgres.NewPool(ctx, dbURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect pool: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
root := repoRoot(t)
|
||||
migrations := []string{
|
||||
"0001_init_schema.up.sql",
|
||||
"0002_add_is_system_to_aliases.up.sql",
|
||||
"0003_create_report_tables.up.sql",
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
|
||||
if err != nil {
|
||||
t.Fatalf("read migration %s: %v", m, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, string(sql)); err != nil {
|
||||
t.Fatalf("apply migration %s: %v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Seed domain, mailbox, INBOX and Junk folders
|
||||
domainID := uuid.New()
|
||||
mailboxID := uuid.New()
|
||||
inboxID := uuid.New()
|
||||
junkID := uuid.New()
|
||||
|
||||
pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'scan.test', 'scan.test')`, domainID)
|
||||
pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'user', 'user@scan.test', 'hash')`, mailboxID, domainID)
|
||||
pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, 'INBOX', 'inbox')`, inboxID, mailboxID)
|
||||
pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, 'Junk', 'junk')`, junkID, mailboxID)
|
||||
|
||||
mailboxRepo := postgres.NewMailboxRepository(pool)
|
||||
messageRepo := postgres.NewInboundMessageRepository(pool)
|
||||
spool := t.TempDir()
|
||||
blobStorage := diskstorage.NewLocalDiskBlobStorage(pool, spool)
|
||||
|
||||
processInboundUC := appusecase.NewProcessInboundEmailUseCase(mailboxRepo, blobStorage, messageRepo)
|
||||
|
||||
// Mock ClamAV server state
|
||||
var clamavInfected bool
|
||||
|
||||
clamavLn, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen mock clamav: %v", err)
|
||||
}
|
||||
defer clamavLn.Close()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := clamavLn.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
_, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if clamavInfected {
|
||||
conn.Write([]byte("stream: EICAR-Test-Signature FOUND\x00"))
|
||||
} else {
|
||||
conn.Write([]byte("stream: OK\x00"))
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// Mock Rspamd server state
|
||||
var rspamdAction string = "no action"
|
||||
|
||||
rspamdTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]interface{}{
|
||||
"action": rspamdAction,
|
||||
"score": 5.0,
|
||||
"required_score": 15.0,
|
||||
"symbols": map[string]interface{}{},
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer rspamdTS.Close()
|
||||
|
||||
clamavAdapter := clamav.NewClamAVAdapter(clamavLn.Addr().String())
|
||||
rspamdAdapter := rspamd.NewRspamdAdapter(rspamdTS.URL)
|
||||
|
||||
pipeline := appusecase.NewScanningPipeline(clamavAdapter, rspamdAdapter)
|
||||
processInboundUC.SetScanner(pipeline)
|
||||
|
||||
smtpServer := gosmtp.NewServer(smtppresentation.NewBackend(processInboundUC))
|
||||
smtpServer.Addr = "127.0.0.1:25026"
|
||||
smtpServer.Domain = "scan.test"
|
||||
smtpServer.AllowInsecureAuth = true
|
||||
|
||||
go smtpServer.ListenAndServe()
|
||||
defer smtpServer.Close()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// 1. Clean message -> delivered to INBOX
|
||||
clamavInfected = false
|
||||
rspamdAction = "no action"
|
||||
err = sendTestSMTPMessage("127.0.0.1:25026", "sender@external.test", "user@scan.test", "Clean Email Body")
|
||||
if err != nil {
|
||||
t.Fatalf("clean email delivery failed: %v", err)
|
||||
}
|
||||
|
||||
var inboxCount int
|
||||
pool.QueryRow(ctx, `SELECT total_count FROM folders WHERE id = $1`, inboxID).Scan(&inboxCount)
|
||||
if inboxCount != 1 {
|
||||
t.Errorf("expected 1 message in INBOX, got %d", inboxCount)
|
||||
}
|
||||
|
||||
// 2. Virus message -> rejected with 554 5.7.1
|
||||
clamavInfected = true
|
||||
err = sendTestSMTPMessage("127.0.0.1:25026", "sender@external.test", "user@scan.test", "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*")
|
||||
if err == nil || !strings.Contains(err.Error(), "554 5.7.1 Virus detected") {
|
||||
t.Fatalf("expected 554 virus rejection, got: %v", err)
|
||||
}
|
||||
|
||||
// 3. Spam message with action=add header -> delivered to Junk folder
|
||||
clamavInfected = false
|
||||
rspamdAction = "add header"
|
||||
err = sendTestSMTPMessage("127.0.0.1:25026", "sender@external.test", "user@scan.test", "Spammy Email Body")
|
||||
if err != nil {
|
||||
t.Fatalf("junk email delivery failed: %v", err)
|
||||
}
|
||||
|
||||
var junkCount int
|
||||
pool.QueryRow(ctx, `SELECT total_count FROM folders WHERE id = $1`, junkID).Scan(&junkCount)
|
||||
if junkCount != 1 {
|
||||
t.Errorf("expected 1 message in Junk folder, got %d", junkCount)
|
||||
}
|
||||
}
|
||||
|
||||
func sendTestSMTPMessage(addr, sender, rcpt, body string) error {
|
||||
conn, err := net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
buf := make([]byte, 1024)
|
||||
conn.Read(buf) // greeting
|
||||
|
||||
conn.Write([]byte("HELO test.sender\r\n"))
|
||||
conn.Read(buf)
|
||||
|
||||
conn.Write([]byte("MAIL FROM:<" + sender + ">\r\n"))
|
||||
conn.Read(buf)
|
||||
|
||||
conn.Write([]byte("RCPT TO:<" + rcpt + ">\r\n"))
|
||||
conn.Read(buf)
|
||||
|
||||
conn.Write([]byte("DATA\r\n"))
|
||||
conn.Read(buf)
|
||||
|
||||
msg := "From: " + sender + "\r\nTo: " + rcpt + "\r\nSubject: Test\r\n\r\n" + body + "\r\n.\r\n"
|
||||
conn.Write([]byte(msg))
|
||||
|
||||
n, _ := conn.Read(buf)
|
||||
respStr := string(buf[:n])
|
||||
|
||||
if !strings.HasPrefix(respStr, "250") {
|
||||
return fmtError(respStr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fmtError(msg string) error {
|
||||
return net.UnknownNetworkError(strings.TrimSpace(msg))
|
||||
}
|
||||
Reference in new issue
Block a user