using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Application.Assets; using Tiku.Application.Jobs; using Tiku.Application.Storage; using Tiku.Domain.Content; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; public sealed class P0AssetSecurityScanTests { [Theory] [InlineData(AssetSecurityScanVerdict.Clean, AssetSecurityScanStatus.Passed, AssetSecurityRiskLevel.None)] [InlineData(AssetSecurityScanVerdict.Infected, AssetSecurityScanStatus.Failed, AssetSecurityRiskLevel.Critical)] public async Task Asset_scan_persists_terminal_verdict_and_audit_event( AssetSecurityScanVerdict verdict, AssetSecurityScanStatus expectedStatus, AssetSecurityRiskLevel expectedRisk) { var scanner = new FakeScanner(new AssetSecurityScanResult( verdict, "clamav", verdict == AssetSecurityScanVerdict.Infected ? "Eicar-Signature" : null, 12, verdict == AssetSecurityScanVerdict.Infected ? "stream: Eicar-Signature FOUND" : "stream: OK")); await using var factory = new ApiTestFactory( objectStorageService: new ReadableStorage(), assetSecurityScanner: scanner); var (tenantId, assetId, jobId) = await SeedScanAsync(factory); using (var scope = factory.CreateSystemScope("Process asset security scan")) { Assert.Equal(1, await scope.ServiceProvider.GetRequiredService() .ProcessPendingAsync("asset-scan-test", 10)); } using var verifyScope = factory.CreateSystemScope("Verify asset security scan"); var dbContext = verifyScope.ServiceProvider.GetRequiredService(); var asset = await dbContext.ContentAssets.AsNoTracking().SingleAsync(item => item.Id == assetId); var job = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == jobId); var scanEvent = await dbContext.ContentAssetSecurityScanEvents.AsNoTracking() .SingleAsync(item => item.TenantId == tenantId && item.AssetId == assetId); Assert.Equal(expectedStatus, asset.SecurityScanStatus); Assert.Equal("clamav", asset.SecurityScanProvider); Assert.NotNull(asset.SecurityScannedAt); Assert.Equal(BackgroundJobStatus.Succeeded, job.Status); Assert.Equal(expectedStatus, scanEvent.ScanStatus); Assert.Equal(expectedRisk, scanEvent.RiskLevel); if (verdict == AssetSecurityScanVerdict.Infected) Assert.Contains("Eicar-Signature", scanEvent.IssueCodes); else Assert.Empty(scanEvent.IssueCodes); } [Fact] public async Task Unavailable_scanner_keeps_asset_pending_and_schedules_job_retry() { await using var factory = new ApiTestFactory( objectStorageService: new ReadableStorage(), assetSecurityScanner: new FakeScanner(new AssetSecurityScannerException( "clamav_unavailable", "ClamAV is unavailable."))); var (tenantId, assetId, jobId) = await SeedScanAsync(factory); using (var scope = factory.CreateSystemScope("Process unavailable asset security scan")) { Assert.Equal(1, await scope.ServiceProvider.GetRequiredService() .ProcessPendingAsync("asset-scan-test", 10)); } using var verifyScope = factory.CreateSystemScope("Verify asset security scan retry"); var dbContext = verifyScope.ServiceProvider.GetRequiredService(); var asset = await dbContext.ContentAssets.AsNoTracking().SingleAsync(item => item.Id == assetId); var job = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == jobId); var scanEvent = await dbContext.ContentAssetSecurityScanEvents.AsNoTracking() .SingleAsync(item => item.TenantId == tenantId && item.AssetId == assetId); Assert.Equal(AssetSecurityScanStatus.Pending, asset.SecurityScanStatus); Assert.Equal(BackgroundJobStatus.Pending, job.Status); Assert.Equal(1, job.RetryCount); Assert.NotNull(job.RunAfter); Assert.Contains("clamav_unavailable", job.LastError); Assert.Equal(AssetSecurityScanStatus.Pending, scanEvent.ScanStatus); Assert.Contains("clamav_unavailable", scanEvent.IssueCodes); } private static async Task<(Guid TenantId, Guid AssetId, Guid JobId)> SeedScanAsync(ApiTestFactory factory) { var tenantId = Guid.NewGuid(); var assetId = Guid.NewGuid(); await factory.SeedAsync( new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Asset scan tenant", Status = TenantStatus.Active }, new ContentAsset { Id = assetId, TenantId = tenantId, Title = "Scannable asset", FileName = "scan.txt", StorageProvider = AssetStorageProvider.LocalDev, Bucket = "tenant-assets", ObjectKey = $"{tenantId:N}/assets/scan.txt", MimeType = "text/plain", FileSizeBytes = 12, VerifiedSizeBytes = 12, UploadStatus = AssetUploadStatus.Verified, SecurityScanStatus = AssetSecurityScanStatus.Pending }); using var scope = factory.CreateSystemScope("Queue asset security scan"); var job = await scope.ServiceProvider.GetRequiredService().EnqueueAsync( new CreateBackgroundJobCommand( tenantId, "asset_security_scan", JsonSerializer.SerializeToElement(new { assetId }), MaxRetries: 5, IdempotencyKey: $"asset:{assetId:N}:integration-test", IsSystemJob: true)); return (tenantId, assetId, job.Id); } private sealed class FakeScanner(object outcome) : IAssetSecurityScanner { public Task ScanAsync( Stream content, long? declaredLength, CancellationToken cancellationToken = default) { return outcome switch { AssetSecurityScanResult result => Task.FromResult(result), Exception exception => Task.FromException(exception), _ => throw new InvalidOperationException("Unsupported scanner outcome.") }; } public Task CheckHealthAsync(CancellationToken cancellationToken = default) { return Task.FromResult(outcome is AssetSecurityScanResult); } } private sealed class ReadableStorage : IObjectStorageService { public string ConfiguredDefaultProvider() { return ObjectStorageProviders.LocalDev; } public string ConfiguredDefaultBucket() { return "tenant-assets"; } public string NormalizeProvider(string? value, string? fallback = null) { return value ?? fallback ?? ObjectStorageProviders.LocalDev; } public string ValidateObjectKey(Guid tenantId, string objectKey) { return objectKey; } public string ValidateMimeType(string mimeType) { return mimeType; } public long? ValidateFileSize(long? fileSizeBytes) { return fileSizeBytes; } public void AssertUploadProvider(string provider) { } public void AssertWritableLocation(StorageAssetLocation location) { } public Task SignUploadAsync(ObjectStorageUploadSignRequest request, CancellationToken cancellationToken = default) { throw new NotSupportedException(); } public Task SignDownloadAsync(ObjectStorageDownloadSignRequest request, CancellationToken cancellationToken = default) { throw new NotSupportedException(); } public Task WriteObjectAsync(ObjectStorageWriteRequest request, CancellationToken cancellationToken = default) { throw new NotSupportedException(); } public Task HeadObjectAsync(ObjectStorageHeadRequest request, CancellationToken cancellationToken = default) { throw new NotSupportedException(); } public Task OpenReadAsync(ObjectStorageReadRequest request, CancellationToken cancellationToken = default) { return Task.FromResult(new MemoryStream(Encoding.UTF8.GetBytes("hello world!"))); } } }