Files
tiku-backend.net/Tiku.IntegrationTests/Api/P0AssetSecurityScanTests.cs
xiong c497a3ca8d
Some checks failed
ci / release-gate (push) Has been cancelled
清理代码
2026-08-03 12:31:39 +08:00

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!")));
}
}
}