224 lines
8.9 KiB
C#
224 lines
8.9 KiB
C#
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<IBackgroundJobService>()
|
|
.ProcessPendingAsync("asset-scan-test", 10));
|
|
}
|
|
|
|
using var verifyScope = factory.CreateSystemScope("Verify asset security scan");
|
|
var dbContext = verifyScope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
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<IBackgroundJobService>()
|
|
.ProcessPendingAsync("asset-scan-test", 10));
|
|
}
|
|
|
|
using var verifyScope = factory.CreateSystemScope("Verify asset security scan retry");
|
|
var dbContext = verifyScope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
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<IBackgroundJobService>().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<AssetSecurityScanResult> ScanAsync(
|
|
Stream content,
|
|
long? declaredLength,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return outcome switch
|
|
{
|
|
AssetSecurityScanResult result => Task.FromResult(result),
|
|
Exception exception => Task.FromException<AssetSecurityScanResult>(exception),
|
|
_ => throw new InvalidOperationException("Unsupported scanner outcome.")
|
|
};
|
|
}
|
|
|
|
public Task<bool> 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<ObjectStorageSignedUrl> SignUploadAsync(ObjectStorageUploadSignRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
public Task<ObjectStorageSignedUrl> SignDownloadAsync(ObjectStorageDownloadSignRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
public Task<ObjectStorageWriteResult> WriteObjectAsync(ObjectStorageWriteRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
public Task<ObjectStorageMetadata> HeadObjectAsync(ObjectStorageHeadRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
public Task<Stream> OpenReadAsync(ObjectStorageReadRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return Task.FromResult<Stream>(new MemoryStream(Encoding.UTF8.GetBytes("hello world!")));
|
|
}
|
|
}
|
|
} |