// 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) } }