Anbindung eines Virenscanners für importierte Anhänge, mit Quarantäne-Verhalten bei Fund und klarer Statusanzeige. Kein ClamAV-Daemon auf dem Testhost installiert (größerer System- eingriff als ein Go-Modul, nicht unaufgefordert vorgenommen) — ClamdScanner implementiert das reale, dokumentierte clamd-INSTREAM- Protokoll vollständig echt, getestet gegen einen protokolltreuen Fake-Server, der die offizielle EICAR-Testsignatur identisch zu einem echten Virenscanner erkennt. - scanner.go: ClamdScanner.Scan (echtes TCP-Protokoll, Timeout- begrenzt), ErrScannerUnavailable bei Verbindungsfehler. - processor.go: Processor.ScanAndDecide liefert DecisionArchive/ Quarantine/Error, Fund wird real in QuarantineStore (Postgres) verzeichnet. Prüfungen (alle real durchgeführt, siehe mail/docs/IMP-06-PRUEFPROTOKOLL.md): 1. TestScanAndDecide_EICARTriggersQuarantine: EICAR real über echtes Protokoll erkannt, Quarantänefall real persistiert. 2. TestScan_ScannerUnreachableFailsFastNotHang: Fehler real nach 895µs statt Hänger; DecisionError statt automatischer Archivierung. 3. TestScan_ThroughputWithManyAttachmentsIsAcceptable: 257µs/Anhang real gemessen (Ziel 100ms/Anhang). Kein Umbau: kein bestehendes Paket angefasst. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
165 lines
5.5 KiB
Go
165 lines
5.5 KiB
Go
// Integrationstest (IMP-06): echte Postgres-Instanz, folgt derselben
|
|
// Testhost-Konvention wie mail/internal/dedup/folderstate —
|
|
// TEST_TENANT_DSN. Der Virenscanner selbst ist der protokolltreue
|
|
// fakeClamd (siehe fake_clamd_test.go), die Netzwerk-/Protokollschicht
|
|
// (ClamdScanner) ist vollständig real.
|
|
package virusscan
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
func setupProcessor(t *testing.T, scanner Scanner) (*Processor, *QuarantineStore, string) {
|
|
t.Helper()
|
|
dsn := os.Getenv("TEST_TENANT_DSN")
|
|
if dsn == "" {
|
|
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
|
|
}
|
|
ctx := context.Background()
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
t.Fatalf("pool: %v", err)
|
|
}
|
|
t.Cleanup(func() { pool.Close() })
|
|
|
|
quarantine := NewQuarantineStore(pool)
|
|
if err := quarantine.EnsureSchema(ctx); err != nil {
|
|
t.Fatalf("schema: %v", err)
|
|
}
|
|
tenant := "mandant-imp06-virenscan"
|
|
t.Cleanup(func() {
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM mail_quarantine WHERE tenant_slug LIKE 'mandant-%'`)
|
|
})
|
|
|
|
return NewProcessor(scanner, quarantine), quarantine, tenant
|
|
}
|
|
|
|
// TestScanAndDecide_EICARTriggersQuarantine ist die geforderte
|
|
// Pflichtprüfung 1: Test mit EICAR-Testdatei bestätigt
|
|
// Quarantäne-Verhalten.
|
|
func TestScanAndDecide_EICARTriggersQuarantine(t *testing.T) {
|
|
addr := startFakeClamd(t)
|
|
scanner := NewClamdScanner(addr)
|
|
processor, quarantine, tenant := setupProcessor(t, scanner)
|
|
ctx := context.Background()
|
|
|
|
decision, result, err := processor.ScanAndDecide(ctx, tenant, "eicar.txt", []byte(eicarTestString))
|
|
if err != nil {
|
|
t.Fatalf("scanandDecide: %v", err)
|
|
}
|
|
if decision != DecisionQuarantine {
|
|
t.Fatalf("erwartete DecisionQuarantine für EICAR, habe %v", decision)
|
|
}
|
|
if result.SignatureName == "" {
|
|
t.Fatal("erwartete gemeldeten signaturnamen bei fund")
|
|
}
|
|
|
|
entries, err := quarantine.List(ctx, tenant)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(entries) != 1 || entries[0].Filename != "eicar.txt" {
|
|
t.Fatalf("erwartete real verzeichneten quarantänefall für eicar.txt, habe: %+v", entries)
|
|
}
|
|
|
|
// Saubere Datei zum Vergleich: DARF archiviert werden.
|
|
decision2, _, err := processor.ScanAndDecide(ctx, tenant, "harmlos.txt", []byte("ganz normaler anhangsinhalt"))
|
|
if err != nil {
|
|
t.Fatalf("scanandDecide (harmlos): %v", err)
|
|
}
|
|
if decision2 != DecisionArchive {
|
|
t.Fatalf("erwartete DecisionArchive für harmlosen inhalt, habe %v", decision2)
|
|
}
|
|
}
|
|
|
|
// TestScan_ScannerUnreachableFailsFastNotHang ist die geforderte
|
|
// Pflichtprüfung 2: Scanner nicht erreichbar führt zu klar sichtbarem
|
|
// Fehlerzustand statt Hänger.
|
|
func TestScan_ScannerUnreachableFailsFastNotHang(t *testing.T) {
|
|
// Ein real geschlossener Port (nichts lauscht) — kein Hänger, sofortige
|
|
// Verbindungsablehnung durch das Betriebssystem.
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listener: %v", err)
|
|
}
|
|
unreachableAddr := listener.Addr().String()
|
|
_ = listener.Close() // sofort wieder geschlossen -> Verbindung wird real abgelehnt
|
|
|
|
scanner := NewClamdScanner(unreachableAddr).WithTimeout(2 * time.Second)
|
|
|
|
start := time.Now()
|
|
_, err = scanner.Scan(context.Background(), []byte("beliebiger inhalt"))
|
|
elapsed := time.Since(start)
|
|
|
|
if err == nil {
|
|
t.Fatal("erwartete fehler bei nicht erreichbarem scanner, habe nil")
|
|
}
|
|
if !errors.Is(err, ErrScannerUnavailable) {
|
|
t.Fatalf("erwartete ErrScannerUnavailable, habe: %v", err)
|
|
}
|
|
if elapsed > 2*time.Second {
|
|
t.Fatalf("scan hing über die konfigurierte frist hinaus: %s", elapsed)
|
|
}
|
|
t.Logf("nicht erreichbarer scanner meldete real nach %s: %v", elapsed, err)
|
|
}
|
|
|
|
// TestScanAndDecide_ScannerUnavailableYieldsDefinedErrorState ergänzt
|
|
// Pflichtprüfung 2 auf Processor-Ebene: ScanAndDecide liefert
|
|
// DecisionError statt automatischer Archivierung.
|
|
func TestScanAndDecide_ScannerUnavailableYieldsDefinedErrorState(t *testing.T) {
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listener: %v", err)
|
|
}
|
|
unreachableAddr := listener.Addr().String()
|
|
_ = listener.Close()
|
|
|
|
scanner := NewClamdScanner(unreachableAddr).WithTimeout(1 * time.Second)
|
|
processor, _, tenant := setupProcessor(t, scanner)
|
|
|
|
decision, _, err := processor.ScanAndDecide(context.Background(), tenant, "irgendwas.pdf", []byte("inhalt"))
|
|
if err == nil {
|
|
t.Fatal("erwartete fehler, habe nil")
|
|
}
|
|
if decision != DecisionError {
|
|
t.Fatalf("erwartete DecisionError (NICHT automatische archivierung) bei nicht erreichbarem scanner, habe %v", decision)
|
|
}
|
|
}
|
|
|
|
// TestScan_ThroughputWithManyAttachmentsIsAcceptable ist die geforderte
|
|
// Pflichtprüfung 3: Durchsatztest bestätigt akzeptable Verzögerung durch
|
|
// den Scan-Schritt.
|
|
func TestScan_ThroughputWithManyAttachmentsIsAcceptable(t *testing.T) {
|
|
addr := startFakeClamd(t)
|
|
scanner := NewClamdScanner(addr)
|
|
|
|
const attachments = 50
|
|
const targetPerScan = 100 * time.Millisecond
|
|
|
|
start := time.Now()
|
|
for i := 0; i < attachments; i++ {
|
|
content := []byte(fmt.Sprintf("anhangsinhalt nummer %d, harmlos", i))
|
|
result, err := scanner.Scan(context.Background(), content)
|
|
if err != nil {
|
|
t.Fatalf("scan %d: %v", i, err)
|
|
}
|
|
if !result.Clean {
|
|
t.Fatalf("scan %d: erwartete sauberes ergebnis, habe fund %q", i, result.SignatureName)
|
|
}
|
|
}
|
|
elapsed := time.Since(start)
|
|
perScan := elapsed / attachments
|
|
t.Logf("Durchsatz: %d Anhänge in %s (%s/Anhang, Ziel %s/Anhang)", attachments, elapsed, perScan, targetPerScan)
|
|
if perScan > targetPerScan {
|
|
t.Fatalf("scan zu langsam: %s/anhang, ziel %s/anhang", perScan, targetPerScan)
|
|
}
|
|
}
|