package antivirus_test import ( "bytes" "context" "strings" "testing" "time" "github.com/cozy/cozy-stack/model/instance" "github.com/cozy/cozy-stack/model/instance/lifecycle" "github.com/cozy/cozy-stack/model/job" "github.com/cozy/cozy-stack/model/notification" "github.com/cozy/cozy-stack/model/vfs" "github.com/cozy/cozy-stack/pkg/clamav" "github.com/cozy/cozy-stack/pkg/config/config" "github.com/cozy/cozy-stack/pkg/consts" "github.com/cozy/cozy-stack/pkg/couchdb" "github.com/cozy/cozy-stack/pkg/logger" "github.com/cozy/cozy-stack/pkg/utils" "github.com/cozy/cozy-stack/tests/testutils" "github.com/cozy/cozy-stack/worker/antivirus" "github.com/stretchr/testify/require" ) func TestAntivirus(t *testing.T) { if testing.Short() { t.Skip("Skipping ClamAV tests in short mode") } fixture := testutils.StartClamAV(t) clamavAddress := fixture.Address // ClamAV client tests can run in parallel t.Run("ClamAVClient", func(t *testing.T) { t.Run("CleanFile", func(t *testing.T) { t.Parallel() client := clamav.NewClient(clamavAddress, 30*time.Second, logger.WithNamespace("test")) // Test PING err := client.Ping(context.Background()) require.NoError(t, err) // Test scanning a clean file cleanContent := []byte("This is a clean test file content") result, err := client.Scan(context.Background(), bytes.NewReader(cleanContent)) require.NoError(t, err) require.Equal(t, clamav.StatusClean, result.Status) require.Empty(t, result.VirusName) require.Empty(t, result.Error) }) t.Run("InfectedFile", func(t *testing.T) { t.Parallel() client := clamav.NewClient(clamavAddress, 30*time.Second, logger.WithNamespace("test")) // Test scanning an infected file (EICAR test signature) infectedContent := testutils.EICARTestSignature() result, err := client.Scan(context.Background(), bytes.NewReader(infectedContent)) require.NoError(t, err) require.Equal(t, clamav.StatusInfected, result.Status) require.Contains(t, strings.ToUpper(result.VirusName), "EICAR") require.Empty(t, result.Error) }) }) // Worker tests share a single instance setup t.Run("Worker", func(t *testing.T) { config.UseTestFile(t) setup := testutils.NewSetup(t, "antivirus_worker_test") inst := setup.GetTestInstance(&lifecycle.Options{}) // Configure antivirus in context for all worker tests conf := config.GetConfig() if conf.Contexts == nil { conf.Contexts = make(map[string]interface{}) } conf.Contexts[config.DefaultInstanceContext] = map[string]interface{}{ "antivirus": map[string]interface{}{ "enabled": true, "address": clamavAddress, "timeout": 30 * time.Second, "max_file_size": int64(0), "notifications": map[string]interface{}{ "email_on_infected": true, }, }, } t.Cleanup(func() { delete(conf.Contexts, config.DefaultInstanceContext) }) t.Run("CleanFile", func(t *testing.T) { testWorkerCleanFile(t, inst) }) t.Run("InfectedFile", func(t *testing.T) { testWorkerInfectedFile(t, inst) }) t.Run("SkipsLargeFiles", func(t *testing.T) { testWorkerSkipsLargeFiles(t, inst) }) t.Run("DeletedFile", func(t *testing.T) { testWorkerDeletedFile(t, inst) }) t.Run("Disabled", func(t *testing.T) { testWorkerDisabled(t, inst) }) t.Run("FileVersionUpdate", func(t *testing.T) { testWorkerFileVersionUpdate(t, inst) }) }) } func testWorkerCleanFile(t *testing.T, inst *instance.Instance) { // Create a test file fs := inst.VFS() doc := createTestFile(t, fs, "clean-test.txt", []byte("This is a clean file")) defer deleteTestFile(t, fs, doc) // Create and run the job msg, err := job.NewMessage(&antivirus.Message{FileID: doc.DocID}) require.NoError(t, err) j := &job.Job{ Domain: inst.Domain, WorkerType: "antivirus", Message: msg, } ctx, cancel := job.NewTaskContext("test", j, inst) defer cancel() err = antivirus.Worker(ctx) require.NoError(t, err) // Verify the file was updated updatedDoc, err := fs.FileByID(doc.DocID) require.NoError(t, err) require.NotNil(t, updatedDoc.AntivirusScan) require.Equal(t, vfs.AVStatusClean, updatedDoc.AntivirusScan.Status) require.NotNil(t, updatedDoc.AntivirusScan.ScannedAt) require.Empty(t, updatedDoc.AntivirusScan.VirusName) } func testWorkerInfectedFile(t *testing.T, inst *instance.Instance) { // Create a test file with EICAR signature fs := inst.VFS() doc := createTestFile(t, fs, "infected-test.txt", testutils.EICARTestSignature()) defer deleteTestFile(t, fs, doc) // Create and run the job msg, err := job.NewMessage(&antivirus.Message{FileID: doc.DocID}) require.NoError(t, err) j := &job.Job{ Domain: inst.Domain, WorkerType: "antivirus", Message: msg, } ctx, cancel := job.NewTaskContext("test", j, inst) defer cancel() err = antivirus.Worker(ctx) require.NoError(t, err) // Verify the file was updated updatedDoc, err := fs.FileByID(doc.DocID) require.NoError(t, err) require.NotNil(t, updatedDoc.AntivirusScan) require.Equal(t, vfs.AVStatusInfected, updatedDoc.AntivirusScan.Status) require.NotNil(t, updatedDoc.AntivirusScan.ScannedAt) require.Contains(t, strings.ToUpper(updatedDoc.AntivirusScan.VirusName), "EICAR") // Verify notification was sent verifyNotificationCreated(t, inst, "infected-test.txt") } func testWorkerSkipsLargeFiles(t *testing.T, inst *instance.Instance) { // Configure antivirus with a small max file size conf := config.GetConfig() ctxData := conf.Contexts[config.DefaultInstanceContext].(map[string]interface{}) avData := ctxData["antivirus"].(map[string]interface{}) originalMaxFileSize := avData["max_file_size"] avData["max_file_size"] = int64(10) // 10 bytes defer func() { avData["max_file_size"] = originalMaxFileSize }() // Create a test file larger than max size fs := inst.VFS() largeContent := []byte("This is a file larger than 10 bytes") doc := createTestFile(t, fs, "large-test.txt", largeContent) defer deleteTestFile(t, fs, doc) // Create and run the job msg, err := job.NewMessage(&antivirus.Message{FileID: doc.DocID}) require.NoError(t, err) j := &job.Job{ Domain: inst.Domain, WorkerType: "antivirus", Message: msg, } ctx, cancel := job.NewTaskContext("test", j, inst) defer cancel() err = antivirus.Worker(ctx) require.NoError(t, err) // Verify the file was marked as skipped updatedDoc, err := fs.FileByID(doc.DocID) require.NoError(t, err) require.NotNil(t, updatedDoc.AntivirusScan) require.Equal(t, vfs.AVStatusSkipped, updatedDoc.AntivirusScan.Status) // Verify notification was sent verifyNotificationCreated(t, inst, "large-test.txt") } func testWorkerDeletedFile(t *testing.T, inst *instance.Instance) { // Create and run the job for a non-existent file msg, err := job.NewMessage(&antivirus.Message{FileID: "non-existent-file-id"}) require.NoError(t, err) j := &job.Job{ Domain: inst.Domain, WorkerType: "antivirus", Message: msg, } ctx, cancel := job.NewTaskContext("test", j, inst) defer cancel() err = antivirus.Worker(ctx) // Should not error - just skip require.NoError(t, err) } func testWorkerDisabled(t *testing.T, inst *instance.Instance) { // Temporarily disable antivirus originalEnabled := antivirusEnabledValue(t) t.Cleanup(func() { setAntivirusEnabled(t, originalEnabled) }) setAntivirusEnabled(t, false) // Create a test file fs := inst.VFS() doc := createTestFile(t, fs, "disabled-test.txt", []byte("test content")) defer deleteTestFile(t, fs, doc) // Create and run the job msg, err := job.NewMessage(&antivirus.Message{FileID: doc.DocID}) require.NoError(t, err) j := &job.Job{ Domain: inst.Domain, WorkerType: "antivirus", Message: msg, } ctx, cancel := job.NewTaskContext("test", j, inst) defer cancel() err = antivirus.Worker(ctx) require.NoError(t, err) // Verify the file was not updated (no scan status) updatedDoc, err := fs.FileByID(doc.DocID) require.NoError(t, err) require.Nil(t, updatedDoc.AntivirusScan) } func createTestFile(t *testing.T, fs vfs.VFS, name string, content []byte) *vfs.FileDoc { t.Helper() parent, err := fs.DirByPath("/") require.NoError(t, err) doc, err := vfs.NewFileDoc(name, parent.DocID, int64(len(content)), nil, "text/plain", "text", time.Now(), false, false, false, nil) require.NoError(t, err) file, err := fs.CreateFile(doc, nil) require.NoError(t, err) _, err = file.Write(content) require.NoError(t, err) err = file.Close() require.NoError(t, err) updatedDoc, err := fs.FileByID(doc.DocID) require.NoError(t, err) return updatedDoc } func deleteTestFile(t *testing.T, fs vfs.VFS, doc *vfs.FileDoc) { t.Helper() _ = fs.DestroyFile(doc) } func verifyNotificationCreated(t *testing.T, inst *instance.Instance, fileName string) { t.Helper() var notifs []*notification.Notification err := couchdb.GetAllDocs(inst, consts.Notifications, nil, ¬ifs) require.NoError(t, err) found := false for _, n := range notifs { if n.Category == "antivirus-alert" { if data, ok := n.Data["FileName"].(string); ok && data == fileName { found = true break } } } require.True(t, found, "Expected notification for file %s not found", fileName) } // testWorkerFileVersionUpdate tests that when a file is updated with new content, // the antivirus worker properly scans the new version. func testWorkerFileVersionUpdate(t *testing.T, inst *instance.Instance) { fs := inst.VFS() originalEnabled := antivirusEnabledValue(t) t.Cleanup(func() { setAntivirusEnabled(t, originalEnabled) }) // Step 1: Create a clean file and scan it setAntivirusEnabled(t, false) doc := createTestFile(t, fs, "version-test.txt", []byte("This is clean content")) defer deleteTestFile(t, fs, doc) // Run initial scan setAntivirusEnabled(t, true) msg, err := job.NewMessage(&antivirus.Message{FileID: doc.DocID}) require.NoError(t, err) j := &job.Job{ Domain: inst.Domain, WorkerType: "antivirus", Message: msg, } ctx, cancel := job.NewTaskContext("test", j, inst) err = antivirus.Worker(ctx) cancel() require.NoError(t, err) // Verify file is clean doc, err = fs.FileByID(doc.DocID) require.NoError(t, err) require.NotNil(t, doc.AntivirusScan) require.Equal(t, vfs.AVStatusClean, doc.AntivirusScan.Status) originalMD5 := doc.MD5Sum // Step 2: Update file with infected content (simulating new version upload) setAntivirusEnabled(t, false) doc = updateTestFileContent(t, fs, doc, testutils.EICARTestSignature()) // Verify MD5 changed (content was updated) require.NotEqual(t, originalMD5, doc.MD5Sum, "MD5 should change when content is updated") // Step 3: Run scan again on the updated file setAntivirusEnabled(t, true) msg, err = job.NewMessage(&antivirus.Message{FileID: doc.DocID}) require.NoError(t, err) j = &job.Job{ Domain: inst.Domain, WorkerType: "antivirus", Message: msg, } ctx, cancel = job.NewTaskContext("test", j, inst) err = antivirus.Worker(ctx) cancel() require.NoError(t, err) // Step 4: Verify the new version is detected as infected updatedDoc, err := fs.FileByID(doc.DocID) require.NoError(t, err) require.NotNil(t, updatedDoc.AntivirusScan) require.Equal(t, vfs.AVStatusInfected, updatedDoc.AntivirusScan.Status) require.Contains(t, strings.ToUpper(updatedDoc.AntivirusScan.VirusName), "EICAR") } // updateTestFileContent updates a file's content by creating a new version. // It retries on conflict errors caused by background antivirus jobs updating the document. func updateTestFileContent(t *testing.T, fs vfs.VFS, olddoc *vfs.FileDoc, newContent []byte) *vfs.FileDoc { t.Helper() var result *vfs.FileDoc fileID := olddoc.DocID err := utils.RetryWithExpBackoff(5, 10*time.Millisecond, func() error { // Re-fetch the document to get the latest revision currentDoc, err := fs.FileByID(fileID) if err != nil { return err } // Create new file doc for update newdoc, err := vfs.NewFileDoc( currentDoc.DocName, currentDoc.DirID, -1, // size will be calculated nil, // MD5 will be calculated currentDoc.Mime, currentDoc.Class, time.Now(), currentDoc.Executable, currentDoc.Trashed, currentDoc.Encrypted, currentDoc.Tags, ) if err != nil { return err } // Create the new version of the file file, err := fs.CreateFile(newdoc, currentDoc) if err != nil { return err } if _, err = file.Write(newContent); err != nil { return err } if err = file.Close(); err != nil { return err } // Success - get the updated document result, err = fs.FileByID(fileID) return err }) require.NoError(t, err) return result } func antivirusEnabledValue(t *testing.T) bool { t.Helper() conf := config.GetConfig() ctxData := conf.Contexts[config.DefaultInstanceContext].(map[string]interface{}) avData := ctxData["antivirus"].(map[string]interface{}) return avData["enabled"].(bool) } func setAntivirusEnabled(t *testing.T, enabled bool) { t.Helper() conf := config.GetConfig() ctxData := conf.Contexts[config.DefaultInstanceContext].(map[string]interface{}) avData := ctxData["antivirus"].(map[string]interface{}) avData["enabled"] = enabled }