From 33f24aca76313b72e8c0fa4fb4e904d33a09aed5 Mon Sep 17 00:00:00 2001 From: sadq Date: Fri, 25 Jul 2025 21:48:02 +0330 Subject: [PATCH 01/21] feat: add clamav scanner related configs, errors and entities --- config/config.go | 3 ++ config/config.yml | 7 ++- internal/domain/entity/malware_scan_result.go | 45 +++++++++++++++++++ internal/infrastructure/clamav/config.go | 6 +++ internal/infrastructure/clamav/error.go | 39 ++++++++++++++++ 5 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 internal/domain/entity/malware_scan_result.go create mode 100644 internal/infrastructure/clamav/config.go create mode 100644 internal/infrastructure/clamav/error.go diff --git a/config/config.go b/config/config.go index 96c6f03..1a770ce 100644 --- a/config/config.go +++ b/config/config.go @@ -9,6 +9,7 @@ import ( "gopkg.in/yaml.v3" "nos3/internal/infrastructure/broker" + "nos3/internal/infrastructure/clamav" "nos3/internal/infrastructure/database" "nos3/internal/infrastructure/grpcclient" "nos3/internal/infrastructure/minio" @@ -28,6 +29,7 @@ type Config struct { GRPCClient grpcclient.ClientConfig `yaml:"manager"` GRPCServer grpcclient.ServerConfig `yaml:"grpc_server"` Logger logger.Config `yaml:"logger"` + ClamAVConfig clamav.ScannerConfig `yaml:"clamav_scanner"` } func Load(path string) (*Config, error) { @@ -61,6 +63,7 @@ func Load(path string) (*Config, error) { config.MinIOClient.SecretKey = os.Getenv("MINIO_ROOT_PASSWORD") config.DBConfig.URI = os.Getenv("DATABASE_URI") config.BrokerConfig.URI = os.Getenv("BROKER_URI") + config.ClamAVConfig.Address = os.Getenv("CLAMAV_ADDRESS") if err = config.basicCheck(); err != nil { return nil, Error{ diff --git a/config/config.yml b/config/config.yml index c9ab09d..4b2f5fd 100644 --- a/config/config.yml +++ b/config/config.yml @@ -105,4 +105,9 @@ logger: # targets is targets for logs to be written to. targets: [file, console] - \ No newline at end of file + +clamav_scanner: + + address: "" + + timeout: diff --git a/internal/domain/entity/malware_scan_result.go b/internal/domain/entity/malware_scan_result.go new file mode 100644 index 0000000..a916f1f --- /dev/null +++ b/internal/domain/entity/malware_scan_result.go @@ -0,0 +1,45 @@ +package entity + +type MalwareScanStatus int + +const ( + MalwareScanStatusUnknown MalwareScanStatus = iota + MalwareScanStatusClean + MalwareScanStatusInfected + MalwareScanStatusError +) + +func (s MalwareScanStatus) String() string { + switch s { + case MalwareScanStatusClean: + return "clean" + case MalwareScanStatusInfected: + return "infected" + case MalwareScanStatusError: + return "error" + default: + return "unknown" + } +} + +type MalwareScanResult struct { + Status MalwareScanStatus `json:"status"` + Error string `json:"error,omitempty"` + Threats []string `json:"threats,omitempty"` +} + +func (r MalwareScanResult) IsClean() bool { + return r.Status == MalwareScanStatusClean +} + +func (r MalwareScanResult) IsInfected() bool { + return r.Status == MalwareScanStatusInfected +} + +func (r MalwareScanResult) HasError() bool { + return r.Status == MalwareScanStatusError +} + +func (r MalwareScanResult) ThreatCount() int { + return len(r.Threats) +} diff --git a/internal/infrastructure/clamav/config.go b/internal/infrastructure/clamav/config.go new file mode 100644 index 0000000..c8570c4 --- /dev/null +++ b/internal/infrastructure/clamav/config.go @@ -0,0 +1,6 @@ +package clamav + +type ScannerConfig struct { + Address string `yaml:"address"` + Timeout int `yaml:"timeout"` +} diff --git a/internal/infrastructure/clamav/error.go b/internal/infrastructure/clamav/error.go new file mode 100644 index 0000000..11374ec --- /dev/null +++ b/internal/infrastructure/clamav/error.go @@ -0,0 +1,39 @@ +package clamav + +import "fmt" + +type ErrorCode int + +const ( + ErrorCodeUnknown ErrorCode = iota + ErrorCodeConnectionFailed + ErrorCodeScanFailed + ErrorCodeTimeout + ErrorCodeMalwareDetected + ErrorCodeInvalidInput +) + +type MalwareError struct { + Code ErrorCode + Message string + Details string +} + +func (e *MalwareError) Error() string { + if e.Details != "" { + return fmt.Sprintf("%s: %s", e.Message, e.Details) + } + return e.Message +} + +func (e *MalwareError) IsTimeout() bool { + return e.Code == ErrorCodeTimeout +} + +func (e *MalwareError) IsMalwareDetected() bool { + return e.Code == ErrorCodeMalwareDetected +} + +func (e *MalwareError) IsConnectionError() bool { + return e.Code == ErrorCodeConnectionFailed +} From cd95cc9f2f4a76bdf0dbffa08eca33fc3c804e83 Mon Sep 17 00:00:00 2001 From: sadq Date: Fri, 25 Jul 2025 21:48:56 +0330 Subject: [PATCH 02/21] feat: add clamav scanner --- go.mod | 1 + go.sum | 2 + internal/domain/repository/clamav/scanner.go | 12 ++ internal/infrastructure/clamav/scanner.go | 159 +++++++++++++++++++ 4 files changed, 174 insertions(+) create mode 100644 internal/domain/repository/clamav/scanner.go create mode 100644 internal/infrastructure/clamav/scanner.go diff --git a/go.mod b/go.mod index cb0d796..7500db9 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module nos3 go 1.24.1 require ( + github.com/dutchcoders/go-clamd v0.0.0-20170520113014-b970184f4d9e github.com/gabriel-vasile/mimetype v1.4.9 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 diff --git a/go.sum b/go.sum index 26f8d1e..7e93312 100644 --- a/go.sum +++ b/go.sum @@ -58,6 +58,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/dutchcoders/go-clamd v0.0.0-20170520113014-b970184f4d9e h1:rcHHSQqzCgvlwP0I/fQ8rQMn/MpHE5gWSLdtpxtP6KQ= +github.com/dutchcoders/go-clamd v0.0.0-20170520113014-b970184f4d9e/go.mod h1:Byz7q8MSzSPkouskHJhX0er2mZY/m0Vj5bMeMCkkyY4= github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= diff --git a/internal/domain/repository/clamav/scanner.go b/internal/domain/repository/clamav/scanner.go new file mode 100644 index 0000000..79e6832 --- /dev/null +++ b/internal/domain/repository/clamav/scanner.go @@ -0,0 +1,12 @@ +package clamav + +import ( + "context" + "io" + "nos3/internal/domain/entity" +) + +// Scanner defines the interface for scanning files for malware. +type Scanner interface { + ScanStream(ctx context.Context, reader io.Reader) (entity.MalwareScanResult, error) +} diff --git a/internal/infrastructure/clamav/scanner.go b/internal/infrastructure/clamav/scanner.go new file mode 100644 index 0000000..dd484ee --- /dev/null +++ b/internal/infrastructure/clamav/scanner.go @@ -0,0 +1,159 @@ +package clamav + +import ( + "context" + "io" + "time" + + "github.com/dutchcoders/go-clamd" + + "nos3/internal/domain/entity" + grpcRepository "nos3/internal/domain/repository/grpcclient" + "nos3/pkg/logger" +) + +type Scanner struct { + clamd *clamd.Clamd + timeout time.Duration + grpcClient grpcRepository.IClient +} + +func NewScanner(cfg ScannerConfig, grpcClient grpcRepository.IClient) (*Scanner, error) { + logger.Info("connecting to ClamAV daemon", "address", cfg.Address) + + c := clamd.NewClamd(cfg.Address) + + scanner := &Scanner{ + clamd: c, + timeout: time.Duration(cfg.Timeout) * time.Millisecond, + grpcClient: grpcClient, + } + + if err := scanner.ping(); err != nil { + if _, logErr := grpcClient.AddLog(context.Background(), "failed to connect to ClamAV daemon", err.Error()); logErr != nil { + logger.Error("can't send log to manager", "err", logErr) + } + return nil, err + } + + return scanner, nil +} + +func (s *Scanner) ping() error { + return s.clamd.Ping() +} + +func (s *Scanner) ScanStream(ctx context.Context, reader io.Reader) (entity.MalwareScanResult, error) { + scanCtx, cancel := context.WithTimeout(ctx, s.timeout) + defer cancel() + + abort := make(chan bool, 1) + + go func() { + select { + case <-scanCtx.Done(): + abort <- true + case <-ctx.Done(): + abort <- true + } + }() + + resultChan, err := s.clamd.ScanStream(reader, abort) + if err != nil { + s.logError(ctx, "failed to initiate stream scan", err.Error()) + return entity.MalwareScanResult{ + Status: entity.MalwareScanStatusError, + Error: err.Error(), + }, &MalwareError{ + Code: ErrorCodeScanFailed, + Message: "failed to initiate stream scan", + Details: err.Error(), + } + } + + return s.processResults(ctx, resultChan) +} + +func (s *Scanner) processResults(ctx context.Context, resultChan chan *clamd.ScanResult) (entity.MalwareScanResult, error) { + var threats []string + + for { + select { + case result, ok := <-resultChan: + if !ok { + return s.buildResult(threats), nil + } + + err := s.handleScanResult(result, &threats) + if err != nil { + return entity.MalwareScanResult{ + Status: entity.MalwareScanStatusError, + Error: err.Error(), + Threats: threats, + }, err + } + + case <-ctx.Done(): + return entity.MalwareScanResult{ + Status: entity.MalwareScanStatusError, + Error: "context cancelled", + Threats: threats, + }, &MalwareError{ + Code: ErrorCodeTimeout, + Message: "scan cancelled", + Details: "context cancelled", + } + + case <-time.After(s.timeout): + return entity.MalwareScanResult{ + Status: entity.MalwareScanStatusError, + Error: "scan took too long", + Threats: threats, + }, &MalwareError{ + Code: ErrorCodeTimeout, + Message: "scan timeout", + Details: "scan took too long", + } + } + } +} + +func (s *Scanner) handleScanResult(result *clamd.ScanResult, threats *[]string) error { + switch result.Status { + case clamd.RES_OK: + return nil + + case clamd.RES_FOUND: + *threats = append(*threats, result.Description) + return nil + + case clamd.RES_ERROR, clamd.RES_PARSE_ERROR: + return &MalwareError{ + Code: ErrorCodeScanFailed, + Message: "scan failed", + Details: result.Description, + } + } + return nil +} + +func (s *Scanner) buildResult(threats []string) entity.MalwareScanResult { + if len(threats) > 0 { + return entity.MalwareScanResult{ + Status: entity.MalwareScanStatusInfected, + Threats: threats, + } + } + + return entity.MalwareScanResult{ + Status: entity.MalwareScanStatusClean, + Threats: nil, + } +} + +func (s *Scanner) logError(ctx context.Context, message, details string) { + logger.Error(message, "details", details) + if _, logErr := s.grpcClient.AddLog(ctx, message, details); logErr != nil { + logger.Error("can't send log to manager", "err", logErr) + } +} From f75cccb86c1acd3b5d0ff329eaf151c303489edf Mon Sep 17 00:00:00 2001 From: sadq Date: Fri, 25 Jul 2025 21:49:07 +0330 Subject: [PATCH 03/21] test: add clamav scanner unit tests --- .../infrastructure/clamav/scanner_test.go | 310 ++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 internal/infrastructure/clamav/scanner_test.go diff --git a/internal/infrastructure/clamav/scanner_test.go b/internal/infrastructure/clamav/scanner_test.go new file mode 100644 index 0000000..ac8c41e --- /dev/null +++ b/internal/infrastructure/clamav/scanner_test.go @@ -0,0 +1,310 @@ +package clamav + +import ( + "bytes" + "context" + "fmt" + "github.com/dutchcoders/go-clamd" + "net" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + + "nos3/internal/domain/entity" + "nos3/internal/infrastructure/grpcclient/gen" +) + +const ( + ClamAVImage = "clamav/clamav:latest" +) + +type MockGRPC struct { + mock.Mock +} + +func (m *MockGRPC) RegisterService(_ context.Context, _, _ string) (*gen.RegisterServiceResponse, error) { + args := m.Called() + return args.Get(0).(*gen.RegisterServiceResponse), args.Error(1) +} + +func (m *MockGRPC) AddLog(_ context.Context, msg, stack string) (*gen.AddLogResponse, error) { + args := m.Called(msg, stack) + return args.Get(0).(*gen.AddLogResponse), args.Error(1) +} + +func (m *MockGRPC) AddReport(_ context.Context, _ string, _ []string, _, _, _, _ string) ( + *gen.AddReportResponse, error, +) { + args := m.Called() + return args.Get(0).(*gen.AddReportResponse), args.Error(1) +} +func setupClamAV(t *testing.T) (string, func()) { + t.Helper() + ctx := context.Background() + + req := testcontainers.ContainerRequest{ + Image: ClamAVImage, + ExposedPorts: []string{"3310/tcp"}, + WaitingFor: wait.ForAll( + wait.ForListeningPort("3310/tcp").WithStartupTimeout(60 * time.Second), + ), + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatal("Failed to start ClamAV container:", err) + } + + host, err := container.Host(ctx) + if err != nil { + t.Fatal("Failed to get container host:", err) + } + + port, err := container.MappedPort(ctx, "3310") + if err != nil { + t.Fatal("Failed to get mapped port:", err) + } + + address := fmt.Sprintf("tcp://%s", net.JoinHostPort(host, port.Port())) + + return address, func() { + _ = container.Terminate(ctx) + } +} +func TestScanStream_CleanFile(t *testing.T) { + // t.Parallel() + + address, cleanup := setupClamAV(t) + t.Cleanup(cleanup) + + mockGRPC := &MockGRPC{} + mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&gen.AddLogResponse{}, nil).Maybe() + + scanner, err := NewScanner(ScannerConfig{ + Address: address, + Timeout: 30000, + }, mockGRPC) + require.NoError(t, err) + + cleanContent := "This is a clean test file with no malware." + reader := strings.NewReader(cleanContent) + + result, err := scanner.ScanStream(context.Background(), reader) + + assert.NoError(t, err) + assert.Equal(t, entity.MalwareScanStatusClean, result.Status) + assert.True(t, result.IsClean()) + assert.Equal(t, 0, result.ThreatCount()) +} + +func TestScanStream_InfectedFile(t *testing.T) { + // t.Parallel() + + address, cleanup := setupClamAV(t) + t.Cleanup(cleanup) + + mockGRPC := &MockGRPC{} + mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&gen.AddLogResponse{}, nil).Maybe() + + scanner, err := NewScanner(ScannerConfig{ + Address: address, + Timeout: 30000, + }, mockGRPC) + require.NoError(t, err) + + reader := bytes.NewReader(clamd.EICAR) + + result, err := scanner.ScanStream(context.Background(), reader) + + assert.NoError(t, err) + assert.Equal(t, entity.MalwareScanStatusInfected, result.Status) + assert.NotEmpty(t, result.Threats) + assert.Contains(t, strings.ToUpper(result.Threats[0]), "EICAR") +} +func TestScanStream_EmptyFile(t *testing.T) { + // t.Parallel() + + address, cleanup := setupClamAV(t) + t.Cleanup(cleanup) + + mockGRPC := &MockGRPC{} + mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&gen.AddLogResponse{}, nil).Maybe() + + scanner, err := NewScanner(ScannerConfig{ + Address: address, + Timeout: 30000, + }, mockGRPC) + require.NoError(t, err) + + reader := strings.NewReader("") + + result, err := scanner.ScanStream(context.Background(), reader) + + assert.NoError(t, err) + assert.Equal(t, entity.MalwareScanStatusClean, result.Status) + assert.True(t, result.IsClean()) + assert.False(t, result.HasError()) + assert.Equal(t, 0, result.ThreatCount()) +} + +func TestScanStream_LargeCleanFile(t *testing.T) { + // t.Parallel() + + address, cleanup := setupClamAV(t) + t.Cleanup(cleanup) + + mockGRPC := &MockGRPC{} + mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&gen.AddLogResponse{}, nil).Maybe() + + scanner, err := NewScanner(ScannerConfig{ + Address: address, + Timeout: 30000, + }, mockGRPC) + require.NoError(t, err) + + largeContent := strings.Repeat("This is a clean file content. ", 350000) + reader := strings.NewReader(largeContent) + + result, err := scanner.ScanStream(context.Background(), reader) + + assert.NoError(t, err) + assert.Equal(t, entity.MalwareScanStatusClean, result.Status) + assert.True(t, result.IsClean()) + assert.False(t, result.HasError()) + assert.Equal(t, 0, result.ThreatCount()) +} + +func TestScanStream_BinaryCleanFile(t *testing.T) { + // t.Parallel() + + address, cleanup := setupClamAV(t) + t.Cleanup(cleanup) + + mockGRPC := &MockGRPC{} + mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&gen.AddLogResponse{}, nil).Maybe() + + scanner, err := NewScanner(ScannerConfig{ + Address: address, + Timeout: 30000, + }, mockGRPC) + require.NoError(t, err) + + binaryContent := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} + binaryContent = append(binaryContent, bytes.Repeat([]byte{0x00, 0x01, 0x02, 0x03}, 1000)...) + reader := bytes.NewReader(binaryContent) + + result, err := scanner.ScanStream(context.Background(), reader) + + assert.NoError(t, err) + assert.Equal(t, entity.MalwareScanStatusClean, result.Status) + assert.True(t, result.IsClean()) + assert.False(t, result.HasError()) + assert.Equal(t, 0, result.ThreatCount()) +} + +func setupClamAVBenchmark(b *testing.B) (string, func()) { + b.Helper() + ctx := context.Background() + + req := testcontainers.ContainerRequest{ + Image: ClamAVImage, + ExposedPorts: []string{"3310/tcp"}, + WaitingFor: wait.ForAll( + wait.ForListeningPort("3310/tcp").WithStartupTimeout(60 * time.Second), + ), + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + b.Fatal("Failed to start ClamAV container:", err) + } + + host, err := container.Host(ctx) + if err != nil { + b.Fatal("Failed to get container host:", err) + } + + port, err := container.MappedPort(ctx, "3310") + if err != nil { + b.Fatal("Failed to get mapped port:", err) + } + + address := fmt.Sprintf("tcp://%s", net.JoinHostPort(host, port.Port())) + + return address, func() { + _ = container.Terminate(ctx) + } +} + +func BenchmarkScanStream_SmallCleanFile(b *testing.B) { + address, cleanup := setupClamAVBenchmark(b) + defer cleanup() + + mockGRPC := &MockGRPC{} + mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&gen.AddLogResponse{}, nil).Maybe() + + scanner, err := NewScanner(ScannerConfig{ + Address: address, + Timeout: 30000, + }, mockGRPC) + if err != nil { + b.Fatal("Failed to create scanner:", err) + } + + content := "This is a small clean test file for benchmarking." + + b.ResetTimer() + for i := 0; i < b.N; i++ { + reader := strings.NewReader(content) + _, err := scanner.ScanStream(context.Background(), reader) + if err != nil { + b.Fatal("Scan failed:", err) + } + } +} + +func BenchmarkScanStream_LargeCleanFile(b *testing.B) { + address, cleanup := setupClamAVBenchmark(b) + defer cleanup() + + mockGRPC := &MockGRPC{} + mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&gen.AddLogResponse{}, nil).Maybe() + + scanner, err := NewScanner(ScannerConfig{ + Address: address, + Timeout: 30000, + }, mockGRPC) + if err != nil { + b.Fatal("Failed to create scanner:", err) + } + + content := strings.Repeat("Large file content for benchmarking. ", 30000) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + reader := strings.NewReader(content) + _, err := scanner.ScanStream(context.Background(), reader) + if err != nil { + b.Fatal("Scan failed:", err) + } + } +} From 8a697d5b382b892e63b0474eddb27fcb01258a78 Mon Sep 17 00:00:00 2001 From: sadq Date: Tue, 5 Aug 2025 20:05:57 +0330 Subject: [PATCH 04/21] feat: add abstraction for clamd client --- internal/domain/repository/clamav/client.go | 11 +++++++++++ internal/infrastructure/clamav/scanner.go | 7 ++++--- 2 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 internal/domain/repository/clamav/client.go diff --git a/internal/domain/repository/clamav/client.go b/internal/domain/repository/clamav/client.go new file mode 100644 index 0000000..99d848d --- /dev/null +++ b/internal/domain/repository/clamav/client.go @@ -0,0 +1,11 @@ +package clamav + +import ( + "github.com/dutchcoders/go-clamd" + "io" +) + +type ClamdClient interface { + Ping() error + ScanStream(reader io.Reader, abort chan bool) (chan *clamd.ScanResult, error) +} diff --git a/internal/infrastructure/clamav/scanner.go b/internal/infrastructure/clamav/scanner.go index dd484ee..f35acc1 100644 --- a/internal/infrastructure/clamav/scanner.go +++ b/internal/infrastructure/clamav/scanner.go @@ -3,6 +3,7 @@ package clamav import ( "context" "io" + "nos3/internal/domain/repository/clamav" "time" "github.com/dutchcoders/go-clamd" @@ -13,7 +14,7 @@ import ( ) type Scanner struct { - clamd *clamd.Clamd + clamd clamav.ClamdClient timeout time.Duration grpcClient grpcRepository.IClient } @@ -21,10 +22,10 @@ type Scanner struct { func NewScanner(cfg ScannerConfig, grpcClient grpcRepository.IClient) (*Scanner, error) { logger.Info("connecting to ClamAV daemon", "address", cfg.Address) - c := clamd.NewClamd(cfg.Address) + clamdClient := clamd.NewClamd(cfg.Address) scanner := &Scanner{ - clamd: c, + clamd: clamdClient, timeout: time.Duration(cfg.Timeout) * time.Millisecond, grpcClient: grpcClient, } From 16fd06e756f687446e98e838d03f3c84c555c3a3 Mon Sep 17 00:00:00 2001 From: sadq Date: Tue, 5 Aug 2025 20:06:19 +0330 Subject: [PATCH 05/21] test: add mocked unit tests for scanner --- .../infrastructure/clamav/scanner_test.go | 199 ++++++++++++++++-- 1 file changed, 180 insertions(+), 19 deletions(-) diff --git a/internal/infrastructure/clamav/scanner_test.go b/internal/infrastructure/clamav/scanner_test.go index ac8c41e..f969a2f 100644 --- a/internal/infrastructure/clamav/scanner_test.go +++ b/internal/infrastructure/clamav/scanner_test.go @@ -5,7 +5,9 @@ import ( "context" "fmt" "github.com/dutchcoders/go-clamd" + "io" "net" + "nos3/internal/domain/repository/clamav" "strings" "testing" "time" @@ -21,7 +23,29 @@ import ( ) const ( - ClamAVImage = "clamav/clamav:latest" + ClamAVImage = "clamav/clamav:latest" + ClamAVPort = "3310/tcp" + ClamAVAddressFormat = "tcp://%s" + CleanFileContent = "This is a clean test file with no malware." + EmptyFileContent = "" + LargeCleanFileContent = "This is a clean file content. " + SmallCleanBenchmarkContent = "This is a small clean test file for benchmarking." + LargeCleanBenchmarkContent = "Large file content for benchmarking. " + InfectedContent = "infected content" + UnparseableContent = "unparseable content" + SomeContent = "some content" + MalwareDescTrojan = "Trojan.Generic.123" + MalwareDescVirus = "Virus.Win32.Test" + MalwareDescMalwareSuspicious = "Malware.Suspicious.456" + ScanErrorCorruptedData = "Unable to scan file: corrupted data" + ParseErrorFileFormatNotRecog = "File format not recognized" + MalwareDescTrojanTest = "Trojan.Test.123" + MalwareDescVirusTest = "Virus.Test.456" + Timeout = 30000 + TimeoutDuration = 30 * time.Second + StartupTimeoutDuration = 60 * time.Second + LargeFileContentRepeatCount = 350000 + LargeBenchmarkRepeatCount = 30000 ) type MockGRPC struct { @@ -44,15 +68,47 @@ func (m *MockGRPC) AddReport(_ context.Context, _ string, _ []string, _, _, _, _ args := m.Called() return args.Get(0).(*gen.AddReportResponse), args.Error(1) } + +type MockClamdClient struct { + mock.Mock +} + +func (m *MockClamdClient) Ping() error { + args := m.Called() + return args.Error(0) +} + +func (m *MockClamdClient) ScanStream(reader io.Reader, abort chan bool) (chan *clamd.ScanResult, error) { + args := m.Called(reader, abort) + return args.Get(0).(chan *clamd.ScanResult), args.Error(1) +} + +func createMockScanner(clamdClient clamav.ClamdClient, grpcClient *MockGRPC) *Scanner { + return &Scanner{ + clamd: clamdClient, + timeout: 30 * time.Second, + grpcClient: grpcClient, + } +} + +func createMockResultChannel(results []*clamd.ScanResult) chan *clamd.ScanResult { + resultChan := make(chan *clamd.ScanResult, len(results)) + for _, result := range results { + resultChan <- result + } + close(resultChan) + return resultChan +} + func setupClamAV(t *testing.T) (string, func()) { t.Helper() ctx := context.Background() req := testcontainers.ContainerRequest{ Image: ClamAVImage, - ExposedPorts: []string{"3310/tcp"}, + ExposedPorts: []string{ClamAVPort}, WaitingFor: wait.ForAll( - wait.ForListeningPort("3310/tcp").WithStartupTimeout(60 * time.Second), + wait.ForListeningPort(ClamAVPort).WithStartupTimeout(StartupTimeoutDuration), ), } @@ -74,7 +130,7 @@ func setupClamAV(t *testing.T) (string, func()) { t.Fatal("Failed to get mapped port:", err) } - address := fmt.Sprintf("tcp://%s", net.JoinHostPort(host, port.Port())) + address := fmt.Sprintf(ClamAVAddressFormat, net.JoinHostPort(host, port.Port())) return address, func() { _ = container.Terminate(ctx) @@ -92,11 +148,11 @@ func TestScanStream_CleanFile(t *testing.T) { scanner, err := NewScanner(ScannerConfig{ Address: address, - Timeout: 30000, + Timeout: Timeout, }, mockGRPC) require.NoError(t, err) - cleanContent := "This is a clean test file with no malware." + cleanContent := CleanFileContent reader := strings.NewReader(cleanContent) result, err := scanner.ScanStream(context.Background(), reader) @@ -119,7 +175,7 @@ func TestScanStream_InfectedFile(t *testing.T) { scanner, err := NewScanner(ScannerConfig{ Address: address, - Timeout: 30000, + Timeout: Timeout, }, mockGRPC) require.NoError(t, err) @@ -144,11 +200,11 @@ func TestScanStream_EmptyFile(t *testing.T) { scanner, err := NewScanner(ScannerConfig{ Address: address, - Timeout: 30000, + Timeout: Timeout, }, mockGRPC) require.NoError(t, err) - reader := strings.NewReader("") + reader := strings.NewReader(EmptyFileContent) result, err := scanner.ScanStream(context.Background(), reader) @@ -171,11 +227,11 @@ func TestScanStream_LargeCleanFile(t *testing.T) { scanner, err := NewScanner(ScannerConfig{ Address: address, - Timeout: 30000, + Timeout: Timeout, }, mockGRPC) require.NoError(t, err) - largeContent := strings.Repeat("This is a clean file content. ", 350000) + largeContent := strings.Repeat(LargeCleanFileContent, LargeFileContentRepeatCount) reader := strings.NewReader(largeContent) result, err := scanner.ScanStream(context.Background(), reader) @@ -199,7 +255,7 @@ func TestScanStream_BinaryCleanFile(t *testing.T) { scanner, err := NewScanner(ScannerConfig{ Address: address, - Timeout: 30000, + Timeout: Timeout, }, mockGRPC) require.NoError(t, err) @@ -216,15 +272,120 @@ func TestScanStream_BinaryCleanFile(t *testing.T) { assert.Equal(t, 0, result.ThreatCount()) } +func TestScanStream_MultipleThreatsDetected(t *testing.T) { + mockGRPC := &MockGRPC{} + mockClamd := &MockClamdClient{} + + results := []*clamd.ScanResult{ + {Status: clamd.RES_FOUND, Description: MalwareDescTrojan}, + {Status: clamd.RES_FOUND, Description: MalwareDescVirus}, + {Status: clamd.RES_FOUND, Description: MalwareDescMalwareSuspicious}, + } + resultChan := createMockResultChannel(results) + + mockClamd.On("ScanStream", mock.Anything, mock.Anything).Return(resultChan, nil) + + scanner := createMockScanner(mockClamd, mockGRPC) + reader := strings.NewReader(InfectedContent) + + result, err := scanner.ScanStream(context.Background(), reader) + + assert.NoError(t, err) + assert.Equal(t, entity.MalwareScanStatusInfected, result.Status) + assert.Equal(t, 3, len(result.Threats)) + assert.Contains(t, result.Threats, MalwareDescTrojan) + assert.Contains(t, result.Threats, MalwareDescVirus) + assert.Contains(t, result.Threats, MalwareDescMalwareSuspicious) +} + +func TestScanStream_ScanErrorDuringScan(t *testing.T) { + mockGRPC := &MockGRPC{} + mockClamd := &MockClamdClient{} + + results := []*clamd.ScanResult{ + {Status: clamd.RES_ERROR, Description: ScanErrorCorruptedData}, + } + resultChan := createMockResultChannel(results) + + mockClamd.On("ScanStream", mock.Anything, mock.Anything).Return(resultChan, nil) + + scanner := createMockScanner(mockClamd, mockGRPC) + reader := strings.NewReader(SomeContent) + + result, err := scanner.ScanStream(context.Background(), reader) + + assert.Error(t, err) + assert.Equal(t, entity.MalwareScanStatusError, result.Status) + assert.Contains(t, result.Error, "scan failed") + + malwareErr, ok := err.(*MalwareError) + assert.True(t, ok) + assert.Equal(t, ErrorCodeScanFailed, malwareErr.Code) + assert.Equal(t, ScanErrorCorruptedData, malwareErr.Details) +} + +func TestScanStream_MixedResults(t *testing.T) { + mockGRPC := &MockGRPC{} + mockClamd := &MockClamdClient{} + + results := []*clamd.ScanResult{ + {Status: clamd.RES_OK, Description: ""}, + {Status: clamd.RES_FOUND, Description: MalwareDescTrojanTest}, + {Status: clamd.RES_OK, Description: ""}, + {Status: clamd.RES_FOUND, Description: MalwareDescVirusTest}, + {Status: clamd.RES_OK, Description: ""}, + } + resultChan := createMockResultChannel(results) + + mockClamd.On("ScanStream", mock.Anything, mock.Anything).Return(resultChan, nil) + + scanner := createMockScanner(mockClamd, mockGRPC) + reader := strings.NewReader("mixed content") + + result, err := scanner.ScanStream(context.Background(), reader) + + assert.NoError(t, err) + assert.Equal(t, entity.MalwareScanStatusInfected, result.Status) + assert.Equal(t, 2, len(result.Threats)) + assert.Contains(t, result.Threats, MalwareDescTrojanTest) + assert.Contains(t, result.Threats, MalwareDescVirusTest) +} + +func TestScanStream_ParseErrorDuringScan(t *testing.T) { + mockGRPC := &MockGRPC{} + mockClamd := &MockClamdClient{} + + results := []*clamd.ScanResult{ + {Status: clamd.RES_PARSE_ERROR, Description: ParseErrorFileFormatNotRecog}, + } + resultChan := createMockResultChannel(results) + + mockClamd.On("ScanStream", mock.Anything, mock.Anything).Return(resultChan, nil) + + scanner := createMockScanner(mockClamd, mockGRPC) + reader := strings.NewReader(UnparseableContent) + + result, err := scanner.ScanStream(context.Background(), reader) + + assert.Error(t, err) + assert.Equal(t, entity.MalwareScanStatusError, result.Status) + assert.Contains(t, result.Error, "scan failed") + + malwareErr, ok := err.(*MalwareError) + assert.True(t, ok) + assert.Equal(t, ErrorCodeScanFailed, malwareErr.Code) + assert.Equal(t, ParseErrorFileFormatNotRecog, malwareErr.Details) +} + func setupClamAVBenchmark(b *testing.B) (string, func()) { b.Helper() ctx := context.Background() req := testcontainers.ContainerRequest{ Image: ClamAVImage, - ExposedPorts: []string{"3310/tcp"}, + ExposedPorts: []string{ClamAVPort}, WaitingFor: wait.ForAll( - wait.ForListeningPort("3310/tcp").WithStartupTimeout(60 * time.Second), + wait.ForListeningPort(ClamAVPort).WithStartupTimeout(StartupTimeoutDuration), ), } @@ -246,7 +407,7 @@ func setupClamAVBenchmark(b *testing.B) (string, func()) { b.Fatal("Failed to get mapped port:", err) } - address := fmt.Sprintf("tcp://%s", net.JoinHostPort(host, port.Port())) + address := fmt.Sprintf(ClamAVAddressFormat, net.JoinHostPort(host, port.Port())) return address, func() { _ = container.Terminate(ctx) @@ -263,13 +424,13 @@ func BenchmarkScanStream_SmallCleanFile(b *testing.B) { scanner, err := NewScanner(ScannerConfig{ Address: address, - Timeout: 30000, + Timeout: Timeout, }, mockGRPC) if err != nil { b.Fatal("Failed to create scanner:", err) } - content := "This is a small clean test file for benchmarking." + content := SmallCleanBenchmarkContent b.ResetTimer() for i := 0; i < b.N; i++ { @@ -291,13 +452,13 @@ func BenchmarkScanStream_LargeCleanFile(b *testing.B) { scanner, err := NewScanner(ScannerConfig{ Address: address, - Timeout: 30000, + Timeout: Timeout, }, mockGRPC) if err != nil { b.Fatal("Failed to create scanner:", err) } - content := strings.Repeat("Large file content for benchmarking. ", 30000) + content := strings.Repeat(LargeCleanBenchmarkContent, LargeBenchmarkRepeatCount) b.ResetTimer() for i := 0; i < b.N; i++ { From f34988e7ffb25b00303e1b4e696d3c3aa2921151 Mon Sep 17 00:00:00 2001 From: sadq Date: Tue, 5 Aug 2025 22:48:30 +0330 Subject: [PATCH 06/21] chore: resolve lint iussues --- internal/domain/entity/malware_scan_result.go | 4 +++- internal/domain/repository/clamav/client.go | 3 ++- internal/infrastructure/clamav/error.go | 1 + internal/infrastructure/clamav/scanner.go | 10 ++++++++-- .../infrastructure/clamav/scanner_test.go | 19 +++++++++++++++---- 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/internal/domain/entity/malware_scan_result.go b/internal/domain/entity/malware_scan_result.go index a916f1f..6a95e71 100644 --- a/internal/domain/entity/malware_scan_result.go +++ b/internal/domain/entity/malware_scan_result.go @@ -17,9 +17,11 @@ func (s MalwareScanStatus) String() string { return "infected" case MalwareScanStatusError: return "error" - default: + case MalwareScanStatusUnknown: return "unknown" } + + return "invalid" } type MalwareScanResult struct { diff --git a/internal/domain/repository/clamav/client.go b/internal/domain/repository/clamav/client.go index 99d848d..f6ebde6 100644 --- a/internal/domain/repository/clamav/client.go +++ b/internal/domain/repository/clamav/client.go @@ -1,8 +1,9 @@ package clamav import ( - "github.com/dutchcoders/go-clamd" "io" + + "github.com/dutchcoders/go-clamd" ) type ClamdClient interface { diff --git a/internal/infrastructure/clamav/error.go b/internal/infrastructure/clamav/error.go index 11374ec..0250a2c 100644 --- a/internal/infrastructure/clamav/error.go +++ b/internal/infrastructure/clamav/error.go @@ -23,6 +23,7 @@ func (e *MalwareError) Error() string { if e.Details != "" { return fmt.Sprintf("%s: %s", e.Message, e.Details) } + return e.Message } diff --git a/internal/infrastructure/clamav/scanner.go b/internal/infrastructure/clamav/scanner.go index f35acc1..7316305 100644 --- a/internal/infrastructure/clamav/scanner.go +++ b/internal/infrastructure/clamav/scanner.go @@ -31,9 +31,11 @@ func NewScanner(cfg ScannerConfig, grpcClient grpcRepository.IClient) (*Scanner, } if err := scanner.ping(); err != nil { - if _, logErr := grpcClient.AddLog(context.Background(), "failed to connect to ClamAV daemon", err.Error()); logErr != nil { + if _, logErr := grpcClient.AddLog(context.Background(), + "failed to connect to ClamAV daemon", err.Error()); logErr != nil { logger.Error("can't send log to manager", "err", logErr) } + return nil, err } @@ -62,6 +64,7 @@ func (s *Scanner) ScanStream(ctx context.Context, reader io.Reader) (entity.Malw resultChan, err := s.clamd.ScanStream(reader, abort) if err != nil { s.logError(ctx, "failed to initiate stream scan", err.Error()) + return entity.MalwareScanResult{ Status: entity.MalwareScanStatusError, Error: err.Error(), @@ -75,7 +78,8 @@ func (s *Scanner) ScanStream(ctx context.Context, reader io.Reader) (entity.Malw return s.processResults(ctx, resultChan) } -func (s *Scanner) processResults(ctx context.Context, resultChan chan *clamd.ScanResult) (entity.MalwareScanResult, error) { +func (s *Scanner) processResults(ctx context.Context, + resultChan chan *clamd.ScanResult) (entity.MalwareScanResult, error) { var threats []string for { @@ -126,6 +130,7 @@ func (s *Scanner) handleScanResult(result *clamd.ScanResult, threats *[]string) case clamd.RES_FOUND: *threats = append(*threats, result.Description) + return nil case clamd.RES_ERROR, clamd.RES_PARSE_ERROR: @@ -135,6 +140,7 @@ func (s *Scanner) handleScanResult(result *clamd.ScanResult, threats *[]string) Details: result.Description, } } + return nil } diff --git a/internal/infrastructure/clamav/scanner_test.go b/internal/infrastructure/clamav/scanner_test.go index f969a2f..e598ec9 100644 --- a/internal/infrastructure/clamav/scanner_test.go +++ b/internal/infrastructure/clamav/scanner_test.go @@ -3,15 +3,15 @@ package clamav import ( "bytes" "context" + "errors" "fmt" - "github.com/dutchcoders/go-clamd" "io" "net" - "nos3/internal/domain/repository/clamav" "strings" "testing" "time" + "github.com/dutchcoders/go-clamd" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -19,6 +19,7 @@ import ( "github.com/testcontainers/testcontainers-go/wait" "nos3/internal/domain/entity" + "nos3/internal/domain/repository/clamav" "nos3/internal/infrastructure/grpcclient/gen" ) @@ -54,11 +55,13 @@ type MockGRPC struct { func (m *MockGRPC) RegisterService(_ context.Context, _, _ string) (*gen.RegisterServiceResponse, error) { args := m.Called() + return args.Get(0).(*gen.RegisterServiceResponse), args.Error(1) } func (m *MockGRPC) AddLog(_ context.Context, msg, stack string) (*gen.AddLogResponse, error) { args := m.Called(msg, stack) + return args.Get(0).(*gen.AddLogResponse), args.Error(1) } @@ -66,6 +69,7 @@ func (m *MockGRPC) AddReport(_ context.Context, _ string, _ []string, _, _, _, _ *gen.AddReportResponse, error, ) { args := m.Called() + return args.Get(0).(*gen.AddReportResponse), args.Error(1) } @@ -75,11 +79,13 @@ type MockClamdClient struct { func (m *MockClamdClient) Ping() error { args := m.Called() + return args.Error(0) } func (m *MockClamdClient) ScanStream(reader io.Reader, abort chan bool) (chan *clamd.ScanResult, error) { args := m.Called(reader, abort) + return args.Get(0).(chan *clamd.ScanResult), args.Error(1) } @@ -97,6 +103,7 @@ func createMockResultChannel(results []*clamd.ScanResult) chan *clamd.ScanResult resultChan <- result } close(resultChan) + return resultChan } @@ -318,7 +325,9 @@ func TestScanStream_ScanErrorDuringScan(t *testing.T) { assert.Equal(t, entity.MalwareScanStatusError, result.Status) assert.Contains(t, result.Error, "scan failed") - malwareErr, ok := err.(*MalwareError) + var malwareErr *MalwareError + ok := errors.As(err, &malwareErr) + assert.True(t, ok) assert.Equal(t, ErrorCodeScanFailed, malwareErr.Code) assert.Equal(t, ScanErrorCorruptedData, malwareErr.Details) @@ -371,7 +380,9 @@ func TestScanStream_ParseErrorDuringScan(t *testing.T) { assert.Equal(t, entity.MalwareScanStatusError, result.Status) assert.Contains(t, result.Error, "scan failed") - malwareErr, ok := err.(*MalwareError) + var malwareErr *MalwareError + ok := errors.As(err, &malwareErr) + assert.True(t, ok) assert.Equal(t, ErrorCodeScanFailed, malwareErr.Code) assert.Equal(t, ParseErrorFileFormatNotRecog, malwareErr.Details) From 15387274cfabea02ab3cbda4e755b523a90f8b53 Mon Sep 17 00:00:00 2001 From: sadq Date: Sun, 12 Oct 2025 22:37:59 +0330 Subject: [PATCH 07/21] feat: add infrastructure for exif removal --- go.mod | 1 + go.sum | 2 + .../domain/repository/exif/exif_processor.go | 9 ++ .../repository/exif/extension_provider.go | 6 + .../domain/repository/exif/file_validator.go | 6 + internal/infrastructure/exif/config.go | 6 + internal/infrastructure/exif/errors.go | 41 +++++++ .../infrastructure/exif/extension_provider.go | 68 ++++++++++++ .../infrastructure/exif/file_validator.go | 40 +++++++ internal/infrastructure/exif/processor.go | 104 ++++++++++++++++++ internal/infrastructure/exif/remover.go | 55 +++++++++ 11 files changed, 338 insertions(+) create mode 100644 internal/domain/repository/exif/exif_processor.go create mode 100644 internal/domain/repository/exif/extension_provider.go create mode 100644 internal/domain/repository/exif/file_validator.go create mode 100644 internal/infrastructure/exif/config.go create mode 100644 internal/infrastructure/exif/errors.go create mode 100644 internal/infrastructure/exif/extension_provider.go create mode 100644 internal/infrastructure/exif/file_validator.go create mode 100644 internal/infrastructure/exif/processor.go create mode 100644 internal/infrastructure/exif/remover.go diff --git a/go.mod b/go.mod index 7500db9..ad9aba1 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module nos3 go 1.24.1 require ( + github.com/barasher/go-exiftool v1.10.0 github.com/dutchcoders/go-clamd v0.0.0-20170520113014-b970184f4d9e github.com/gabriel-vasile/mimetype v1.4.9 github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum index 7e93312..f88a214 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNN github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs= +github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= diff --git a/internal/domain/repository/exif/exif_processor.go b/internal/domain/repository/exif/exif_processor.go new file mode 100644 index 0000000..0b17b9f --- /dev/null +++ b/internal/domain/repository/exif/exif_processor.go @@ -0,0 +1,9 @@ +package exif + +import "context" + +// ExifProcessor handles EXIF removal operations +type ExifProcessor interface { + RemoveExifData(ctx context.Context, filePath string) error + Close() error +} diff --git a/internal/domain/repository/exif/extension_provider.go b/internal/domain/repository/exif/extension_provider.go new file mode 100644 index 0000000..ba165ee --- /dev/null +++ b/internal/domain/repository/exif/extension_provider.go @@ -0,0 +1,6 @@ +package exif + +// ExtensionProvider provides supported file extensions +type ExtensionProvider interface { + GetSupportedExtensions() (map[string]bool, error) +} diff --git a/internal/domain/repository/exif/file_validator.go b/internal/domain/repository/exif/file_validator.go new file mode 100644 index 0000000..b9c9ddd --- /dev/null +++ b/internal/domain/repository/exif/file_validator.go @@ -0,0 +1,6 @@ +package exif + +// FileValidator validates file types +type FileValidator interface { + ValidateFileType(filePath string) error +} diff --git a/internal/infrastructure/exif/config.go b/internal/infrastructure/exif/config.go new file mode 100644 index 0000000..884687d --- /dev/null +++ b/internal/infrastructure/exif/config.go @@ -0,0 +1,6 @@ +package exif + +type RemoverConfig struct { + Timeout int `yaml:"timeout_in_ms"` + ExifToolCmd string `yaml:"exiftool_cmd"` +} diff --git a/internal/infrastructure/exif/errors.go b/internal/infrastructure/exif/errors.go new file mode 100644 index 0000000..cec2419 --- /dev/null +++ b/internal/infrastructure/exif/errors.go @@ -0,0 +1,41 @@ +package exif + +import "fmt" + +type ErrorCode int + +const ( + ErrorCodeUnknown ErrorCode = iota + ErrorCodeExifToolNotFound + ErrorCodeTempFileCreationFailed + ErrorCodeExifRemovalFailed + ErrorCodeTimeout + ErrorCodeInvalidInput + ErrorCodeUnsupportedFileType +) + +type ExifError struct { + Code ErrorCode + Message string + Details string +} + +func (e *ExifError) Error() string { + if e.Details != "" { + return fmt.Sprintf("%s: %s", e.Message, e.Details) + } + + return e.Message +} + +func (e *ExifError) IsTimeout() bool { + return e.Code == ErrorCodeTimeout +} + +func (e *ExifError) IsUnsupportedFileType() bool { + return e.Code == ErrorCodeUnsupportedFileType +} + +func (e *ExifError) IsTempFileError() bool { + return e.Code == ErrorCodeTempFileCreationFailed +} diff --git a/internal/infrastructure/exif/extension_provider.go b/internal/infrastructure/exif/extension_provider.go new file mode 100644 index 0000000..662861a --- /dev/null +++ b/internal/infrastructure/exif/extension_provider.go @@ -0,0 +1,68 @@ +package exif + +import ( + "os/exec" + "strings" + "sync" +) + +type ExtensionProvider struct { + exiftoolCmd string + supportedExtensions map[string]bool + initErr error + once sync.Once +} + +func NewExtensionProvider(exiftoolCmd string) *ExtensionProvider { + return &ExtensionProvider{ + exiftoolCmd: exiftoolCmd, + } +} + +func (e *ExtensionProvider) GetSupportedExtensions() (map[string]bool, error) { + e.once.Do(func() { + e.supportedExtensions, e.initErr = e.fetchSupportedExtensions() + }) + + if e.initErr != nil { + return nil, e.initErr + } + + result := make(map[string]bool, len(e.supportedExtensions)) + for k, v := range e.supportedExtensions { + result[k] = v + } + return result, nil +} + +func (e *ExtensionProvider) fetchSupportedExtensions() (map[string]bool, error) { + var cmd *exec.Cmd + if e.exiftoolCmd != "" { + cmd = exec.Command(e.exiftoolCmd, "-listf") + } else { + cmd = exec.Command("exiftool", "-listf") + } + + output, err := cmd.Output() + if err != nil { + return nil, err + } + + extensions := make(map[string]bool) + lines := strings.Split(string(output), "\n") + + for _, line := range lines { + if strings.Contains(line, ":") { + continue + } + + fields := strings.Fields(line) + for _, ext := range fields { + if ext != "" { + extensions["."+strings.ToLower(ext)] = true + } + } + } + + return extensions, nil +} diff --git a/internal/infrastructure/exif/file_validator.go b/internal/infrastructure/exif/file_validator.go new file mode 100644 index 0000000..b6ba64f --- /dev/null +++ b/internal/infrastructure/exif/file_validator.go @@ -0,0 +1,40 @@ +package exif + +import ( + "nos3/internal/domain/repository/exif" + "path/filepath" + "strings" +) + +type FileValidator struct { + extensionProvider exif.ExtensionProvider +} + +func NewFileValidator(extensionProvider exif.ExtensionProvider) exif.FileValidator { + return &FileValidator{ + extensionProvider: extensionProvider, + } +} + +func (f *FileValidator) ValidateFileType(filePath string) error { + ext := strings.ToLower(filepath.Ext(filePath)) + + supportedExtensions, err := f.extensionProvider.GetSupportedExtensions() + if err != nil { + return &ExifError{ + Code: ErrorCodeExifToolNotFound, + Message: "failed to get supported extensions", + Details: err.Error(), + } + } + + if !supportedExtensions[ext] { + return &ExifError{ + Code: ErrorCodeUnsupportedFileType, + Message: "file type does not support EXIF data", + Details: "extension " + ext + " not supported by ExifTool", + } + } + + return nil +} diff --git a/internal/infrastructure/exif/processor.go b/internal/infrastructure/exif/processor.go new file mode 100644 index 0000000..15a096c --- /dev/null +++ b/internal/infrastructure/exif/processor.go @@ -0,0 +1,104 @@ +package exif + +import ( + "context" + "nos3/internal/domain/repository/exif" + "time" + + "github.com/barasher/go-exiftool" + + grpcRepository "nos3/internal/domain/repository/grpcclient" + "nos3/pkg/logger" +) + +type Processor struct { + exiftool *exiftool.Exiftool + timeout time.Duration + grpcClient grpcRepository.IClient +} + +func NewProcessor(exiftoolCmd string, + timeout time.Duration, + grpcClient grpcRepository.IClient) (exif.ExifProcessor, error) { + logger.Info("initializing exiftool for EXIF processing") + + var et *exiftool.Exiftool + var err error + + if exiftoolCmd != "" { + et, err = exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(exiftoolCmd)) + } else { + et, err = exiftool.NewExiftool() + } + + if err != nil { + logError(context.Background(), grpcClient, "failed to initialize exiftool", err.Error()) + + return nil, &ExifError{ + Code: ErrorCodeExifToolNotFound, + Message: "failed to initialize exiftool", + Details: err.Error(), + } + } + + return &Processor{ + exiftool: et, + timeout: timeout, + grpcClient: grpcClient, + }, nil +} + +func (e *Processor) RemoveExifData(ctx context.Context, filePath string) error { + done := make(chan error, 1) + + go func() { + defer close(done) + + fileMetadata := exiftool.FileMetadata{ + File: filePath, + Fields: map[string]interface{}{"All": ""}, + } + + e.exiftool.WriteMetadata([]exiftool.FileMetadata{fileMetadata}) + + if fileMetadata.Err != nil { + done <- &ExifError{ + Code: ErrorCodeExifRemovalFailed, + Message: "failed to remove EXIF data", + Details: fileMetadata.Err.Error(), + } + return + } + + done <- nil + }() + + select { + case err := <-done: + if err != nil { + logError(ctx, e.grpcClient, "EXIF removal failed", err.Error()) + } + return err + case <-ctx.Done(): + return &ExifError{ + Code: ErrorCodeTimeout, + Message: "EXIF removal timeout", + Details: "operation took too long", + } + } +} + +func (e *Processor) Close() error { + if e.exiftool != nil { + return e.exiftool.Close() + } + return nil +} + +// logError is a standalone function for logging errors to both local logger and gRPC client +func logError(ctx context.Context, grpcClient grpcRepository.IClient, message, details string) { + logger.Error(message, "details", details) + if _, logErr := grpcClient.AddLog(ctx, message, details); logErr != nil { + logger.Error("can't send log to manager", "err", logErr) + } +} diff --git a/internal/infrastructure/exif/remover.go b/internal/infrastructure/exif/remover.go new file mode 100644 index 0000000..e2f8881 --- /dev/null +++ b/internal/infrastructure/exif/remover.go @@ -0,0 +1,55 @@ +package exif + +import ( + "context" + "nos3/internal/domain/repository/exif" + "time" + + grpcRepository "nos3/internal/domain/repository/grpcclient" + "nos3/pkg/logger" +) + +type Remover struct { + fileValidator exif.FileValidator + exifProcessor exif.ExifProcessor + timeout time.Duration + grpcClient grpcRepository.IClient +} + +func NewRemover( + fileValidator exif.FileValidator, + exifProcessor exif.ExifProcessor, + timeout time.Duration, + grpcClient grpcRepository.IClient, +) *Remover { + logger.Info("initializing EXIF remover") + + return &Remover{ + fileValidator: fileValidator, + exifProcessor: exifProcessor, + timeout: timeout, + grpcClient: grpcClient, + } +} + +func (r *Remover) RemoveExifFromFile(ctx context.Context, filePath string) error { + ctx, cancel := context.WithTimeout(ctx, r.timeout) + defer cancel() + + if err := r.fileValidator.ValidateFileType(filePath); err != nil { + if exifErr, ok := err.(*ExifError); ok && exifErr.Code == ErrorCodeUnsupportedFileType { + return nil + } + return err + } + + if err := r.exifProcessor.RemoveExifData(ctx, filePath); err != nil { + return err + } + + return nil +} + +func (r *Remover) Close() error { + return r.exifProcessor.Close() +} From 7bdf8fe355e8e3d2943656a199327a5d73b77173 Mon Sep 17 00:00:00 2001 From: sadq Date: Sun, 12 Oct 2025 22:47:19 +0330 Subject: [PATCH 08/21] docs: update method comments for clarity --- internal/infrastructure/exif/extension_provider.go | 7 +++++++ internal/infrastructure/exif/file_validator.go | 5 +++++ internal/infrastructure/exif/processor.go | 9 +++++++++ internal/infrastructure/exif/remover.go | 9 +++++++++ 4 files changed, 30 insertions(+) diff --git a/internal/infrastructure/exif/extension_provider.go b/internal/infrastructure/exif/extension_provider.go index 662861a..70259b5 100644 --- a/internal/infrastructure/exif/extension_provider.go +++ b/internal/infrastructure/exif/extension_provider.go @@ -13,12 +13,17 @@ type ExtensionProvider struct { once sync.Once } +// NewExtensionProvider creates a new ExtensionProvider instance that can fetch +// and cache supported file extensions from ExifTool func NewExtensionProvider(exiftoolCmd string) *ExtensionProvider { return &ExtensionProvider{ exiftoolCmd: exiftoolCmd, } } +// GetSupportedExtensions returns a map of supported file extensions (with dot prefix). +// The extensions are fetched from ExifTool on first call and cached for subsequent calls. +// Returns a copy of the map to prevent external modification. func (e *ExtensionProvider) GetSupportedExtensions() (map[string]bool, error) { e.once.Do(func() { e.supportedExtensions, e.initErr = e.fetchSupportedExtensions() @@ -35,6 +40,8 @@ func (e *ExtensionProvider) GetSupportedExtensions() (map[string]bool, error) { return result, nil } +// fetchSupportedExtensions executes 'exiftool -listf' command to retrieve all +// supported file extensions. Each extension is stored with a dot prefix and in lowercase. func (e *ExtensionProvider) fetchSupportedExtensions() (map[string]bool, error) { var cmd *exec.Cmd if e.exiftoolCmd != "" { diff --git a/internal/infrastructure/exif/file_validator.go b/internal/infrastructure/exif/file_validator.go index b6ba64f..f0868e6 100644 --- a/internal/infrastructure/exif/file_validator.go +++ b/internal/infrastructure/exif/file_validator.go @@ -10,12 +10,17 @@ type FileValidator struct { extensionProvider exif.ExtensionProvider } +// NewFileValidator creates a new FileValidator instance that validates file types +// against ExifTool's supported extensions using the provided ExtensionProvider func NewFileValidator(extensionProvider exif.ExtensionProvider) exif.FileValidator { return &FileValidator{ extensionProvider: extensionProvider, } } +// ValidateFileType checks if the given file path has an extension supported by ExifTool. +// Returns ErrorCodeUnsupportedFileType if the file type is not supported. +// Returns ErrorCodeExifToolNotFound if unable to retrieve supported extensions. func (f *FileValidator) ValidateFileType(filePath string) error { ext := strings.ToLower(filepath.Ext(filePath)) diff --git a/internal/infrastructure/exif/processor.go b/internal/infrastructure/exif/processor.go index 15a096c..a8a2bff 100644 --- a/internal/infrastructure/exif/processor.go +++ b/internal/infrastructure/exif/processor.go @@ -17,6 +17,9 @@ type Processor struct { grpcClient grpcRepository.IClient } +// NewProcessor creates a new Processor instance that handles EXIF metadata removal. +// It initializes the ExifTool wrapper with stay-open mode for efficient processing. +// Returns an error if ExifTool initialization fails. func NewProcessor(exiftoolCmd string, timeout time.Duration, grpcClient grpcRepository.IClient) (exif.ExifProcessor, error) { @@ -48,6 +51,10 @@ func NewProcessor(exiftoolCmd string, }, nil } +// RemoveExifData removes all EXIF metadata from the specified file. +// The operation is performed asynchronously with context cancellation support. +// Returns ErrorCodeExifRemovalFailed if metadata removal fails. +// Returns ErrorCodeTimeout if the operation exceeds the configured timeout. func (e *Processor) RemoveExifData(ctx context.Context, filePath string) error { done := make(chan error, 1) @@ -88,6 +95,8 @@ func (e *Processor) RemoveExifData(ctx context.Context, filePath string) error { } } +// Close closes the ExifTool process and releases associated resources. +// Should be called when the Processor is no longer needed. func (e *Processor) Close() error { if e.exiftool != nil { return e.exiftool.Close() diff --git a/internal/infrastructure/exif/remover.go b/internal/infrastructure/exif/remover.go index e2f8881..937fa0c 100644 --- a/internal/infrastructure/exif/remover.go +++ b/internal/infrastructure/exif/remover.go @@ -16,6 +16,9 @@ type Remover struct { grpcClient grpcRepository.IClient } +// NewRemover creates a new Remover instance that orchestrates EXIF metadata removal. +// It uses dependency injection to receive validator and processor services. +// This is the main entry point for EXIF removal operations. func NewRemover( fileValidator exif.FileValidator, exifProcessor exif.ExifProcessor, @@ -32,6 +35,10 @@ func NewRemover( } } +// RemoveExifFromFile removes EXIF metadata from the specified file. +// It first validates that the file type supports EXIF metadata. +// If the file type is unsupported, returns nil (not an error). +// For supported files, removes all EXIF data and returns any errors encountered. func (r *Remover) RemoveExifFromFile(ctx context.Context, filePath string) error { ctx, cancel := context.WithTimeout(ctx, r.timeout) defer cancel() @@ -50,6 +57,8 @@ func (r *Remover) RemoveExifFromFile(ctx context.Context, filePath string) error return nil } +// Close closes the underlying EXIF processor and releases associated resources. +// Should be called when the Remover is no longer needed. func (r *Remover) Close() error { return r.exifProcessor.Close() } From daa96f502fea68ce6f755dec21e3dc7138f4b9fc Mon Sep 17 00:00:00 2001 From: sadq Date: Thu, 6 Nov 2025 21:07:56 +0330 Subject: [PATCH 09/21] feat: add command executor infra --- .../repository/cli_executer/command_executor.go | 5 +++++ .../cli_executer/system_command_executor.go | 13 +++++++++++++ internal/infrastructure/exif/config.go | 5 +++++ 3 files changed, 23 insertions(+) create mode 100644 internal/domain/repository/cli_executer/command_executor.go create mode 100644 internal/infrastructure/cli_executer/system_command_executor.go diff --git a/internal/domain/repository/cli_executer/command_executor.go b/internal/domain/repository/cli_executer/command_executor.go new file mode 100644 index 0000000..07414be --- /dev/null +++ b/internal/domain/repository/cli_executer/command_executor.go @@ -0,0 +1,5 @@ +package cli_executer + +type CommandExecutor interface { + Execute(cmd string, args ...string) ([]byte, error) +} diff --git a/internal/infrastructure/cli_executer/system_command_executor.go b/internal/infrastructure/cli_executer/system_command_executor.go new file mode 100644 index 0000000..b6b6565 --- /dev/null +++ b/internal/infrastructure/cli_executer/system_command_executor.go @@ -0,0 +1,13 @@ +package cli_executer + +import "os/exec" + +type SystemCommandExecutor struct{} + +func NewSystemCommandExecutor() *SystemCommandExecutor { + return &SystemCommandExecutor{} +} + +func (s *SystemCommandExecutor) Execute(cmd string, args ...string) ([]byte, error) { + return exec.Command(cmd, args...).Output() +} diff --git a/internal/infrastructure/exif/config.go b/internal/infrastructure/exif/config.go index 884687d..3705d81 100644 --- a/internal/infrastructure/exif/config.go +++ b/internal/infrastructure/exif/config.go @@ -1,5 +1,10 @@ package exif +type ExtensionProviderConfig struct { + ExifToolCmd string `yaml:"exiftool_cmd"` + ExifToolListFlag string `yaml:"exiftool_list_flag"` +} + type RemoverConfig struct { Timeout int `yaml:"timeout_in_ms"` ExifToolCmd string `yaml:"exiftool_cmd"` From 5452503c833c4ef45071ed674fee2c4271ea4a85 Mon Sep 17 00:00:00 2001 From: sadq Date: Thu, 6 Nov 2025 21:09:41 +0330 Subject: [PATCH 10/21] test: add dependency injection for mocking --- .../infrastructure/exif/extension_provider.go | 17 +- .../exif/extension_provider_test.go | 310 ++++++++++++++++++ 2 files changed, 317 insertions(+), 10 deletions(-) create mode 100644 internal/infrastructure/exif/extension_provider_test.go diff --git a/internal/infrastructure/exif/extension_provider.go b/internal/infrastructure/exif/extension_provider.go index 70259b5..cb47917 100644 --- a/internal/infrastructure/exif/extension_provider.go +++ b/internal/infrastructure/exif/extension_provider.go @@ -1,13 +1,15 @@ package exif import ( - "os/exec" + "nos3/internal/domain/repository/cli_executer" "strings" "sync" ) type ExtensionProvider struct { exiftoolCmd string + listFlag string + executor cli_executer.CommandExecutor supportedExtensions map[string]bool initErr error once sync.Once @@ -17,7 +19,9 @@ type ExtensionProvider struct { // and cache supported file extensions from ExifTool func NewExtensionProvider(exiftoolCmd string) *ExtensionProvider { return &ExtensionProvider{ - exiftoolCmd: exiftoolCmd, + exiftoolCmd: cfg.ExifToolCmd, + listFlag: cfg.ExifToolListFlag, + executor: executor, } } @@ -43,14 +47,7 @@ func (e *ExtensionProvider) GetSupportedExtensions() (map[string]bool, error) { // fetchSupportedExtensions executes 'exiftool -listf' command to retrieve all // supported file extensions. Each extension is stored with a dot prefix and in lowercase. func (e *ExtensionProvider) fetchSupportedExtensions() (map[string]bool, error) { - var cmd *exec.Cmd - if e.exiftoolCmd != "" { - cmd = exec.Command(e.exiftoolCmd, "-listf") - } else { - cmd = exec.Command("exiftool", "-listf") - } - - output, err := cmd.Output() + output, err := e.executor.Execute(e.exiftoolCmd, e.listFlag) if err != nil { return nil, err } diff --git a/internal/infrastructure/exif/extension_provider_test.go b/internal/infrastructure/exif/extension_provider_test.go new file mode 100644 index 0000000..10fa68e --- /dev/null +++ b/internal/infrastructure/exif/extension_provider_test.go @@ -0,0 +1,310 @@ +package exif + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + DefaultExifToolCmd = "" + CustomExifToolCmd = "/usr/bin/exiftool" + InvalidExifToolCmd = "invalid-exiftool-path" + DefaultListFlag = "-listf" + SampleOutput = `JPG JPEG PNG GIF TIFF TIF BMP RAW CR2 NEF ARW DNG MP4 MOV AVI PDF` + MinimalOutput = `JPG PNG` + ThreeExtensionOutput = `JPG PNG GIF` + MixedCaseOutput = `JPG Jpeg PnG gif` + EmptyOutput = `` +) + +func createTestConfig(exiftoolCmd, listFlag string) ExtensionProviderConfig { + return ExtensionProviderConfig{ + ExifToolCmd: exiftoolCmd, + ExifToolListFlag: listFlag, + } +} + +func TestGetSupportedExtensions_Success(t *testing.T) { + t.Parallel() + + cfg := createTestConfig(DefaultExifToolCmd, DefaultListFlag) + mockExecutor := &MockCommandExecutor{} + mockExecutor.On("Execute", DefaultExifToolCmd, []string{DefaultListFlag}).Return([]byte(SampleOutput), nil) + + provider := NewExtensionProvider(cfg, mockExecutor) + + extensions, err := provider.GetSupportedExtensions() + + require.NoError(t, err) + require.NotNil(t, extensions) + assert.True(t, extensions[".jpg"]) + assert.True(t, extensions[".png"]) + assert.True(t, extensions[".mp4"]) + assert.False(t, extensions[".txt"]) + mockExecutor.AssertExpectations(t) +} + +func TestGetSupportedExtensions_CachingBehavior(t *testing.T) { + t.Parallel() + + cfg := createTestConfig(DefaultExifToolCmd, DefaultListFlag) + mockExecutor := &MockCommandExecutor{} + mockExecutor.On("Execute", DefaultExifToolCmd, []string{DefaultListFlag}).Return([]byte(MinimalOutput), nil).Once() + + provider := NewExtensionProvider(cfg, mockExecutor) + + ext1, err1 := provider.GetSupportedExtensions() + require.NoError(t, err1) + require.Equal(t, 2, len(ext1)) + + ext1[".gif"] = true + + ext2, err2 := provider.GetSupportedExtensions() + require.NoError(t, err2) + assert.Equal(t, 2, len(ext2)) + assert.False(t, ext2[".gif"]) + + mockExecutor.AssertExpectations(t) +} + +func TestGetSupportedExtensions_ErrorHandling(t *testing.T) { + t.Parallel() + + cfg := createTestConfig(InvalidExifToolCmd, DefaultListFlag) + mockExecutor := &MockCommandExecutor{} + mockExecutor.On("Execute", InvalidExifToolCmd, []string{DefaultListFlag}).Return(nil, errors.New("command not found")) + + provider := NewExtensionProvider(cfg, mockExecutor) + + extensions, err := provider.GetSupportedExtensions() + + require.Error(t, err) + assert.Nil(t, extensions) + mockExecutor.AssertExpectations(t) +} + +func TestGetSupportedExtensions_EmptyExtensions(t *testing.T) { + t.Parallel() + + cfg := createTestConfig(DefaultExifToolCmd, DefaultListFlag) + mockExecutor := &MockCommandExecutor{} + mockExecutor.On("Execute", DefaultExifToolCmd, []string{DefaultListFlag}).Return([]byte(EmptyOutput), nil) + + provider := NewExtensionProvider(cfg, mockExecutor) + + extensions, err := provider.GetSupportedExtensions() + + require.NoError(t, err) + require.NotNil(t, extensions) + assert.Equal(t, 0, len(extensions)) + mockExecutor.AssertExpectations(t) +} + +func TestFetchSupportedExtensions_ParseOutput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + output string + expectedCount int + shouldContain []string + shouldNotExist []string + }{ + { + name: "standard output", + output: `Recognized file extensions: + JPG JPEG PNG GIF + MP4 MOV AVI + PDF DOC DOCX`, + expectedCount: 10, + shouldContain: []string{".jpg", ".jpeg", ".png", ".gif", ".mp4", ".pdf"}, + shouldNotExist: []string{".txt", ".exe"}, + }, + { + name: "single line", + output: `JPG PNG GIF`, + expectedCount: 3, + shouldContain: []string{".jpg", ".png", ".gif"}, + shouldNotExist: []string{".mp4"}, + }, + { + name: "with headers and data lines", + output: `Recognized file extensions: + JPG PNG + MP4 AVI`, + expectedCount: 4, + shouldContain: []string{".jpg", ".png", ".mp4", ".avi"}, + shouldNotExist: []string{".txt"}, + }, + { + name: "empty output", + output: "", + expectedCount: 0, + shouldContain: []string{}, + shouldNotExist: []string{".jpg"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := createTestConfig(DefaultExifToolCmd, DefaultListFlag) + mockExecutor := &MockCommandExecutor{} + mockExecutor.On("Execute", DefaultExifToolCmd, []string{DefaultListFlag}).Return([]byte(tt.output), nil) + + provider := NewExtensionProvider(cfg, mockExecutor) + + result, err := provider.GetSupportedExtensions() + require.NoError(t, err) + require.Equal(t, tt.expectedCount, len(result)) + + for _, ext := range tt.shouldContain { + assert.True(t, result[ext], "should contain %s", ext) + } + + for _, ext := range tt.shouldNotExist { + assert.False(t, result[ext], "should not contain %s", ext) + } + + mockExecutor.AssertExpectations(t) + }) + } +} + +func TestNewExtensionProvider(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + exiftoolCmd string + listFlag string + }{ + {"with custom command", CustomExifToolCmd, DefaultListFlag}, + {"with default command", DefaultExifToolCmd, DefaultListFlag}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := createTestConfig(tt.exiftoolCmd, tt.listFlag) + mockExecutor := &MockCommandExecutor{} + provider := NewExtensionProvider(cfg, mockExecutor) + + require.NotNil(t, provider) + assert.Equal(t, tt.exiftoolCmd, provider.exiftoolCmd) + assert.Equal(t, tt.listFlag, provider.listFlag) + require.NotNil(t, provider.executor) + assert.Nil(t, provider.supportedExtensions) + assert.Nil(t, provider.initErr) + }) + } +} + +func TestGetSupportedExtensions_WithCustomCommand(t *testing.T) { + t.Parallel() + + cfg := createTestConfig(CustomExifToolCmd, DefaultListFlag) + mockExecutor := &MockCommandExecutor{} + mockExecutor.On("Execute", CustomExifToolCmd, []string{DefaultListFlag}).Return([]byte(ThreeExtensionOutput), nil) + + provider := NewExtensionProvider(cfg, mockExecutor) + + extensions, err := provider.GetSupportedExtensions() + + require.NoError(t, err) + require.Equal(t, 3, len(extensions)) + assert.True(t, extensions[".jpg"]) + assert.True(t, extensions[".png"]) + assert.True(t, extensions[".gif"]) + + mockExecutor.AssertExpectations(t) +} + +func TestGetSupportedExtensions_CaseInsensitive(t *testing.T) { + t.Parallel() + + cfg := createTestConfig(DefaultExifToolCmd, DefaultListFlag) + mockExecutor := &MockCommandExecutor{} + mockExecutor.On("Execute", DefaultExifToolCmd, []string{DefaultListFlag}).Return([]byte(MixedCaseOutput), nil) + + provider := NewExtensionProvider(cfg, mockExecutor) + + extensions, err := provider.GetSupportedExtensions() + + require.NoError(t, err) + assert.True(t, extensions[".jpg"]) + assert.True(t, extensions[".jpeg"]) + assert.True(t, extensions[".png"]) + assert.True(t, extensions[".gif"]) + + mockExecutor.AssertExpectations(t) +} + +func TestGetSupportedExtensions_MultipleCallsUseCachedResult(t *testing.T) { + t.Parallel() + + cfg := createTestConfig(DefaultExifToolCmd, DefaultListFlag) + mockExecutor := &MockCommandExecutor{} + mockExecutor.On("Execute", DefaultExifToolCmd, []string{DefaultListFlag}).Return([]byte(MinimalOutput), nil).Once() + + provider := NewExtensionProvider(cfg, mockExecutor) + + for i := 0; i < 5; i++ { + extensions, err := provider.GetSupportedExtensions() + require.NoError(t, err) + assert.Equal(t, 2, len(extensions)) + } + + mockExecutor.AssertExpectations(t) +} + +func TestGetSupportedExtensions_WithCustomListFlag(t *testing.T) { + t.Parallel() + + customFlag := "--list" + cfg := createTestConfig(DefaultExifToolCmd, customFlag) + mockExecutor := &MockCommandExecutor{} + mockExecutor.On("Execute", DefaultExifToolCmd, []string{customFlag}).Return([]byte(MinimalOutput), nil) + + provider := NewExtensionProvider(cfg, mockExecutor) + + extensions, err := provider.GetSupportedExtensions() + + require.NoError(t, err) + require.NotNil(t, extensions) + assert.Equal(t, 2, len(extensions)) + mockExecutor.AssertExpectations(t) +} + +func TestGetSupportedExtensions_ConcurrentAccess(t *testing.T) { + t.Parallel() + + cfg := createTestConfig(DefaultExifToolCmd, DefaultListFlag) + mockExecutor := &MockCommandExecutor{} + mockExecutor.On("Execute", DefaultExifToolCmd, []string{DefaultListFlag}).Return([]byte(MinimalOutput), nil).Once() + + provider := NewExtensionProvider(cfg, mockExecutor) + + // Run multiple goroutines concurrently + done := make(chan bool) + for i := 0; i < 10; i++ { + go func() { + extensions, err := provider.GetSupportedExtensions() + require.NoError(t, err) + assert.Equal(t, 2, len(extensions)) + done <- true + }() + } + + // Wait for all goroutines to complete + for i := 0; i < 10; i++ { + <-done + } + + mockExecutor.AssertExpectations(t) +} From 74a05cab6be54dda2bf3f71dcf8768ea3fa39bf7 Mon Sep 17 00:00:00 2001 From: sadq Date: Thu, 6 Nov 2025 21:10:33 +0330 Subject: [PATCH 11/21] test: add test for file type validation --- .../exif/file_validator_test.go | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 internal/infrastructure/exif/file_validator_test.go diff --git a/internal/infrastructure/exif/file_validator_test.go b/internal/infrastructure/exif/file_validator_test.go new file mode 100644 index 0000000..26eee07 --- /dev/null +++ b/internal/infrastructure/exif/file_validator_test.go @@ -0,0 +1,292 @@ +package exif + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewFileValidator(t *testing.T) { + t.Parallel() + + mockProvider := &MockExtensionProvider{} + + validator := NewFileValidator(mockProvider) + + require.NotNil(t, validator) + assert.IsType(t, &FileValidator{}, validator) +} + +func TestValidateFileType_SupportedFileTypes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + filePath string + ext string + }{ + {"jpeg file", "/path/to/image.jpg", ".jpg"}, + {"jpeg uppercase", "/path/to/IMAGE.JPG", ".jpg"}, + {"jpeg with dots", "/path.to/file.image.jpeg", ".jpeg"}, + {"png file", "image.png", ".png"}, + {"png uppercase", "IMAGE.PNG", ".png"}, + {"tiff file", "/tmp/scan.tiff", ".tiff"}, + {"gif file", "animated.gif", ".gif"}, + {"raw file", "photo.raw", ".raw"}, + {"cr2 file", "canon.cr2", ".cr2"}, + {"nef file", "nikon.nef", ".nef"}, + {"mp4 video", "video.mp4", ".mp4"}, + {"mov video", "movie.mov", ".mov"}, + {"pdf document", "document.pdf", ".pdf"}, + {"path with spaces", "/path with spaces/file name.jpg", ".jpg"}, + {"relative path", "./relative/path/image.png", ".png"}, + {"no path", "simple.jpg", ".jpg"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockProvider := &MockExtensionProvider{} + supportedExts := map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + ".tiff": true, + ".gif": true, + ".raw": true, + ".cr2": true, + ".nef": true, + ".mp4": true, + ".mov": true, + ".pdf": true, + } + mockProvider.On("GetSupportedExtensions").Return(supportedExts, nil) + + validator := NewFileValidator(mockProvider) + + err := validator.ValidateFileType(tt.filePath) + + require.NoError(t, err) + mockProvider.AssertExpectations(t) + }) + } +} + +func TestValidateFileType_UnsupportedFileTypes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + filePath string + ext string + }{ + {"text file", "/path/to/document.txt", ".txt"}, + {"text uppercase", "DOCUMENT.TXT", ".txt"}, + {"executable", "program.exe", ".exe"}, + {"shell script", "script.sh", ".sh"}, + {"python script", "script.py", ".py"}, + {"go source", "main.go", ".go"}, + {"json file", "config.json", ".json"}, + {"xml file", "data.xml", ".xml"}, + {"csv file", "data.csv", ".csv"}, + {"markdown", "README.md", ".md"}, + {"yaml file", "config.yaml", ".yaml"}, + {"no extension", "filename", ""}, + {"hidden file", ".gitignore", ".gitignore"}, + {"double extension unsupported", "file.tar.gz", ".gz"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockProvider := &MockExtensionProvider{} + supportedExts := map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + } + mockProvider.On("GetSupportedExtensions").Return(supportedExts, nil) + + validator := NewFileValidator(mockProvider) + + err := validator.ValidateFileType(tt.filePath) + + require.Error(t, err) + + var exifErr *ExifError + require.True(t, errors.As(err, &exifErr)) + assert.Equal(t, ErrorCodeUnsupportedFileType, exifErr.Code) + assert.Contains(t, exifErr.Message, "file type does not support EXIF data") + assert.Contains(t, exifErr.Details, tt.ext) + + mockProvider.AssertExpectations(t) + }) + } +} + +func TestValidateFileType_ExtensionProviderError(t *testing.T) { + t.Parallel() + + mockProvider := &MockExtensionProvider{} + providerErr := errors.New("failed to execute exiftool") + mockProvider.On("GetSupportedExtensions").Return(nil, providerErr) + + validator := NewFileValidator(mockProvider) + + err := validator.ValidateFileType("/path/to/image.jpg") + + require.Error(t, err) + + var exifErr *ExifError + require.True(t, errors.As(err, &exifErr)) + assert.Equal(t, ErrorCodeExifToolNotFound, exifErr.Code) + assert.Equal(t, "failed to get supported extensions", exifErr.Message) + assert.Equal(t, providerErr.Error(), exifErr.Details) + + mockProvider.AssertExpectations(t) +} + +func TestValidateFileType_EmptySupportedExtensions(t *testing.T) { + t.Parallel() + + mockProvider := &MockExtensionProvider{} + emptyExts := map[string]bool{} + mockProvider.On("GetSupportedExtensions").Return(emptyExts, nil) + + validator := NewFileValidator(mockProvider) + + err := validator.ValidateFileType("/path/to/image.jpg") + + require.Error(t, err) + + var exifErr *ExifError + require.True(t, errors.As(err, &exifErr)) + assert.Equal(t, ErrorCodeUnsupportedFileType, exifErr.Code) + + mockProvider.AssertExpectations(t) +} + +func TestValidateFileType_EdgeCases(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + filePath string + expectedExt string + isSupported bool + expectedError bool + }{ + { + name: "empty filepath", + filePath: "", + expectedExt: "", + isSupported: false, + expectedError: true, + }, + { + name: "dot only", + filePath: ".", + expectedExt: ".", + isSupported: false, + expectedError: true, + }, + { + name: "multiple dots", + filePath: "file.backup.old.jpg", + expectedExt: ".jpg", + isSupported: true, + expectedError: false, + }, + { + name: "path with dot but no extension", + filePath: "./directory/filename", + expectedExt: "", + isSupported: false, + expectedError: true, + }, + { + name: "very long extension", + filePath: "file.verylongextension", + expectedExt: ".verylongextension", + isSupported: false, + expectedError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockProvider := &MockExtensionProvider{} + supportedExts := map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + } + mockProvider.On("GetSupportedExtensions").Return(supportedExts, nil) + + validator := NewFileValidator(mockProvider) + + err := validator.ValidateFileType(tt.filePath) + + if tt.expectedError { + require.Error(t, err) + if tt.expectedExt != "" { + var exifErr *ExifError + require.True(t, errors.As(err, &exifErr)) + assert.Equal(t, ErrorCodeUnsupportedFileType, exifErr.Code) + } + } else { + require.NoError(t, err) + } + + mockProvider.AssertExpectations(t) + }) + } +} + +func TestValidateFileType_CaseSensitivity(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + filePath string + shouldPass bool + }{ + {"lowercase jpg", "image.jpg", true}, + {"uppercase JPG", "image.JPG", true}, + {"mixed case Jpg", "image.Jpg", true}, + {"mixed case jPg", "image.jPg", true}, + {"all caps JPEG", "IMAGE.JPEG", true}, + {"mixed path case", "Path/To/IMAGE.jpeg", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockProvider := &MockExtensionProvider{} + supportedExts := map[string]bool{ + ".jpg": true, + ".jpeg": true, + } + mockProvider.On("GetSupportedExtensions").Return(supportedExts, nil) + + validator := NewFileValidator(mockProvider) + + err := validator.ValidateFileType(tt.filePath) + + if tt.shouldPass { + require.NoError(t, err) + } else { + require.Error(t, err) + } + + mockProvider.AssertExpectations(t) + }) + } +} From 091828255017b577a2d75210ce1c2583edffe883 Mon Sep 17 00:00:00 2001 From: sadq Date: Thu, 6 Nov 2025 21:12:50 +0330 Subject: [PATCH 12/21] test: move mocks to a separate file --- .../exif/testing_helpers_test.go | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 internal/infrastructure/exif/testing_helpers_test.go diff --git a/internal/infrastructure/exif/testing_helpers_test.go b/internal/infrastructure/exif/testing_helpers_test.go new file mode 100644 index 0000000..4f44312 --- /dev/null +++ b/internal/infrastructure/exif/testing_helpers_test.go @@ -0,0 +1,93 @@ +package exif + +import ( + "context" + + "github.com/stretchr/testify/mock" + + "nos3/internal/infrastructure/grpcclient/gen" +) + +// MockGRPC is a shared mock implementation of the gRPC client interface +type MockGRPC struct { + mock.Mock +} + +func (m *MockGRPC) RegisterService(_ context.Context, _, _ string) (*gen.RegisterServiceResponse, error) { + args := m.Called() + return args.Get(0).(*gen.RegisterServiceResponse), args.Error(1) +} + +func (m *MockGRPC) AddLog(_ context.Context, msg, stack string) (*gen.AddLogResponse, error) { + args := m.Called(msg, stack) + return args.Get(0).(*gen.AddLogResponse), args.Error(1) +} + +func (m *MockGRPC) AddReport(_ context.Context, _ string, _ []string, _, _, _, _ string) ( + *gen.AddReportResponse, error, +) { + args := m.Called() + return args.Get(0).(*gen.AddReportResponse), args.Error(1) +} + +// MockCommandExecutor is a mock for command execution +type MockCommandExecutor struct { + mock.Mock +} + +func (m *MockCommandExecutor) Execute(cmd string, args ...string) ([]byte, error) { + argsList := m.Called(cmd, args) + if argsList.Get(0) == nil { + return nil, argsList.Error(1) + } + return argsList.Get(0).([]byte), argsList.Error(1) +} + +// MockExtensionProvider is a mock for extension provider +type MockExtensionProvider struct { + mock.Mock +} + +func (m *MockExtensionProvider) GetSupportedExtensions() (map[string]bool, error) { + args := m.Called() + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(map[string]bool), args.Error(1) +} + +// MockExifProcessor is a mock for exif processor +type MockExifProcessor struct { + mock.Mock +} + +func (m *MockExifProcessor) RemoveExifData(ctx context.Context, filePath string) error { + args := m.Called(ctx, filePath) + return args.Error(0) +} + +func (m *MockExifProcessor) Close() error { + args := m.Called() + return args.Error(0) +} + +// MockFileValidator is a mock for file validator +type MockFileValidator struct { + mock.Mock +} + +func (m *MockFileValidator) ValidateFileType(filePath string) error { + args := m.Called(filePath) + return args.Error(0) +} + +const ( + TestImagePath = "/tmp/test_image.jpg" + TestVideoPath = "/tmp/test_video.mp4" + TestFilePath = "/test/image.jpg" + TestUnsupportedPath = "/test/document.txt" + TestCorruptedPath = "/test/corrupted.jpg" + TestTimeoutPath = "/test/timeout.jpg" + TestExifToolPath = "/usr/bin/exiftool" + InvalidExifToolPath = "/invalid/path/exiftool" +) From 63cc7724ec0f9d4e10c8dff75c0489d956ac2c10 Mon Sep 17 00:00:00 2001 From: sadq Date: Thu, 6 Nov 2025 21:21:21 +0330 Subject: [PATCH 13/21] fix: add correct parameters to constructor --- internal/infrastructure/exif/extension_provider.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/infrastructure/exif/extension_provider.go b/internal/infrastructure/exif/extension_provider.go index cb47917..48fb160 100644 --- a/internal/infrastructure/exif/extension_provider.go +++ b/internal/infrastructure/exif/extension_provider.go @@ -17,7 +17,7 @@ type ExtensionProvider struct { // NewExtensionProvider creates a new ExtensionProvider instance that can fetch // and cache supported file extensions from ExifTool -func NewExtensionProvider(exiftoolCmd string) *ExtensionProvider { +func NewExtensionProvider(cfg ExtensionProviderConfig, executor cli_executer.CommandExecutor) *ExtensionProvider { return &ExtensionProvider{ exiftoolCmd: cfg.ExifToolCmd, listFlag: cfg.ExifToolListFlag, From 6e64d822abb2afbda18f4f9b38b73a4ed215e324 Mon Sep 17 00:00:00 2001 From: sadq Date: Thu, 6 Nov 2025 21:21:44 +0330 Subject: [PATCH 14/21] test: add tests for remover service --- internal/infrastructure/exif/remover_test.go | 276 +++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 internal/infrastructure/exif/remover_test.go diff --git a/internal/infrastructure/exif/remover_test.go b/internal/infrastructure/exif/remover_test.go new file mode 100644 index 0000000..89ddb39 --- /dev/null +++ b/internal/infrastructure/exif/remover_test.go @@ -0,0 +1,276 @@ +package exif + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + RemoverTimeout = 5 * time.Second +) + +func TestNewRemover(t *testing.T) { + t.Parallel() + + mockValidator := &MockFileValidator{} + mockProcessor := &MockExifProcessor{} + mockGRPC := &MockGRPC{} + + remover := NewRemover(mockValidator, mockProcessor, RemoverTimeout, mockGRPC) + + require.NotNil(t, remover) + assert.Equal(t, RemoverTimeout, remover.timeout) + assert.Equal(t, mockValidator, remover.fileValidator) + assert.Equal(t, mockProcessor, remover.exifProcessor) + assert.Equal(t, mockGRPC, remover.grpcClient) +} + +func TestRemoveExifFromFile_Success(t *testing.T) { + t.Parallel() + + mockValidator := &MockFileValidator{} + mockProcessor := &MockExifProcessor{} + mockGRPC := &MockGRPC{} + + mockValidator.On("ValidateFileType", TestFilePath).Return(nil) + mockProcessor.On("RemoveExifData", mock.Anything, TestFilePath).Return(nil) + + remover := NewRemover(mockValidator, mockProcessor, RemoverTimeout, mockGRPC) + + err := remover.RemoveExifFromFile(context.Background(), TestFilePath) + + require.NoError(t, err) + mockValidator.AssertExpectations(t) + mockProcessor.AssertExpectations(t) +} + +func TestRemoveExifFromFile_UnsupportedFileType(t *testing.T) { + t.Parallel() + + mockValidator := &MockFileValidator{} + mockProcessor := &MockExifProcessor{} + mockGRPC := &MockGRPC{} + + unsupportedErr := &ExifError{ + Code: ErrorCodeUnsupportedFileType, + Message: "file type does not support EXIF data", + Details: ".txt not supported", + } + + mockValidator.On("ValidateFileType", TestUnsupportedPath).Return(unsupportedErr) + + remover := NewRemover(mockValidator, mockProcessor, RemoverTimeout, mockGRPC) + + err := remover.RemoveExifFromFile(context.Background(), TestUnsupportedPath) + + require.NoError(t, err) + mockValidator.AssertExpectations(t) + mockProcessor.AssertNotCalled(t, "RemoveExifData", mock.Anything, mock.Anything) +} + +func TestRemoveExifFromFile_ValidationError(t *testing.T) { + t.Parallel() + + mockValidator := &MockFileValidator{} + mockProcessor := &MockExifProcessor{} + mockGRPC := &MockGRPC{} + + validationErr := &ExifError{ + Code: ErrorCodeExifToolNotFound, + Message: "failed to get supported extensions", + Details: "exiftool not found", + } + + mockValidator.On("ValidateFileType", TestFilePath).Return(validationErr) + + remover := NewRemover(mockValidator, mockProcessor, RemoverTimeout, mockGRPC) + + err := remover.RemoveExifFromFile(context.Background(), TestFilePath) + + require.Error(t, err) + assert.Equal(t, validationErr, err) + mockValidator.AssertExpectations(t) + mockProcessor.AssertNotCalled(t, "RemoveExifData", mock.Anything, mock.Anything) +} + +func TestRemoveExifFromFile_ProcessorError(t *testing.T) { + t.Parallel() + + mockValidator := &MockFileValidator{} + mockProcessor := &MockExifProcessor{} + mockGRPC := &MockGRPC{} + + processorErr := &ExifError{ + Code: ErrorCodeExifRemovalFailed, + Message: "failed to remove EXIF data", + Details: "write error", + } + + mockValidator.On("ValidateFileType", TestCorruptedPath).Return(nil) + mockProcessor.On("RemoveExifData", mock.Anything, TestCorruptedPath).Return(processorErr) + + remover := NewRemover(mockValidator, mockProcessor, RemoverTimeout, mockGRPC) + + err := remover.RemoveExifFromFile(context.Background(), TestCorruptedPath) + + require.Error(t, err) + assert.Equal(t, processorErr, err) + mockValidator.AssertExpectations(t) + mockProcessor.AssertExpectations(t) +} + +func TestRemoveExifFromFile_ContextCancellation(t *testing.T) { + t.Parallel() + + mockValidator := &MockFileValidator{} + mockProcessor := &MockExifProcessor{} + mockGRPC := &MockGRPC{} + + mockValidator.On("ValidateFileType", TestFilePath).Return(nil) + mockProcessor.On("RemoveExifData", mock.Anything, TestFilePath). + Return(&ExifError{ + Code: ErrorCodeTimeout, + Message: "context cancelled", + Details: "operation cancelled", + }) + + remover := NewRemover(mockValidator, mockProcessor, RemoverTimeout, mockGRPC) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := remover.RemoveExifFromFile(ctx, TestFilePath) + + require.Error(t, err) + mockValidator.AssertExpectations(t) +} + +func TestRemoveExifFromFile_MultipleFiles(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + filePath string + validateErr error + processErr error + expectError bool + expectedCode ErrorCode + }{ + { + name: "successful removal", + filePath: "/images/photo1.jpg", + validateErr: nil, + processErr: nil, + expectError: false, + }, + { + name: "unsupported file", + filePath: "/docs/readme.txt", + validateErr: &ExifError{Code: ErrorCodeUnsupportedFileType}, + processErr: nil, + expectError: false, + }, + { + name: "validation fails", + filePath: "/images/photo2.jpg", + validateErr: &ExifError{Code: ErrorCodeExifToolNotFound}, + processErr: nil, + expectError: true, + expectedCode: ErrorCodeExifToolNotFound, + }, + { + name: "processing fails", + filePath: "/images/photo3.jpg", + validateErr: nil, + processErr: &ExifError{Code: ErrorCodeExifRemovalFailed}, + expectError: true, + expectedCode: ErrorCodeExifRemovalFailed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockValidator := &MockFileValidator{} + mockProcessor := &MockExifProcessor{} + mockGRPC := &MockGRPC{} + + mockValidator.On("ValidateFileType", tt.filePath).Return(tt.validateErr) + + if tt.validateErr == nil || tt.validateErr.(*ExifError).Code != ErrorCodeUnsupportedFileType { + if tt.validateErr == nil { + mockProcessor.On("RemoveExifData", mock.Anything, tt.filePath).Return(tt.processErr) + } + } + + remover := NewRemover(mockValidator, mockProcessor, RemoverTimeout, mockGRPC) + + err := remover.RemoveExifFromFile(context.Background(), tt.filePath) + + if tt.expectError { + require.Error(t, err) + var exifErr *ExifError + require.True(t, errors.As(err, &exifErr)) + assert.Equal(t, tt.expectedCode, exifErr.Code) + } else { + require.NoError(t, err) + } + + mockValidator.AssertExpectations(t) + mockProcessor.AssertExpectations(t) + }) + } +} + +func TestClose(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + closeErr error + expectError bool + }{ + { + name: "successful close", + closeErr: nil, + expectError: false, + }, + { + name: "close error", + closeErr: errors.New("failed to close exiftool"), + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockValidator := &MockFileValidator{} + mockProcessor := &MockExifProcessor{} + mockGRPC := &MockGRPC{} + + mockProcessor.On("Close").Return(tt.closeErr) + + remover := NewRemover(mockValidator, mockProcessor, RemoverTimeout, mockGRPC) + + err := remover.Close() + + if tt.expectError { + require.Error(t, err) + assert.Equal(t, tt.closeErr, err) + } else { + require.NoError(t, err) + } + + mockProcessor.AssertExpectations(t) + }) + } +} From b128e6a64fc537be8b2200ec9b901aff25288889 Mon Sep 17 00:00:00 2001 From: sadq Date: Thu, 6 Nov 2025 23:19:19 +0330 Subject: [PATCH 15/21] test: add tests for processor service --- .../infrastructure/exif/processor_test.go | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 internal/infrastructure/exif/processor_test.go diff --git a/internal/infrastructure/exif/processor_test.go b/internal/infrastructure/exif/processor_test.go new file mode 100644 index 0000000..ac5fa73 --- /dev/null +++ b/internal/infrastructure/exif/processor_test.go @@ -0,0 +1,231 @@ +package exif + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "nos3/internal/infrastructure/grpcclient/gen" +) + +const ( + TestTimeout = 5 * time.Second + TestExifComment = "Test EXIF comment with metadata" + TestExifArtist = "Test Artist" + TestExifCopyright = "Copyright 2024" +) + +func setupTestDir(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "exif_processor_test_*") + require.NoError(t, err) + t.Cleanup(func() { + _ = os.RemoveAll(dir) + }) + return dir +} + +func checkExifToolAvailable(t *testing.T) { + t.Helper() + cmd := exec.Command("exiftool", "-ver") + if err := cmd.Run(); err != nil { + t.Skip("exiftool not available on this system, skipping integration test") + } +} + +func createTestImageWithExif(t *testing.T, filePath string) { + t.Helper() + jpegData := []byte{ + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, + 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, + 0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09, + 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B, 0x0C, 0x19, 0x12, + 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20, + 0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29, + 0x2C, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32, + 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, + 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00, 0x14, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x03, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, + 0x37, 0xFF, 0xD9, + } + err := os.WriteFile(filePath, jpegData, 0644) + require.NoError(t, err) + cmd := exec.Command("exiftool", + "-overwrite_original", + fmt.Sprintf("-Comment=%s", TestExifComment), + fmt.Sprintf("-Artist=%s", TestExifArtist), + fmt.Sprintf("-Copyright=%s", TestExifCopyright), + "-Make=TestCamera", + "-Model=TestModel", + "-Software=TestSoftware", + filePath, + ) + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Failed to add EXIF data: %s", string(output)) +} + +func createTestPNGWithExif(t *testing.T, filePath string) { + t.Helper() + pngData := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, + 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, + 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, + 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, + 0xAE, 0x42, 0x60, 0x82, + } + err := os.WriteFile(filePath, pngData, 0644) + require.NoError(t, err) + cmd := exec.Command("exiftool", + "-overwrite_original", + fmt.Sprintf("-Comment=%s", TestExifComment), + fmt.Sprintf("-Artist=%s", TestExifArtist), + filePath, + ) + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Failed to add metadata: %s", string(output)) +} + +func hasExifData(t *testing.T, filePath string) bool { + t.Helper() + cmd := exec.Command("exiftool", "-a", "-G1", filePath) + output, err := cmd.CombinedOutput() + if err != nil { + return false + } + outputStr := string(output) + return strings.Contains(outputStr, TestExifComment) || + strings.Contains(outputStr, TestExifArtist) || + strings.Contains(outputStr, TestExifCopyright) || + strings.Contains(outputStr, "TestCamera") +} + +func TestProcessor_RemoveExifData_JPEG(t *testing.T) { + checkExifToolAvailable(t) + + testDir := setupTestDir(t) + testFile := filepath.Join(testDir, "test_image.jpg") + createTestImageWithExif(t, testFile) + + require.True(t, hasExifData(t, testFile), "Test image should have EXIF data") + + mockGRPC := &MockGRPC{} + processor, err := NewProcessor("", TestTimeout, mockGRPC) + require.NoError(t, err) + defer processor.Close() + + err = processor.RemoveExifData(context.Background(), testFile) + assert.NoError(t, err) + + assert.False(t, hasExifData(t, testFile), "EXIF data should be removed") + fileInfo, err := os.Stat(testFile) + assert.NoError(t, err) + assert.Greater(t, fileInfo.Size(), int64(0), "File should not be empty") +} + +func TestProcessor_RemoveExifData_PNG(t *testing.T) { + checkExifToolAvailable(t) + + testDir := setupTestDir(t) + testFile := filepath.Join(testDir, "test_image.png") + createTestPNGWithExif(t, testFile) + + require.True(t, hasExifData(t, testFile), "Test PNG should have metadata") + + mockGRPC := &MockGRPC{} + processor, err := NewProcessor("", TestTimeout, mockGRPC) + require.NoError(t, err) + defer processor.Close() + + err = processor.RemoveExifData(context.Background(), testFile) + assert.NoError(t, err) + + assert.False(t, hasExifData(t, testFile), "Metadata should be removed") + fileInfo, err := os.Stat(testFile) + assert.NoError(t, err) + assert.Greater(t, fileInfo.Size(), int64(0), "File should not be empty") +} + +func TestProcessor_RemoveExifData_NonExistentFile(t *testing.T) { + checkExifToolAvailable(t) + + testDir := setupTestDir(t) + nonExistentFile := filepath.Join(testDir, "does_not_exist.jpg") + + mockGRPC := &MockGRPC{} + processor, err := NewProcessor("", TestTimeout, mockGRPC) + require.NoError(t, err) + defer processor.Close() + + err = processor.RemoveExifData(context.Background(), nonExistentFile) + if err != nil { + var exifErr *ExifError + if assert.ErrorAs(t, err, &exifErr) { + assert.Equal(t, ErrorCodeExifRemovalFailed, exifErr.Code) + } + } +} + +func TestProcessor_RemoveExifData_ContextCancellation(t *testing.T) { + checkExifToolAvailable(t) + + testDir := setupTestDir(t) + testFile := filepath.Join(testDir, "test_image.jpg") + createTestImageWithExif(t, testFile) + + mockGRPC := &MockGRPC{} + processor, err := NewProcessor("", TestTimeout, mockGRPC) + require.NoError(t, err) + defer processor.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err = processor.RemoveExifData(ctx, testFile) + assert.Error(t, err) + + var exifErr *ExifError + if assert.ErrorAs(t, err, &exifErr) { + assert.Equal(t, ErrorCodeTimeout, exifErr.Code) + assert.True(t, exifErr.IsTimeout()) + } +} + +func TestProcessor_Close(t *testing.T) { + checkExifToolAvailable(t) + + mockGRPC := &MockGRPC{} + processor, err := NewProcessor("", TestTimeout, mockGRPC) + require.NoError(t, err) + + err = processor.Close() + assert.NoError(t, err) +} + +func TestProcessor_NewProcessor_InvalidExifToolPath(t *testing.T) { + mockGRPC := &MockGRPC{} + mockGRPC.On("AddLog", mock.Anything, mock.Anything).Return(&gen.AddLogResponse{}, nil).Maybe() + + processor, err := NewProcessor("/invalid/path/to/exiftool", TestTimeout, mockGRPC) + assert.Error(t, err) + assert.Nil(t, processor) + + var exifErr *ExifError + if assert.ErrorAs(t, err, &exifErr) { + assert.Equal(t, ErrorCodeExifToolNotFound, exifErr.Code) + } +} From 90cecb80b3956ef6e1070f2c6cb933b0c2941788 Mon Sep 17 00:00:00 2001 From: sadq Date: Sun, 16 Nov 2025 20:58:05 +0330 Subject: [PATCH 16/21] refactor: remove .Maybe() from mock setups since no assertion needed --- internal/infrastructure/clamav/scanner_test.go | 15 +++++++-------- internal/infrastructure/database/lister_test.go | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/internal/infrastructure/clamav/scanner_test.go b/internal/infrastructure/clamav/scanner_test.go index e598ec9..0f48e11 100644 --- a/internal/infrastructure/clamav/scanner_test.go +++ b/internal/infrastructure/clamav/scanner_test.go @@ -43,7 +43,6 @@ const ( MalwareDescTrojanTest = "Trojan.Test.123" MalwareDescVirusTest = "Virus.Test.456" Timeout = 30000 - TimeoutDuration = 30 * time.Second StartupTimeoutDuration = 60 * time.Second LargeFileContentRepeatCount = 350000 LargeBenchmarkRepeatCount = 30000 @@ -151,7 +150,7 @@ func TestScanStream_CleanFile(t *testing.T) { mockGRPC := &MockGRPC{} mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). - Return(&gen.AddLogResponse{}, nil).Maybe() + Return(&gen.AddLogResponse{}, nil) scanner, err := NewScanner(ScannerConfig{ Address: address, @@ -178,7 +177,7 @@ func TestScanStream_InfectedFile(t *testing.T) { mockGRPC := &MockGRPC{} mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). - Return(&gen.AddLogResponse{}, nil).Maybe() + Return(&gen.AddLogResponse{}, nil) scanner, err := NewScanner(ScannerConfig{ Address: address, @@ -203,7 +202,7 @@ func TestScanStream_EmptyFile(t *testing.T) { mockGRPC := &MockGRPC{} mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). - Return(&gen.AddLogResponse{}, nil).Maybe() + Return(&gen.AddLogResponse{}, nil) scanner, err := NewScanner(ScannerConfig{ Address: address, @@ -230,7 +229,7 @@ func TestScanStream_LargeCleanFile(t *testing.T) { mockGRPC := &MockGRPC{} mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). - Return(&gen.AddLogResponse{}, nil).Maybe() + Return(&gen.AddLogResponse{}, nil) scanner, err := NewScanner(ScannerConfig{ Address: address, @@ -258,7 +257,7 @@ func TestScanStream_BinaryCleanFile(t *testing.T) { mockGRPC := &MockGRPC{} mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). - Return(&gen.AddLogResponse{}, nil).Maybe() + Return(&gen.AddLogResponse{}, nil) scanner, err := NewScanner(ScannerConfig{ Address: address, @@ -431,7 +430,7 @@ func BenchmarkScanStream_SmallCleanFile(b *testing.B) { mockGRPC := &MockGRPC{} mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). - Return(&gen.AddLogResponse{}, nil).Maybe() + Return(&gen.AddLogResponse{}, nil) scanner, err := NewScanner(ScannerConfig{ Address: address, @@ -459,7 +458,7 @@ func BenchmarkScanStream_LargeCleanFile(b *testing.B) { mockGRPC := &MockGRPC{} mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). - Return(&gen.AddLogResponse{}, nil).Maybe() + Return(&gen.AddLogResponse{}, nil) scanner, err := NewScanner(ScannerConfig{ Address: address, diff --git a/internal/infrastructure/database/lister_test.go b/internal/infrastructure/database/lister_test.go index dcf17cb..11b7983 100644 --- a/internal/infrastructure/database/lister_test.go +++ b/internal/infrastructure/database/lister_test.go @@ -96,7 +96,7 @@ func TestGetByAuthor(t *testing.T) { defer cleanUp() mockGrpcClient := &MockGRPC{} - mockGrpcClient.On("AddLog", mock.Anything, mock.Anything, mock.Anything).Return(&gen.AddLogResponse{Success: true}, nil).Maybe() + mockGrpcClient.On("AddLog", mock.Anything, mock.Anything, mock.Anything).Return(&gen.AddLogResponse{Success: true}, nil) db, err := Connect(Config{ URI: uri, From c15154baad1519bc1bf94df600deca7671308876 Mon Sep 17 00:00:00 2001 From: sadq Date: Sun, 23 Nov 2025 23:40:09 +0330 Subject: [PATCH 17/21] refactor: use custom clamd client --- go.mod | 2 +- go.sum | 4 ++-- internal/domain/repository/clamav/client.go | 2 +- internal/infrastructure/clamav/scanner.go | 2 +- internal/infrastructure/clamav/scanner_test.go | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index ad9aba1..9b8709f 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.24.1 require ( github.com/barasher/go-exiftool v1.10.0 - github.com/dutchcoders/go-clamd v0.0.0-20170520113014-b970184f4d9e github.com/gabriel-vasile/mimetype v1.4.9 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 @@ -14,6 +13,7 @@ require ( github.com/redis/go-redis/v9 v9.8.0 github.com/rs/zerolog v1.33.0 github.com/stretchr/testify v1.10.0 + github.com/swimmingrieux/go-clamd v0.0.0-20251116164441-e8eb8db2ea4c github.com/testcontainers/testcontainers-go v0.37.0 go.mongodb.org/mongo-driver v1.17.3 google.golang.org/grpc v1.70.0 diff --git a/go.sum b/go.sum index f88a214..f757f5e 100644 --- a/go.sum +++ b/go.sum @@ -60,8 +60,6 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/dutchcoders/go-clamd v0.0.0-20170520113014-b970184f4d9e h1:rcHHSQqzCgvlwP0I/fQ8rQMn/MpHE5gWSLdtpxtP6KQ= -github.com/dutchcoders/go-clamd v0.0.0-20170520113014-b970184f4d9e/go.mod h1:Byz7q8MSzSPkouskHJhX0er2mZY/m0Vj5bMeMCkkyY4= github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= @@ -198,6 +196,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/swimmingrieux/go-clamd v0.0.0-20251116164441-e8eb8db2ea4c h1:6h4UQvFHiMm4Z9/vH/5yMO80+F3p+jrXpCsJG9pW6dM= +github.com/swimmingrieux/go-clamd v0.0.0-20251116164441-e8eb8db2ea4c/go.mod h1:1XQNbsdPSew/aqEAk2Uz3qcAbX15zGgh2aQMaBlizyk= github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg= github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= diff --git a/internal/domain/repository/clamav/client.go b/internal/domain/repository/clamav/client.go index f6ebde6..de7ae5d 100644 --- a/internal/domain/repository/clamav/client.go +++ b/internal/domain/repository/clamav/client.go @@ -3,7 +3,7 @@ package clamav import ( "io" - "github.com/dutchcoders/go-clamd" + "github.com/swimmingrieux/go-clamd" ) type ClamdClient interface { diff --git a/internal/infrastructure/clamav/scanner.go b/internal/infrastructure/clamav/scanner.go index 7316305..755db92 100644 --- a/internal/infrastructure/clamav/scanner.go +++ b/internal/infrastructure/clamav/scanner.go @@ -6,7 +6,7 @@ import ( "nos3/internal/domain/repository/clamav" "time" - "github.com/dutchcoders/go-clamd" + "github.com/swimmingrieux/go-clamd" "nos3/internal/domain/entity" grpcRepository "nos3/internal/domain/repository/grpcclient" diff --git a/internal/infrastructure/clamav/scanner_test.go b/internal/infrastructure/clamav/scanner_test.go index 0f48e11..8965f48 100644 --- a/internal/infrastructure/clamav/scanner_test.go +++ b/internal/infrastructure/clamav/scanner_test.go @@ -11,10 +11,10 @@ import ( "testing" "time" - "github.com/dutchcoders/go-clamd" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/swimmingrieux/go-clamd" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" From ba169b2bb47b11da7a7fd31f0fb6b38dbd503c61 Mon Sep 17 00:00:00 2001 From: sadq Date: Sun, 23 Nov 2025 23:46:04 +0330 Subject: [PATCH 18/21] test: implement wait strategy for clamav client --- .../infrastructure/clamav/scanner_test.go | 126 +++++------------- 1 file changed, 30 insertions(+), 96 deletions(-) diff --git a/internal/infrastructure/clamav/scanner_test.go b/internal/infrastructure/clamav/scanner_test.go index 8965f48..a440bc0 100644 --- a/internal/infrastructure/clamav/scanner_test.go +++ b/internal/infrastructure/clamav/scanner_test.go @@ -30,8 +30,6 @@ const ( CleanFileContent = "This is a clean test file with no malware." EmptyFileContent = "" LargeCleanFileContent = "This is a clean file content. " - SmallCleanBenchmarkContent = "This is a small clean test file for benchmarking." - LargeCleanBenchmarkContent = "Large file content for benchmarking. " InfectedContent = "infected content" UnparseableContent = "unparseable content" SomeContent = "some content" @@ -45,7 +43,6 @@ const ( Timeout = 30000 StartupTimeoutDuration = 60 * time.Second LargeFileContentRepeatCount = 350000 - LargeBenchmarkRepeatCount = 30000 ) type MockGRPC struct { @@ -138,10 +135,40 @@ func setupClamAV(t *testing.T) (string, func()) { address := fmt.Sprintf(ClamAVAddressFormat, net.JoinHostPort(host, port.Port())) + waitForClamAVReady(t, address) + return address, func() { _ = container.Terminate(ctx) } } + +func waitForClamAVReady(t *testing.T, address string) { + t.Helper() + maxRetries := 30 + retryDelay := 2 * time.Second + + mockGRPC := &MockGRPC{} + mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). + Return(&gen.AddLogResponse{}, nil) + + for i := 0; i < maxRetries; i++ { + scanner, err := NewScanner(ScannerConfig{ + Address: address, + Timeout: Timeout, + }, mockGRPC) + + if err == nil && scanner != nil { + // Successfully connected and pinged + return + } + + if i < maxRetries-1 { + time.Sleep(retryDelay) + } + } + + t.Fatal("ClamAV daemon did not become ready within timeout period") +} func TestScanStream_CleanFile(t *testing.T) { // t.Parallel() @@ -386,96 +413,3 @@ func TestScanStream_ParseErrorDuringScan(t *testing.T) { assert.Equal(t, ErrorCodeScanFailed, malwareErr.Code) assert.Equal(t, ParseErrorFileFormatNotRecog, malwareErr.Details) } - -func setupClamAVBenchmark(b *testing.B) (string, func()) { - b.Helper() - ctx := context.Background() - - req := testcontainers.ContainerRequest{ - Image: ClamAVImage, - ExposedPorts: []string{ClamAVPort}, - WaitingFor: wait.ForAll( - wait.ForListeningPort(ClamAVPort).WithStartupTimeout(StartupTimeoutDuration), - ), - } - - container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) - if err != nil { - b.Fatal("Failed to start ClamAV container:", err) - } - - host, err := container.Host(ctx) - if err != nil { - b.Fatal("Failed to get container host:", err) - } - - port, err := container.MappedPort(ctx, "3310") - if err != nil { - b.Fatal("Failed to get mapped port:", err) - } - - address := fmt.Sprintf(ClamAVAddressFormat, net.JoinHostPort(host, port.Port())) - - return address, func() { - _ = container.Terminate(ctx) - } -} - -func BenchmarkScanStream_SmallCleanFile(b *testing.B) { - address, cleanup := setupClamAVBenchmark(b) - defer cleanup() - - mockGRPC := &MockGRPC{} - mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). - Return(&gen.AddLogResponse{}, nil) - - scanner, err := NewScanner(ScannerConfig{ - Address: address, - Timeout: Timeout, - }, mockGRPC) - if err != nil { - b.Fatal("Failed to create scanner:", err) - } - - content := SmallCleanBenchmarkContent - - b.ResetTimer() - for i := 0; i < b.N; i++ { - reader := strings.NewReader(content) - _, err := scanner.ScanStream(context.Background(), reader) - if err != nil { - b.Fatal("Scan failed:", err) - } - } -} - -func BenchmarkScanStream_LargeCleanFile(b *testing.B) { - address, cleanup := setupClamAVBenchmark(b) - defer cleanup() - - mockGRPC := &MockGRPC{} - mockGRPC.On("AddLog", mock.AnythingOfType("string"), mock.AnythingOfType("string")). - Return(&gen.AddLogResponse{}, nil) - - scanner, err := NewScanner(ScannerConfig{ - Address: address, - Timeout: Timeout, - }, mockGRPC) - if err != nil { - b.Fatal("Failed to create scanner:", err) - } - - content := strings.Repeat(LargeCleanBenchmarkContent, LargeBenchmarkRepeatCount) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - reader := strings.NewReader(content) - _, err := scanner.ScanStream(context.Background(), reader) - if err != nil { - b.Fatal("Scan failed:", err) - } - } -} From 51dbf76bd044507418f433225fa8f1228a3cbf7d Mon Sep 17 00:00:00 2001 From: sadq Date: Sun, 23 Nov 2025 23:47:12 +0330 Subject: [PATCH 19/21] refactor: update vulnerable packages --- go.mod | 12 ++++++------ go.sum | 12 ++++++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 9b8709f..3020972 100644 --- a/go.mod +++ b/go.mod @@ -111,12 +111,12 @@ require ( go.opentelemetry.io/otel/trace v1.35.0 // indirect go.opentelemetry.io/proto/otlp v1.5.0 // indirect golang.org/x/arch v0.15.0 // indirect - golang.org/x/crypto v0.37.0 // indirect - golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.8.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect ) diff --git a/go.sum b/go.sum index f757f5e..b90472e 100644 --- a/go.sum +++ b/go.sum @@ -260,8 +260,12 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= +golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -273,12 +277,15 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -297,16 +304,21 @@ golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= From 962b7a0b25f23ad6b791dfa661913d727da833f2 Mon Sep 17 00:00:00 2001 From: sadq Date: Sun, 23 Nov 2025 23:54:57 +0330 Subject: [PATCH 20/21] chore: format code with make fmt --- go.sum | 16 ++-------------- internal/domain/repository/clamav/scanner.go | 1 + internal/infrastructure/clamav/scanner.go | 6 ++++-- internal/infrastructure/clamav/scanner_test.go | 2 ++ .../infrastructure/exif/extension_provider.go | 3 ++- internal/infrastructure/exif/file_validator.go | 3 ++- internal/infrastructure/exif/processor.go | 6 ++++-- internal/infrastructure/exif/processor_test.go | 4 ++-- internal/infrastructure/exif/remover.go | 3 ++- 9 files changed, 21 insertions(+), 23 deletions(-) diff --git a/go.sum b/go.sum index b90472e..7102f45 100644 --- a/go.sum +++ b/go.sum @@ -258,12 +258,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -275,16 +271,13 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -302,21 +295,16 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= diff --git a/internal/domain/repository/clamav/scanner.go b/internal/domain/repository/clamav/scanner.go index 79e6832..a9ba82d 100644 --- a/internal/domain/repository/clamav/scanner.go +++ b/internal/domain/repository/clamav/scanner.go @@ -3,6 +3,7 @@ package clamav import ( "context" "io" + "nos3/internal/domain/entity" ) diff --git a/internal/infrastructure/clamav/scanner.go b/internal/infrastructure/clamav/scanner.go index 755db92..34e95dc 100644 --- a/internal/infrastructure/clamav/scanner.go +++ b/internal/infrastructure/clamav/scanner.go @@ -3,9 +3,10 @@ package clamav import ( "context" "io" - "nos3/internal/domain/repository/clamav" "time" + "nos3/internal/domain/repository/clamav" + "github.com/swimmingrieux/go-clamd" "nos3/internal/domain/entity" @@ -79,7 +80,8 @@ func (s *Scanner) ScanStream(ctx context.Context, reader io.Reader) (entity.Malw } func (s *Scanner) processResults(ctx context.Context, - resultChan chan *clamd.ScanResult) (entity.MalwareScanResult, error) { + resultChan chan *clamd.ScanResult, +) (entity.MalwareScanResult, error) { var threats []string for { diff --git a/internal/infrastructure/clamav/scanner_test.go b/internal/infrastructure/clamav/scanner_test.go index a440bc0..d59caed 100644 --- a/internal/infrastructure/clamav/scanner_test.go +++ b/internal/infrastructure/clamav/scanner_test.go @@ -169,6 +169,7 @@ func waitForClamAVReady(t *testing.T, address string) { t.Fatal("ClamAV daemon did not become ready within timeout period") } + func TestScanStream_CleanFile(t *testing.T) { // t.Parallel() @@ -221,6 +222,7 @@ func TestScanStream_InfectedFile(t *testing.T) { assert.NotEmpty(t, result.Threats) assert.Contains(t, strings.ToUpper(result.Threats[0]), "EICAR") } + func TestScanStream_EmptyFile(t *testing.T) { // t.Parallel() diff --git a/internal/infrastructure/exif/extension_provider.go b/internal/infrastructure/exif/extension_provider.go index 48fb160..33445a6 100644 --- a/internal/infrastructure/exif/extension_provider.go +++ b/internal/infrastructure/exif/extension_provider.go @@ -1,9 +1,10 @@ package exif import ( - "nos3/internal/domain/repository/cli_executer" "strings" "sync" + + "nos3/internal/domain/repository/cli_executer" ) type ExtensionProvider struct { diff --git a/internal/infrastructure/exif/file_validator.go b/internal/infrastructure/exif/file_validator.go index f0868e6..45eac60 100644 --- a/internal/infrastructure/exif/file_validator.go +++ b/internal/infrastructure/exif/file_validator.go @@ -1,9 +1,10 @@ package exif import ( - "nos3/internal/domain/repository/exif" "path/filepath" "strings" + + "nos3/internal/domain/repository/exif" ) type FileValidator struct { diff --git a/internal/infrastructure/exif/processor.go b/internal/infrastructure/exif/processor.go index a8a2bff..0798653 100644 --- a/internal/infrastructure/exif/processor.go +++ b/internal/infrastructure/exif/processor.go @@ -2,9 +2,10 @@ package exif import ( "context" - "nos3/internal/domain/repository/exif" "time" + "nos3/internal/domain/repository/exif" + "github.com/barasher/go-exiftool" grpcRepository "nos3/internal/domain/repository/grpcclient" @@ -22,7 +23,8 @@ type Processor struct { // Returns an error if ExifTool initialization fails. func NewProcessor(exiftoolCmd string, timeout time.Duration, - grpcClient grpcRepository.IClient) (exif.ExifProcessor, error) { + grpcClient grpcRepository.IClient, +) (exif.ExifProcessor, error) { logger.Info("initializing exiftool for EXIF processing") var et *exiftool.Exiftool diff --git a/internal/infrastructure/exif/processor_test.go b/internal/infrastructure/exif/processor_test.go index ac5fa73..8da15cd 100644 --- a/internal/infrastructure/exif/processor_test.go +++ b/internal/infrastructure/exif/processor_test.go @@ -60,7 +60,7 @@ func createTestImageWithExif(t *testing.T, filePath string) { 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0x37, 0xFF, 0xD9, } - err := os.WriteFile(filePath, jpegData, 0644) + err := os.WriteFile(filePath, jpegData, 0o644) require.NoError(t, err) cmd := exec.Command("exiftool", "-overwrite_original", @@ -88,7 +88,7 @@ func createTestPNGWithExif(t *testing.T, filePath string) { 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, } - err := os.WriteFile(filePath, pngData, 0644) + err := os.WriteFile(filePath, pngData, 0o644) require.NoError(t, err) cmd := exec.Command("exiftool", "-overwrite_original", diff --git a/internal/infrastructure/exif/remover.go b/internal/infrastructure/exif/remover.go index 937fa0c..a65b809 100644 --- a/internal/infrastructure/exif/remover.go +++ b/internal/infrastructure/exif/remover.go @@ -2,9 +2,10 @@ package exif import ( "context" - "nos3/internal/domain/repository/exif" "time" + "nos3/internal/domain/repository/exif" + grpcRepository "nos3/internal/domain/repository/grpcclient" "nos3/pkg/logger" ) From 5077dbd1151e31409ff24923f66f10d95d3b98c7 Mon Sep 17 00:00:00 2001 From: sadq Date: Tue, 25 Nov 2025 19:13:33 +0330 Subject: [PATCH 21/21] chore(lint): resolve golangci-lint issues --- .../command_executor.go | 2 +- .../domain/repository/exif/exif_processor.go | 4 +- .../repository/exif/extension_provider.go | 2 +- .../domain/repository/exif/file_validator.go | 2 +- .../system_command_executor.go | 2 +- internal/infrastructure/exif/errors.go | 10 ++--- .../infrastructure/exif/extension_provider.go | 9 +++-- .../infrastructure/exif/file_validator.go | 6 +-- .../exif/file_validator_test.go | 8 ++-- internal/infrastructure/exif/processor.go | 13 ++++--- .../infrastructure/exif/processor_test.go | 23 +++++------ internal/infrastructure/exif/remover.go | 9 +++-- internal/infrastructure/exif/remover_test.go | 38 +++++++++---------- .../exif/testing_helpers_test.go | 24 ++++++++---- 14 files changed, 83 insertions(+), 69 deletions(-) rename internal/domain/repository/{cli_executer => cliexecuter}/command_executor.go (80%) rename internal/infrastructure/{cli_executer => cliexecuter}/system_command_executor.go (93%) diff --git a/internal/domain/repository/cli_executer/command_executor.go b/internal/domain/repository/cliexecuter/command_executor.go similarity index 80% rename from internal/domain/repository/cli_executer/command_executor.go rename to internal/domain/repository/cliexecuter/command_executor.go index 07414be..6b7e4f2 100644 --- a/internal/domain/repository/cli_executer/command_executor.go +++ b/internal/domain/repository/cliexecuter/command_executor.go @@ -1,4 +1,4 @@ -package cli_executer +package cliexecuter type CommandExecutor interface { Execute(cmd string, args ...string) ([]byte, error) diff --git a/internal/domain/repository/exif/exif_processor.go b/internal/domain/repository/exif/exif_processor.go index 0b17b9f..5b60e7a 100644 --- a/internal/domain/repository/exif/exif_processor.go +++ b/internal/domain/repository/exif/exif_processor.go @@ -2,8 +2,8 @@ package exif import "context" -// ExifProcessor handles EXIF removal operations -type ExifProcessor interface { +// ExifProcessor handles EXIF removal operations. +type Processor interface { RemoveExifData(ctx context.Context, filePath string) error Close() error } diff --git a/internal/domain/repository/exif/extension_provider.go b/internal/domain/repository/exif/extension_provider.go index ba165ee..6e6a504 100644 --- a/internal/domain/repository/exif/extension_provider.go +++ b/internal/domain/repository/exif/extension_provider.go @@ -1,6 +1,6 @@ package exif -// ExtensionProvider provides supported file extensions +// ExtensionProvider provides supported file extensions. type ExtensionProvider interface { GetSupportedExtensions() (map[string]bool, error) } diff --git a/internal/domain/repository/exif/file_validator.go b/internal/domain/repository/exif/file_validator.go index b9c9ddd..7198e15 100644 --- a/internal/domain/repository/exif/file_validator.go +++ b/internal/domain/repository/exif/file_validator.go @@ -1,6 +1,6 @@ package exif -// FileValidator validates file types +// FileValidator validates file types. type FileValidator interface { ValidateFileType(filePath string) error } diff --git a/internal/infrastructure/cli_executer/system_command_executor.go b/internal/infrastructure/cliexecuter/system_command_executor.go similarity index 93% rename from internal/infrastructure/cli_executer/system_command_executor.go rename to internal/infrastructure/cliexecuter/system_command_executor.go index b6b6565..6a7600d 100644 --- a/internal/infrastructure/cli_executer/system_command_executor.go +++ b/internal/infrastructure/cliexecuter/system_command_executor.go @@ -1,4 +1,4 @@ -package cli_executer +package cliexecuter import "os/exec" diff --git a/internal/infrastructure/exif/errors.go b/internal/infrastructure/exif/errors.go index cec2419..931e5d0 100644 --- a/internal/infrastructure/exif/errors.go +++ b/internal/infrastructure/exif/errors.go @@ -14,13 +14,13 @@ const ( ErrorCodeUnsupportedFileType ) -type ExifError struct { +type Error struct { Code ErrorCode Message string Details string } -func (e *ExifError) Error() string { +func (e *Error) Error() string { if e.Details != "" { return fmt.Sprintf("%s: %s", e.Message, e.Details) } @@ -28,14 +28,14 @@ func (e *ExifError) Error() string { return e.Message } -func (e *ExifError) IsTimeout() bool { +func (e *Error) IsTimeout() bool { return e.Code == ErrorCodeTimeout } -func (e *ExifError) IsUnsupportedFileType() bool { +func (e *Error) IsUnsupportedFileType() bool { return e.Code == ErrorCodeUnsupportedFileType } -func (e *ExifError) IsTempFileError() bool { +func (e *Error) IsTempFileError() bool { return e.Code == ErrorCodeTempFileCreationFailed } diff --git a/internal/infrastructure/exif/extension_provider.go b/internal/infrastructure/exif/extension_provider.go index 33445a6..37c3f98 100644 --- a/internal/infrastructure/exif/extension_provider.go +++ b/internal/infrastructure/exif/extension_provider.go @@ -4,21 +4,21 @@ import ( "strings" "sync" - "nos3/internal/domain/repository/cli_executer" + "nos3/internal/domain/repository/cliexecuter" ) type ExtensionProvider struct { exiftoolCmd string listFlag string - executor cli_executer.CommandExecutor + executor cliexecuter.CommandExecutor supportedExtensions map[string]bool initErr error once sync.Once } // NewExtensionProvider creates a new ExtensionProvider instance that can fetch -// and cache supported file extensions from ExifTool -func NewExtensionProvider(cfg ExtensionProviderConfig, executor cli_executer.CommandExecutor) *ExtensionProvider { +// and cache supported file extensions from ExifTool. +func NewExtensionProvider(cfg ExtensionProviderConfig, executor cliexecuter.CommandExecutor) *ExtensionProvider { return &ExtensionProvider{ exiftoolCmd: cfg.ExifToolCmd, listFlag: cfg.ExifToolListFlag, @@ -42,6 +42,7 @@ func (e *ExtensionProvider) GetSupportedExtensions() (map[string]bool, error) { for k, v := range e.supportedExtensions { result[k] = v } + return result, nil } diff --git a/internal/infrastructure/exif/file_validator.go b/internal/infrastructure/exif/file_validator.go index 45eac60..0708449 100644 --- a/internal/infrastructure/exif/file_validator.go +++ b/internal/infrastructure/exif/file_validator.go @@ -12,7 +12,7 @@ type FileValidator struct { } // NewFileValidator creates a new FileValidator instance that validates file types -// against ExifTool's supported extensions using the provided ExtensionProvider +// against ExifTool's supported extensions using the provided ExtensionProvider. func NewFileValidator(extensionProvider exif.ExtensionProvider) exif.FileValidator { return &FileValidator{ extensionProvider: extensionProvider, @@ -27,7 +27,7 @@ func (f *FileValidator) ValidateFileType(filePath string) error { supportedExtensions, err := f.extensionProvider.GetSupportedExtensions() if err != nil { - return &ExifError{ + return &Error{ Code: ErrorCodeExifToolNotFound, Message: "failed to get supported extensions", Details: err.Error(), @@ -35,7 +35,7 @@ func (f *FileValidator) ValidateFileType(filePath string) error { } if !supportedExtensions[ext] { - return &ExifError{ + return &Error{ Code: ErrorCodeUnsupportedFileType, Message: "file type does not support EXIF data", Details: "extension " + ext + " not supported by ExifTool", diff --git a/internal/infrastructure/exif/file_validator_test.go b/internal/infrastructure/exif/file_validator_test.go index 26eee07..7624340 100644 --- a/internal/infrastructure/exif/file_validator_test.go +++ b/internal/infrastructure/exif/file_validator_test.go @@ -117,7 +117,7 @@ func TestValidateFileType_UnsupportedFileTypes(t *testing.T) { require.Error(t, err) - var exifErr *ExifError + var exifErr *Error require.True(t, errors.As(err, &exifErr)) assert.Equal(t, ErrorCodeUnsupportedFileType, exifErr.Code) assert.Contains(t, exifErr.Message, "file type does not support EXIF data") @@ -141,7 +141,7 @@ func TestValidateFileType_ExtensionProviderError(t *testing.T) { require.Error(t, err) - var exifErr *ExifError + var exifErr *Error require.True(t, errors.As(err, &exifErr)) assert.Equal(t, ErrorCodeExifToolNotFound, exifErr.Code) assert.Equal(t, "failed to get supported extensions", exifErr.Message) @@ -163,7 +163,7 @@ func TestValidateFileType_EmptySupportedExtensions(t *testing.T) { require.Error(t, err) - var exifErr *ExifError + var exifErr *Error require.True(t, errors.As(err, &exifErr)) assert.Equal(t, ErrorCodeUnsupportedFileType, exifErr.Code) @@ -236,7 +236,7 @@ func TestValidateFileType_EdgeCases(t *testing.T) { if tt.expectedError { require.Error(t, err) if tt.expectedExt != "" { - var exifErr *ExifError + var exifErr *Error require.True(t, errors.As(err, &exifErr)) assert.Equal(t, ErrorCodeUnsupportedFileType, exifErr.Code) } diff --git a/internal/infrastructure/exif/processor.go b/internal/infrastructure/exif/processor.go index 0798653..2b73530 100644 --- a/internal/infrastructure/exif/processor.go +++ b/internal/infrastructure/exif/processor.go @@ -24,7 +24,7 @@ type Processor struct { func NewProcessor(exiftoolCmd string, timeout time.Duration, grpcClient grpcRepository.IClient, -) (exif.ExifProcessor, error) { +) (exif.Processor, error) { logger.Info("initializing exiftool for EXIF processing") var et *exiftool.Exiftool @@ -39,7 +39,7 @@ func NewProcessor(exiftoolCmd string, if err != nil { logError(context.Background(), grpcClient, "failed to initialize exiftool", err.Error()) - return nil, &ExifError{ + return nil, &Error{ Code: ErrorCodeExifToolNotFound, Message: "failed to initialize exiftool", Details: err.Error(), @@ -71,11 +71,12 @@ func (e *Processor) RemoveExifData(ctx context.Context, filePath string) error { e.exiftool.WriteMetadata([]exiftool.FileMetadata{fileMetadata}) if fileMetadata.Err != nil { - done <- &ExifError{ + done <- &Error{ Code: ErrorCodeExifRemovalFailed, Message: "failed to remove EXIF data", Details: fileMetadata.Err.Error(), } + return } @@ -87,9 +88,10 @@ func (e *Processor) RemoveExifData(ctx context.Context, filePath string) error { if err != nil { logError(ctx, e.grpcClient, "EXIF removal failed", err.Error()) } + return err case <-ctx.Done(): - return &ExifError{ + return &Error{ Code: ErrorCodeTimeout, Message: "EXIF removal timeout", Details: "operation took too long", @@ -103,10 +105,11 @@ func (e *Processor) Close() error { if e.exiftool != nil { return e.exiftool.Close() } + return nil } -// logError is a standalone function for logging errors to both local logger and gRPC client +// logError is a standalone function for logging errors to both local logger and gRPC client. func logError(ctx context.Context, grpcClient grpcRepository.IClient, message, details string) { logger.Error(message, "details", details) if _, logErr := grpcClient.AddLog(ctx, message, details); logErr != nil { diff --git a/internal/infrastructure/exif/processor_test.go b/internal/infrastructure/exif/processor_test.go index 8da15cd..72e40f7 100644 --- a/internal/infrastructure/exif/processor_test.go +++ b/internal/infrastructure/exif/processor_test.go @@ -2,7 +2,6 @@ package exif import ( "context" - "fmt" "os" "os/exec" "path/filepath" @@ -31,6 +30,7 @@ func setupTestDir(t *testing.T) string { t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return dir } @@ -60,13 +60,13 @@ func createTestImageWithExif(t *testing.T, filePath string) { 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0x37, 0xFF, 0xD9, } - err := os.WriteFile(filePath, jpegData, 0o644) + err := os.WriteFile(filePath, jpegData, 0o600) require.NoError(t, err) cmd := exec.Command("exiftool", "-overwrite_original", - fmt.Sprintf("-Comment=%s", TestExifComment), - fmt.Sprintf("-Artist=%s", TestExifArtist), - fmt.Sprintf("-Copyright=%s", TestExifCopyright), + "-Comment="+TestExifComment, + "-Artist="+TestExifArtist, + "-Copyright="+TestExifCopyright, "-Make=TestCamera", "-Model=TestModel", "-Software=TestSoftware", @@ -88,12 +88,12 @@ func createTestPNGWithExif(t *testing.T, filePath string) { 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, } - err := os.WriteFile(filePath, pngData, 0o644) + err := os.WriteFile(filePath, pngData, 0o600) require.NoError(t, err) cmd := exec.Command("exiftool", "-overwrite_original", - fmt.Sprintf("-Comment=%s", TestExifComment), - fmt.Sprintf("-Artist=%s", TestExifArtist), + "-Comment="+TestExifComment, + "-Artist="+TestExifArtist, filePath, ) output, err := cmd.CombinedOutput() @@ -108,6 +108,7 @@ func hasExifData(t *testing.T, filePath string) bool { return false } outputStr := string(output) + return strings.Contains(outputStr, TestExifComment) || strings.Contains(outputStr, TestExifArtist) || strings.Contains(outputStr, TestExifCopyright) || @@ -173,7 +174,7 @@ func TestProcessor_RemoveExifData_NonExistentFile(t *testing.T) { err = processor.RemoveExifData(context.Background(), nonExistentFile) if err != nil { - var exifErr *ExifError + var exifErr *Error if assert.ErrorAs(t, err, &exifErr) { assert.Equal(t, ErrorCodeExifRemovalFailed, exifErr.Code) } @@ -198,7 +199,7 @@ func TestProcessor_RemoveExifData_ContextCancellation(t *testing.T) { err = processor.RemoveExifData(ctx, testFile) assert.Error(t, err) - var exifErr *ExifError + var exifErr *Error if assert.ErrorAs(t, err, &exifErr) { assert.Equal(t, ErrorCodeTimeout, exifErr.Code) assert.True(t, exifErr.IsTimeout()) @@ -224,7 +225,7 @@ func TestProcessor_NewProcessor_InvalidExifToolPath(t *testing.T) { assert.Error(t, err) assert.Nil(t, processor) - var exifErr *ExifError + var exifErr *Error if assert.ErrorAs(t, err, &exifErr) { assert.Equal(t, ErrorCodeExifToolNotFound, exifErr.Code) } diff --git a/internal/infrastructure/exif/remover.go b/internal/infrastructure/exif/remover.go index a65b809..af31392 100644 --- a/internal/infrastructure/exif/remover.go +++ b/internal/infrastructure/exif/remover.go @@ -2,6 +2,7 @@ package exif import ( "context" + "errors" "time" "nos3/internal/domain/repository/exif" @@ -12,7 +13,7 @@ import ( type Remover struct { fileValidator exif.FileValidator - exifProcessor exif.ExifProcessor + exifProcessor exif.Processor timeout time.Duration grpcClient grpcRepository.IClient } @@ -22,7 +23,7 @@ type Remover struct { // This is the main entry point for EXIF removal operations. func NewRemover( fileValidator exif.FileValidator, - exifProcessor exif.ExifProcessor, + exifProcessor exif.Processor, timeout time.Duration, grpcClient grpcRepository.IClient, ) *Remover { @@ -45,9 +46,11 @@ func (r *Remover) RemoveExifFromFile(ctx context.Context, filePath string) error defer cancel() if err := r.fileValidator.ValidateFileType(filePath); err != nil { - if exifErr, ok := err.(*ExifError); ok && exifErr.Code == ErrorCodeUnsupportedFileType { + var exifErr *Error + if errors.As(err, &exifErr) && exifErr.Code == ErrorCodeUnsupportedFileType { return nil } + return err } diff --git a/internal/infrastructure/exif/remover_test.go b/internal/infrastructure/exif/remover_test.go index 89ddb39..0afb8e9 100644 --- a/internal/infrastructure/exif/remover_test.go +++ b/internal/infrastructure/exif/remover_test.go @@ -19,7 +19,7 @@ func TestNewRemover(t *testing.T) { t.Parallel() mockValidator := &MockFileValidator{} - mockProcessor := &MockExifProcessor{} + mockProcessor := &MockProcessor{} mockGRPC := &MockGRPC{} remover := NewRemover(mockValidator, mockProcessor, RemoverTimeout, mockGRPC) @@ -35,7 +35,7 @@ func TestRemoveExifFromFile_Success(t *testing.T) { t.Parallel() mockValidator := &MockFileValidator{} - mockProcessor := &MockExifProcessor{} + mockProcessor := &MockProcessor{} mockGRPC := &MockGRPC{} mockValidator.On("ValidateFileType", TestFilePath).Return(nil) @@ -54,10 +54,10 @@ func TestRemoveExifFromFile_UnsupportedFileType(t *testing.T) { t.Parallel() mockValidator := &MockFileValidator{} - mockProcessor := &MockExifProcessor{} + mockProcessor := &MockProcessor{} mockGRPC := &MockGRPC{} - unsupportedErr := &ExifError{ + unsupportedErr := &Error{ Code: ErrorCodeUnsupportedFileType, Message: "file type does not support EXIF data", Details: ".txt not supported", @@ -78,10 +78,10 @@ func TestRemoveExifFromFile_ValidationError(t *testing.T) { t.Parallel() mockValidator := &MockFileValidator{} - mockProcessor := &MockExifProcessor{} + mockProcessor := &MockProcessor{} mockGRPC := &MockGRPC{} - validationErr := &ExifError{ + validationErr := &Error{ Code: ErrorCodeExifToolNotFound, Message: "failed to get supported extensions", Details: "exiftool not found", @@ -103,10 +103,10 @@ func TestRemoveExifFromFile_ProcessorError(t *testing.T) { t.Parallel() mockValidator := &MockFileValidator{} - mockProcessor := &MockExifProcessor{} + mockProcessor := &MockProcessor{} mockGRPC := &MockGRPC{} - processorErr := &ExifError{ + processorErr := &Error{ Code: ErrorCodeExifRemovalFailed, Message: "failed to remove EXIF data", Details: "write error", @@ -129,12 +129,12 @@ func TestRemoveExifFromFile_ContextCancellation(t *testing.T) { t.Parallel() mockValidator := &MockFileValidator{} - mockProcessor := &MockExifProcessor{} + mockProcessor := &MockProcessor{} mockGRPC := &MockGRPC{} mockValidator.On("ValidateFileType", TestFilePath).Return(nil) mockProcessor.On("RemoveExifData", mock.Anything, TestFilePath). - Return(&ExifError{ + Return(&Error{ Code: ErrorCodeTimeout, Message: "context cancelled", Details: "operation cancelled", @@ -172,14 +172,14 @@ func TestRemoveExifFromFile_MultipleFiles(t *testing.T) { { name: "unsupported file", filePath: "/docs/readme.txt", - validateErr: &ExifError{Code: ErrorCodeUnsupportedFileType}, + validateErr: &Error{Code: ErrorCodeUnsupportedFileType}, processErr: nil, expectError: false, }, { name: "validation fails", filePath: "/images/photo2.jpg", - validateErr: &ExifError{Code: ErrorCodeExifToolNotFound}, + validateErr: &Error{Code: ErrorCodeExifToolNotFound}, processErr: nil, expectError: true, expectedCode: ErrorCodeExifToolNotFound, @@ -188,7 +188,7 @@ func TestRemoveExifFromFile_MultipleFiles(t *testing.T) { name: "processing fails", filePath: "/images/photo3.jpg", validateErr: nil, - processErr: &ExifError{Code: ErrorCodeExifRemovalFailed}, + processErr: &Error{Code: ErrorCodeExifRemovalFailed}, expectError: true, expectedCode: ErrorCodeExifRemovalFailed, }, @@ -199,15 +199,13 @@ func TestRemoveExifFromFile_MultipleFiles(t *testing.T) { t.Parallel() mockValidator := &MockFileValidator{} - mockProcessor := &MockExifProcessor{} + mockProcessor := &MockProcessor{} mockGRPC := &MockGRPC{} mockValidator.On("ValidateFileType", tt.filePath).Return(tt.validateErr) - if tt.validateErr == nil || tt.validateErr.(*ExifError).Code != ErrorCodeUnsupportedFileType { - if tt.validateErr == nil { - mockProcessor.On("RemoveExifData", mock.Anything, tt.filePath).Return(tt.processErr) - } + if tt.validateErr == nil { + mockProcessor.On("RemoveExifData", mock.Anything, tt.filePath).Return(tt.processErr) } remover := NewRemover(mockValidator, mockProcessor, RemoverTimeout, mockGRPC) @@ -216,7 +214,7 @@ func TestRemoveExifFromFile_MultipleFiles(t *testing.T) { if tt.expectError { require.Error(t, err) - var exifErr *ExifError + var exifErr *Error require.True(t, errors.As(err, &exifErr)) assert.Equal(t, tt.expectedCode, exifErr.Code) } else { @@ -254,7 +252,7 @@ func TestClose(t *testing.T) { t.Parallel() mockValidator := &MockFileValidator{} - mockProcessor := &MockExifProcessor{} + mockProcessor := &MockProcessor{} mockGRPC := &MockGRPC{} mockProcessor.On("Close").Return(tt.closeErr) diff --git a/internal/infrastructure/exif/testing_helpers_test.go b/internal/infrastructure/exif/testing_helpers_test.go index 4f44312..05b4fd1 100644 --- a/internal/infrastructure/exif/testing_helpers_test.go +++ b/internal/infrastructure/exif/testing_helpers_test.go @@ -8,18 +8,20 @@ import ( "nos3/internal/infrastructure/grpcclient/gen" ) -// MockGRPC is a shared mock implementation of the gRPC client interface +// MockGRPC is a shared mock implementation of the gRPC client interface. type MockGRPC struct { mock.Mock } func (m *MockGRPC) RegisterService(_ context.Context, _, _ string) (*gen.RegisterServiceResponse, error) { args := m.Called() + return args.Get(0).(*gen.RegisterServiceResponse), args.Error(1) } func (m *MockGRPC) AddLog(_ context.Context, msg, stack string) (*gen.AddLogResponse, error) { args := m.Called(msg, stack) + return args.Get(0).(*gen.AddLogResponse), args.Error(1) } @@ -27,10 +29,11 @@ func (m *MockGRPC) AddReport(_ context.Context, _ string, _ []string, _, _, _, _ *gen.AddReportResponse, error, ) { args := m.Called() + return args.Get(0).(*gen.AddReportResponse), args.Error(1) } -// MockCommandExecutor is a mock for command execution +// MockCommandExecutor is a mock for command execution. type MockCommandExecutor struct { mock.Mock } @@ -40,10 +43,11 @@ func (m *MockCommandExecutor) Execute(cmd string, args ...string) ([]byte, error if argsList.Get(0) == nil { return nil, argsList.Error(1) } + return argsList.Get(0).([]byte), argsList.Error(1) } -// MockExtensionProvider is a mock for extension provider +// MockExtensionProvider is a mock for extension provider. type MockExtensionProvider struct { mock.Mock } @@ -53,31 +57,35 @@ func (m *MockExtensionProvider) GetSupportedExtensions() (map[string]bool, error if args.Get(0) == nil { return nil, args.Error(1) } + return args.Get(0).(map[string]bool), args.Error(1) } -// MockExifProcessor is a mock for exif processor -type MockExifProcessor struct { +// MockProcessor is a mock for exif processor. +type MockProcessor struct { mock.Mock } -func (m *MockExifProcessor) RemoveExifData(ctx context.Context, filePath string) error { +func (m *MockProcessor) RemoveExifData(ctx context.Context, filePath string) error { args := m.Called(ctx, filePath) + return args.Error(0) } -func (m *MockExifProcessor) Close() error { +func (m *MockProcessor) Close() error { args := m.Called() + return args.Error(0) } -// MockFileValidator is a mock for file validator +// MockFileValidator is a mock for file validator. type MockFileValidator struct { mock.Mock } func (m *MockFileValidator) ValidateFileType(filePath string) error { args := m.Called(filePath) + return args.Error(0) }