Add rspamd tests
This commit is contained in:
parent
2a2bfe46a8
commit
da93d6d706
1 changed files with 394 additions and 0 deletions
394
pkg/analyzer/rspamd_test.go
Normal file
394
pkg/analyzer/rspamd_test.go
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/mail"
|
||||
"testing"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/api"
|
||||
)
|
||||
|
||||
func TestAnalyzeRspamdNoHeaders(t *testing.T) {
|
||||
analyzer := NewRspamdAnalyzer()
|
||||
email := &EmailMessage{Header: make(mail.Header)}
|
||||
|
||||
result := analyzer.AnalyzeRspamd(email)
|
||||
|
||||
if result != nil {
|
||||
t.Errorf("Expected nil for email without rspamd headers, got %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSpamdResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
expectedScore float32
|
||||
expectedThreshold float32
|
||||
expectedIsSpam bool
|
||||
expectedSymbols map[string]float32
|
||||
expectedSymParams map[string]string
|
||||
}{
|
||||
{
|
||||
name: "Clean email negative score",
|
||||
header: "default: False [-3.91 / 15.00];\n\tDATE_IN_PAST(0.10); ALL_TRUSTED(-1.00)[trusted]",
|
||||
expectedScore: -3.91,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: false,
|
||||
expectedSymbols: map[string]float32{
|
||||
"DATE_IN_PAST": 0.10,
|
||||
"ALL_TRUSTED": -1.00,
|
||||
},
|
||||
expectedSymParams: map[string]string{
|
||||
"ALL_TRUSTED": "trusted",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Spam email True flag",
|
||||
header: "default: True [16.50 / 15.00];\n\tBAYES_99(5.00)[1.00]; SPOOFED_SENDER(3.50)",
|
||||
expectedScore: 16.50,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: true,
|
||||
expectedSymbols: map[string]float32{
|
||||
"BAYES_99": 5.00,
|
||||
"SPOOFED_SENDER": 3.50,
|
||||
},
|
||||
expectedSymParams: map[string]string{
|
||||
"BAYES_99": "1.00",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Zero threshold uses default",
|
||||
header: "default: False [1.00 / 0.00]",
|
||||
expectedScore: 1.00,
|
||||
expectedThreshold: rspamdDefaultAddHeaderThreshold,
|
||||
expectedIsSpam: false,
|
||||
expectedSymbols: map[string]float32{},
|
||||
},
|
||||
{
|
||||
name: "Symbol without params",
|
||||
header: "default: False [2.00 / 15.00];\n\tMISSING_DATE(1.00)",
|
||||
expectedScore: 2.00,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: false,
|
||||
expectedSymbols: map[string]float32{
|
||||
"MISSING_DATE": 1.00,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Case-insensitive true flag",
|
||||
header: "default: true [8.00 / 6.00]",
|
||||
expectedScore: 8.00,
|
||||
expectedThreshold: 6.00,
|
||||
expectedIsSpam: true,
|
||||
expectedSymbols: map[string]float32{},
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewRspamdAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := &api.RspamdResult{
|
||||
Symbols: make(map[string]api.RspamdSymbol),
|
||||
}
|
||||
analyzer.parseSpamdResult(tt.header, result)
|
||||
|
||||
if result.Score != tt.expectedScore {
|
||||
t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
|
||||
}
|
||||
if result.Threshold != tt.expectedThreshold {
|
||||
t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold)
|
||||
}
|
||||
if result.IsSpam != tt.expectedIsSpam {
|
||||
t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
|
||||
}
|
||||
for symName, expectedScore := range tt.expectedSymbols {
|
||||
sym, ok := result.Symbols[symName]
|
||||
if !ok {
|
||||
t.Errorf("Symbol %s not found", symName)
|
||||
continue
|
||||
}
|
||||
if sym.Score != expectedScore {
|
||||
t.Errorf("Symbol %s score = %v, want %v", symName, sym.Score, expectedScore)
|
||||
}
|
||||
}
|
||||
for symName, expectedParam := range tt.expectedSymParams {
|
||||
sym, ok := result.Symbols[symName]
|
||||
if !ok {
|
||||
t.Errorf("Symbol %s not found for params check", symName)
|
||||
continue
|
||||
}
|
||||
if sym.Params == nil {
|
||||
t.Errorf("Symbol %s params = nil, want %q", symName, expectedParam)
|
||||
} else if *sym.Params != expectedParam {
|
||||
t.Errorf("Symbol %s params = %q, want %q", symName, *sym.Params, expectedParam)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeRspamd(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
headers map[string]string
|
||||
expectedScore float32
|
||||
expectedThreshold float32
|
||||
expectedIsSpam bool
|
||||
expectedServer *string
|
||||
expectedSymCount int
|
||||
}{
|
||||
{
|
||||
name: "Full headers clean email",
|
||||
headers: map[string]string{
|
||||
"X-Spamd-Result": "default: False [-3.91 / 15.00];\n\tALL_TRUSTED(-1.00)[local]",
|
||||
"X-Rspamd-Score": "-3.91",
|
||||
"X-Rspamd-Server": "mail.example.com",
|
||||
},
|
||||
expectedScore: -3.91,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: false,
|
||||
expectedServer: func() *string { s := "mail.example.com"; return &s }(),
|
||||
expectedSymCount: 1,
|
||||
},
|
||||
{
|
||||
name: "X-Rspamd-Score overrides spamd result score",
|
||||
headers: map[string]string{
|
||||
"X-Spamd-Result": "default: False [2.00 / 15.00]",
|
||||
"X-Rspamd-Score": "3.50",
|
||||
},
|
||||
expectedScore: 3.50,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: false,
|
||||
},
|
||||
{
|
||||
name: "Spam email above threshold",
|
||||
headers: map[string]string{
|
||||
"X-Spamd-Result": "default: True [16.00 / 15.00];\n\tBAYES_99(5.00)",
|
||||
"X-Rspamd-Score": "16.00",
|
||||
},
|
||||
expectedScore: 16.00,
|
||||
expectedThreshold: 15.00,
|
||||
expectedIsSpam: true,
|
||||
expectedSymCount: 1,
|
||||
},
|
||||
{
|
||||
name: "No X-Spamd-Result, only X-Rspamd-Score below default threshold",
|
||||
headers: map[string]string{
|
||||
"X-Rspamd-Score": "2.00",
|
||||
},
|
||||
expectedScore: 2.00,
|
||||
expectedIsSpam: false,
|
||||
},
|
||||
{
|
||||
name: "No X-Spamd-Result, X-Rspamd-Score above default add-header threshold",
|
||||
headers: map[string]string{
|
||||
"X-Rspamd-Score": "7.00",
|
||||
},
|
||||
expectedScore: 7.00,
|
||||
expectedIsSpam: true,
|
||||
},
|
||||
{
|
||||
name: "Server header is trimmed",
|
||||
headers: map[string]string{
|
||||
"X-Rspamd-Score": "1.00",
|
||||
"X-Rspamd-Server": " rspamd-01 ",
|
||||
},
|
||||
expectedScore: 1.00,
|
||||
expectedServer: func() *string { s := "rspamd-01"; return &s }(),
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewRspamdAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
email := &EmailMessage{Header: make(mail.Header)}
|
||||
for k, v := range tt.headers {
|
||||
email.Header[k] = []string{v}
|
||||
}
|
||||
|
||||
result := analyzer.AnalyzeRspamd(email)
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected non-nil result")
|
||||
}
|
||||
if result.Score != tt.expectedScore {
|
||||
t.Errorf("Score = %v, want %v", result.Score, tt.expectedScore)
|
||||
}
|
||||
if tt.expectedThreshold > 0 && result.Threshold != tt.expectedThreshold {
|
||||
t.Errorf("Threshold = %v, want %v", result.Threshold, tt.expectedThreshold)
|
||||
}
|
||||
if result.IsSpam != tt.expectedIsSpam {
|
||||
t.Errorf("IsSpam = %v, want %v", result.IsSpam, tt.expectedIsSpam)
|
||||
}
|
||||
if tt.expectedServer != nil {
|
||||
if result.Server == nil {
|
||||
t.Errorf("Server = nil, want %q", *tt.expectedServer)
|
||||
} else if *result.Server != *tt.expectedServer {
|
||||
t.Errorf("Server = %q, want %q", *result.Server, *tt.expectedServer)
|
||||
}
|
||||
}
|
||||
if tt.expectedSymCount > 0 && len(result.Symbols) != tt.expectedSymCount {
|
||||
t.Errorf("Symbol count = %d, want %d", len(result.Symbols), tt.expectedSymCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateRspamdScore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result *api.RspamdResult
|
||||
expectedScore int
|
||||
expectedGrade string
|
||||
}{
|
||||
{
|
||||
name: "Nil result (rspamd not installed)",
|
||||
result: nil,
|
||||
expectedScore: 100,
|
||||
expectedGrade: "",
|
||||
},
|
||||
{
|
||||
name: "Score well below threshold",
|
||||
result: &api.RspamdResult{
|
||||
Score: -3.91,
|
||||
Threshold: 15.00,
|
||||
},
|
||||
expectedScore: 100,
|
||||
expectedGrade: "A+",
|
||||
},
|
||||
{
|
||||
name: "Score at zero",
|
||||
result: &api.RspamdResult{
|
||||
Score: 0,
|
||||
Threshold: 15.00,
|
||||
},
|
||||
// 100 - round(0*100/30) = 100 → hits ScoreToGrade(100) = "A"
|
||||
expectedScore: 100,
|
||||
expectedGrade: "A",
|
||||
},
|
||||
{
|
||||
name: "Score at threshold (half of 2*threshold)",
|
||||
result: &api.RspamdResult{
|
||||
Score: 15.00,
|
||||
Threshold: 15.00,
|
||||
},
|
||||
// 100 - round(15*100/(2*15)) = 100 - 50 = 50
|
||||
expectedScore: 50,
|
||||
},
|
||||
{
|
||||
name: "Score above 2*threshold",
|
||||
result: &api.RspamdResult{
|
||||
Score: 31.00,
|
||||
Threshold: 15.00,
|
||||
},
|
||||
expectedScore: 0,
|
||||
expectedGrade: "F",
|
||||
},
|
||||
{
|
||||
name: "Score exactly at 2*threshold",
|
||||
result: &api.RspamdResult{
|
||||
Score: 30.00,
|
||||
Threshold: 15.00,
|
||||
},
|
||||
// 100 - round(30*100/30) = 100 - 100 = 0
|
||||
expectedScore: 0,
|
||||
expectedGrade: "F",
|
||||
},
|
||||
}
|
||||
|
||||
analyzer := NewRspamdAnalyzer()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
score, grade := analyzer.CalculateRspamdScore(tt.result)
|
||||
|
||||
if score != tt.expectedScore {
|
||||
t.Errorf("Score = %d, want %d", score, tt.expectedScore)
|
||||
}
|
||||
if tt.expectedGrade != "" && grade != tt.expectedGrade {
|
||||
t.Errorf("Grade = %q, want %q", grade, tt.expectedGrade)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const sampleEmailWithRspamdHeaders = `X-Spamd-Result: default: False [-3.91 / 15.00];
|
||||
BAYES_HAM(-3.00)[99%];
|
||||
RCVD_IN_DNSWL_MED(-0.01)[1.2.3.4:from];
|
||||
R_DKIM_ALLOW(-0.20)[example.com:s=dkim];
|
||||
FROM_HAS_DN(0.00)[];
|
||||
MIME_GOOD(-0.10)[text/plain];
|
||||
X-Rspamd-Score: -3.91
|
||||
X-Rspamd-Server: rspamd-01.example.com
|
||||
Date: Mon, 09 Mar 2026 10:00:00 +0000
|
||||
From: sender@example.com
|
||||
To: test@happydomain.org
|
||||
Subject: Test email
|
||||
Message-ID: <test123@example.com>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain
|
||||
|
||||
Hello world`
|
||||
|
||||
func TestAnalyzeRspamdRealEmail(t *testing.T) {
|
||||
email, err := ParseEmail(bytes.NewBufferString(sampleEmailWithRspamdHeaders))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse email: %v", err)
|
||||
}
|
||||
|
||||
analyzer := NewRspamdAnalyzer()
|
||||
result := analyzer.AnalyzeRspamd(email)
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected non-nil result")
|
||||
}
|
||||
if result.IsSpam {
|
||||
t.Error("Expected IsSpam=false")
|
||||
}
|
||||
if result.Score != -3.91 {
|
||||
t.Errorf("Score = %v, want -3.91", result.Score)
|
||||
}
|
||||
if result.Threshold != 15.00 {
|
||||
t.Errorf("Threshold = %v, want 15.00", result.Threshold)
|
||||
}
|
||||
if result.Server == nil || *result.Server != "rspamd-01.example.com" {
|
||||
t.Errorf("Server = %v, want \"rspamd-01.example.com\"", result.Server)
|
||||
}
|
||||
|
||||
expectedSymbols := []string{"BAYES_HAM", "RCVD_IN_DNSWL_MED", "R_DKIM_ALLOW", "FROM_HAS_DN", "MIME_GOOD"}
|
||||
for _, sym := range expectedSymbols {
|
||||
if _, ok := result.Symbols[sym]; !ok {
|
||||
t.Errorf("Symbol %s not found", sym)
|
||||
}
|
||||
}
|
||||
|
||||
score, _ := analyzer.CalculateRspamdScore(result)
|
||||
if score != 100 {
|
||||
t.Errorf("CalculateRspamdScore = %d, want 100", score)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue