Files
tiku-backend.net/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs

575 lines
24 KiB
C#

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Api.Contracts;
using Tiku.Application.Security;
using Tiku.Application.Storage;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Identity;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
public sealed class AssetManagementEndpointTests
{
[Fact]
public async Task Tenant_content_upload_sign_requires_authentication()
{
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
using var client = factory.CreateClient();
using var response = await client.PostAsJsonAsync(
"/api/tenant-content/assets/uploads/sign",
new AssetUploadSignDto
{
FileName = "lesson.pdf",
MimeType = "application/pdf"
});
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Tenant_admin_can_sign_upload_and_create_pending_asset()
{
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
var seed = await SeedAdminAsync(factory);
using var client = factory.CreateClient();
await LoginAsync(client, seed);
using var response = await client.PostAsJsonAsync(
"/api/tenant-content/assets/uploads/sign",
new AssetUploadSignDto
{
FileName = "lesson.pdf",
MimeType = "application/pdf",
FileSizeBytes = 1024,
Title = "课程讲义",
AssetType = "pdf",
Visibility = "members"
});
var body = await ReadJsonAsync(response);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("PUT", body.RootElement.GetProperty("upload").GetProperty("method").GetString());
var item = body.RootElement.GetProperty("item");
var assetId = item.GetProperty("id").GetGuid();
Assert.Equal("Pending", item.GetProperty("uploadStatus").GetString());
Assert.Equal("AliyunOss", item.GetProperty("storageProvider").GetString());
Assert.StartsWith($"{seed.TenantId:N}/assets/", item.GetProperty("objectKey").GetString(), StringComparison.Ordinal);
using var listResponse = await client.GetAsync("/api/tenant-content/assets?uploadStatus=pending");
var list = await ReadJsonAsync(listResponse);
var listItem = Assert.Single(list.RootElement.GetProperty("items").EnumerateArray());
Assert.Equal(assetId, listItem.GetProperty("id").GetGuid());
using var scope = factory.CreateSystemScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var asset = dbContext.ContentAssets.Single(asset => asset.Id == assetId);
Assert.Equal(seed.TenantId, asset.TenantId);
Assert.Equal(seed.UserId, asset.CreatedBy);
Assert.Equal(AssetUploadStatus.Pending, asset.UploadStatus);
Assert.Equal(AssetSecurityScanStatus.Pending, asset.SecurityScanStatus);
}
[Fact]
public async Task Tenant_admin_can_confirm_upload_and_mark_asset_verified()
{
var storage = new FakeObjectStorageService
{
MetadataSizeBytes = 2048,
MetadataChecksumSha256 = new string('a', 64)
};
await using var factory = new ApiTestFactory(objectStorageService: storage);
var seed = await SeedAdminAsync(factory);
var assetId = Guid.NewGuid();
await factory.SeedAsync(new ContentAsset
{
Id = assetId,
TenantId = seed.TenantId,
Title = "课程讲义",
FileName = "lesson.pdf",
StorageProvider = AssetStorageProvider.AliyunOss,
Bucket = "tenant-assets",
ObjectKey = $"{seed.TenantId:N}/assets/lesson.pdf",
MimeType = "application/pdf",
AssetType = ContentAssetType.Pdf,
UploadStatus = AssetUploadStatus.Pending,
SecurityScanStatus = AssetSecurityScanStatus.Pending
});
using var client = factory.CreateClient();
await LoginAsync(client, seed);
using var response = await client.PostAsJsonAsync(
"/api/tenant-content/assets/uploads/confirm",
new AssetUploadConfirmDto
{
AssetId = assetId,
MimeType = "application/pdf",
FileSizeBytes = 2048,
ChecksumSha256 = new string('a', 64)
});
var body = await ReadJsonAsync(response);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("Verified", body.RootElement.GetProperty("item").GetProperty("uploadStatus").GetString());
Assert.Equal(2048, body.RootElement.GetProperty("metadata").GetProperty("sizeBytes").GetInt64());
using var scope = factory.CreateSystemScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var asset = dbContext.ContentAssets.Single(asset => asset.Id == assetId);
Assert.Equal(AssetUploadStatus.Verified, asset.UploadStatus);
Assert.Equal(seed.UserId, asset.VerifiedBy);
Assert.Equal(2048, asset.VerifiedSizeBytes);
Assert.Equal(new string('a', 64), asset.VerifiedChecksumSha256);
Assert.False(dbContext.TenantFeatureUsages.Any(value =>
value.TenantId == seed.TenantId && value.MetricCode == SaasQuotaMetricCatalog.StorageBytes));
}
[Fact]
public async Task Verified_upload_consumes_storage_quota_and_archive_releases_it()
{
var storage = new FakeObjectStorageService { MetadataSizeBytes = 6 };
await using var factory = new ApiTestFactory(objectStorageService: storage);
var seed = await SeedAdminWithStorageQuotaAsync(factory, 10);
var firstAssetId = Guid.NewGuid();
var secondAssetId = Guid.NewGuid();
await factory.SeedAsync(
PendingAsset(firstAssetId, seed.TenantId, "first.pdf"),
PendingAsset(secondAssetId, seed.TenantId, "second.pdf"));
using var client = factory.CreateClient();
await LoginAsync(client, seed);
using var firstConfirm = await client.PostAsJsonAsync(
"/api/tenant-content/assets/uploads/confirm",
new AssetUploadConfirmDto { AssetId = firstAssetId });
Assert.Equal(HttpStatusCode.OK, firstConfirm.StatusCode);
Assert.Equal(6, await StorageUsageAsync(factory, seed.TenantId));
storage.MetadataSizeBytes = 5;
using var exhausted = await client.PostAsJsonAsync(
"/api/tenant-content/assets/uploads/confirm",
new AssetUploadConfirmDto { AssetId = secondAssetId });
Assert.Equal(HttpStatusCode.Conflict, exhausted.StatusCode);
var exhaustedBody = await ReadJsonAsync(exhausted);
Assert.Equal("feature_quota_exhausted", exhaustedBody.RootElement.GetProperty("code").GetString());
Assert.Equal(6, await StorageUsageAsync(factory, seed.TenantId));
using var archive = await client.DeleteAsync($"/api/tenant-content/assets/{firstAssetId}");
Assert.Equal(HttpStatusCode.OK, archive.StatusCode);
Assert.Equal(0, await StorageUsageAsync(factory, seed.TenantId));
using var secondConfirm = await client.PostAsJsonAsync(
"/api/tenant-content/assets/uploads/confirm",
new AssetUploadConfirmDto { AssetId = secondAssetId });
Assert.Equal(HttpStatusCode.OK, secondConfirm.StatusCode);
Assert.Equal(5, await StorageUsageAsync(factory, seed.TenantId));
using var repeatedConfirm = await client.PostAsJsonAsync(
"/api/tenant-content/assets/uploads/confirm",
new AssetUploadConfirmDto { AssetId = secondAssetId });
Assert.Equal(HttpStatusCode.OK, repeatedConfirm.StatusCode);
Assert.Equal(5, await StorageUsageAsync(factory, seed.TenantId));
}
[Fact]
public async Task Import_jobs_are_scoped_to_current_tenant_and_include_detail()
{
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
var seed = await SeedAdminAsync(factory);
var otherTenantId = Guid.NewGuid();
var jobId = Guid.NewGuid();
var itemId = Guid.NewGuid();
await factory.SeedAsync(
new Tenant
{
Id = otherTenantId,
Slug = otherTenantId.ToString("N"),
Name = "Other Tenant",
Status = TenantStatus.Active,
Metadata = JsonDefaults.Object()
},
new ContentImportJob
{
Id = jobId,
TenantId = seed.TenantId,
SourceName = "questions.xlsx",
ImportType = ContentImportType.Questions,
SourceFormat = ImportSourceFormat.Excel,
Status = ContentImportStatus.CompletedWithErrors,
TotalCount = 1,
ErrorCount = 1
},
new ContentImportItem
{
Id = itemId,
TenantId = seed.TenantId,
JobId = jobId,
RowNo = 1,
Status = ContentImportItemStatus.Invalid,
IssuesCount = 1
},
new ContentImportIssue
{
Id = Guid.NewGuid(),
TenantId = seed.TenantId,
JobId = jobId,
ItemId = itemId,
RowNo = 1,
Severity = ImportIssueSeverity.Error,
Code = "missing_answer",
Message = "答案不能为空"
},
new ContentImportJob
{
Id = Guid.NewGuid(),
TenantId = otherTenantId,
SourceName = "other.xlsx"
});
using var client = factory.CreateClient();
await LoginAsync(client, seed);
using var listResponse = await client.GetAsync("/api/tenant-content/import-jobs?status=completedWithErrors");
using var detailResponse = await client.GetAsync($"/api/tenant-content/import-jobs/{jobId}");
var list = await ReadJsonAsync(listResponse);
var detail = await ReadJsonAsync(detailResponse);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
var listItem = Assert.Single(list.RootElement.GetProperty("items").EnumerateArray());
Assert.Equal(jobId, listItem.GetProperty("id").GetGuid());
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
Assert.Equal(jobId, detail.RootElement.GetProperty("job").GetProperty("id").GetGuid());
Assert.Single(detail.RootElement.GetProperty("items").EnumerateArray());
Assert.Single(detail.RootElement.GetProperty("issues").EnumerateArray());
}
[Fact]
public async Task Tenant_admin_can_upsert_asset_sign_access_and_query_asset_events()
{
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
var seed = await SeedAdminAsync(factory);
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var upsertResponse = await client.PutAsJsonAsync(
"/api/tenant-content/assets",
new UpsertAssetDto
{
Title = "管理侧资料",
FileName = "admin.pdf",
AssetType = "pdf",
Visibility = "members",
Provider = "local_dev",
Bucket = "tenant-assets",
ObjectKey = $"{seed.TenantId:N}/admin.pdf",
PreviewObjectKey = $"{seed.TenantId:N}/admin-preview.pdf",
MimeType = "application/pdf",
FileSizeBytes = 512
});
var upsert = await ReadJsonAsync(upsertResponse);
var assetId = upsert.RootElement.GetProperty("item").GetProperty("id").GetGuid();
await factory.SeedAsync(new ContentAssetSecurityScanEvent
{
TenantId = seed.TenantId,
AssetId = assetId,
Provider = "local",
ScanStatus = AssetSecurityScanStatus.Passed,
RiskLevel = AssetSecurityRiskLevel.None
});
var downloadResponse = await client.PostAsJsonAsync(
"/api/tenant-content/assets/sign-download",
new AssetAccessSignDto { AssetId = assetId, ExpiresInSeconds = 120 });
var previewResponse = await client.PostAsJsonAsync(
"/api/tenant-content/assets/sign-preview",
new AssetAccessSignDto { AssetId = assetId, ExpiresInSeconds = 120 });
var accessEventsResponse = await client.GetAsync($"/api/tenant-content/assets/access-events?assetId={assetId}");
var scanEventsResponse = await client.GetAsync($"/api/tenant-content/assets/security-scan-events?assetId={assetId}");
var accessEvents = await ReadJsonAsync(accessEventsResponse);
var scanEvents = await ReadJsonAsync(scanEventsResponse);
Assert.Equal(HttpStatusCode.OK, upsertResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, downloadResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode);
Assert.Equal(2, accessEvents.RootElement.GetProperty("items").GetArrayLength());
Assert.Single(scanEvents.RootElement.GetProperty("items").EnumerateArray());
}
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminAsync(ApiTestFactory factory)
{
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var phone = "13900000000";
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "Test Tenant",
Status = TenantStatus.Active,
Metadata = JsonDefaults.Object()
},
new User
{
Id = userId,
Phone = phone,
Name = "Tenant Admin"
}.WithTestPassword(),
new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = TenantRole.TenantAdmin,
Status = MembershipStatus.Active
});
return (tenantId, userId, phone);
}
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminWithStorageQuotaAsync(
ApiTestFactory factory,
long storageLimit)
{
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var phone = "13900000001";
var offeringId = Guid.NewGuid();
var versionId = Guid.NewGuid();
var subscriptionId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "Storage Quota Tenant",
Status = TenantStatus.Active,
BillingStatus = BillingStatus.Active
},
new User
{
Id = userId,
Phone = phone,
Name = "Storage Quota Admin"
}.WithTestPassword(),
new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = TenantRole.TenantAdmin,
Status = MembershipStatus.Active
},
new SaasFeature
{
Code = SaasFeatureCatalog.PrivateQuestionBank,
Name = "Private question bank",
Category = "content",
Status = SaasFeatureStatus.Active
},
new SaasOffering
{
Id = offeringId,
Code = $"storage-quota-{tenantId:N}",
Name = "Storage Quota Plan",
Type = SaasOfferingType.BasePlan,
Status = SaasOfferingStatus.Active
},
new SaasOfferingVersion
{
Id = versionId,
OfferingId = offeringId,
Version = 1,
Status = SaasOfferingVersionStatus.Draft,
BillingCycle = PlatformBillingCycle.Monthly,
OriginalAmountCents = 100,
AmountCents = 100,
EffectiveAt = now.AddDays(-1)
},
new SaasOfferingVersionFeature
{
OfferingVersionId = versionId,
FeatureCode = SaasFeatureCatalog.PrivateQuestionBank
},
new SaasFeatureLimitDefinition
{
MetricCode = SaasQuotaMetricCatalog.StorageBytes,
FeatureCode = SaasFeatureCatalog.PrivateQuestionBank,
Name = "Storage bytes",
Unit = "byte",
Kind = SaasFeatureLimitKind.Current,
WarningPercent = 80,
IsHardLimit = true
},
new SaasOfferingVersionLimit
{
OfferingVersionId = versionId,
MetricCode = SaasQuotaMetricCatalog.StorageBytes,
LimitValue = storageLimit
},
new TenantSaasSubscription
{
Id = subscriptionId,
TenantId = tenantId,
BaseOfferingVersionId = versionId,
Status = TenantSaasSubscriptionStatus.Active,
StartsAt = now.AddDays(-1),
CurrentPeriodStart = now.AddDays(-1),
CurrentPeriodEnd = now.AddMonths(1)
},
new TenantSaasSubscriptionItem
{
TenantId = tenantId,
SubscriptionId = subscriptionId,
OfferingVersionId = versionId,
ItemType = TenantSaasSubscriptionItemType.BasePlan,
Status = TenantSaasSubscriptionItemStatus.Active,
StartsAt = now.AddDays(-1),
EndsAt = now.AddMonths(1)
});
using var scope = factory.CreateSystemScope("Publish storage quota fixture");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var version = await dbContext.SaasOfferingVersions.SingleAsync(value => value.Id == versionId);
version.Status = SaasOfferingVersionStatus.Published;
version.PublishedAt = now;
await dbContext.SaveChangesAsync();
return (tenantId, userId, phone);
}
private static ContentAsset PendingAsset(Guid id, Guid tenantId, string fileName) => new()
{
Id = id,
TenantId = tenantId,
Title = fileName,
FileName = fileName,
StorageProvider = AssetStorageProvider.AliyunOss,
Bucket = "tenant-assets",
ObjectKey = $"{tenantId:N}/assets/{fileName}",
MimeType = "application/pdf",
AssetType = ContentAssetType.Pdf,
UploadStatus = AssetUploadStatus.Pending,
SecurityScanStatus = AssetSecurityScanStatus.Pending
};
private static async Task<long> StorageUsageAsync(ApiTestFactory factory, Guid tenantId)
{
using var scope = factory.CreateSystemScope("Read storage quota usage");
return await scope.ServiceProvider.GetRequiredService<TikuDbContext>().TenantFeatureUsages
.Where(value => value.TenantId == tenantId && value.MetricCode == SaasQuotaMetricCatalog.StorageBytes)
.Select(value => value.UsedValue)
.SingleOrDefaultAsync();
}
private static async Task LoginAsync(
HttpClient client,
(Guid TenantId, Guid UserId, string Phone) seed)
{
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
}
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
{
var stream = await response.Content.ReadAsStreamAsync();
return await JsonDocument.ParseAsync(stream);
}
private sealed class FakeObjectStorageService : IObjectStorageService
{
public long? MetadataSizeBytes { get; set; }
public string? MetadataChecksumSha256 { get; init; }
public string ConfiguredDefaultProvider() => ObjectStorageProviders.AliyunOss;
public string ConfiguredDefaultBucket() => "tenant-assets";
public string NormalizeProvider(string? value, string? fallback = null)
{
return value ?? fallback ?? ObjectStorageProviders.AliyunOss;
}
public string ValidateObjectKey(Guid tenantId, string objectKey) => objectKey;
public string ValidateMimeType(string mimeType) => mimeType;
public long? ValidateFileSize(long? fileSizeBytes) => fileSizeBytes;
public void AssertUploadProvider(string provider) { }
public void AssertWritableLocation(StorageAssetLocation location) { }
public Task<ObjectStorageSignedUrl> SignUploadAsync(
ObjectStorageUploadSignRequest request,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new ObjectStorageSignedUrl(
request.Provider,
request.Bucket,
request.ObjectKey,
"PUT",
new Uri($"https://storage.example.test/{request.ObjectKey}"),
new Dictionary<string, string> { ["content-type"] = request.MimeType },
DateTimeOffset.UtcNow.Add(request.ExpiresIn),
request.ExpiresIn,
"fake-signed-url"));
}
public Task<ObjectStorageSignedUrl> SignDownloadAsync(
ObjectStorageDownloadSignRequest request,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new ObjectStorageSignedUrl(
request.Provider,
request.Bucket,
request.ObjectKey,
"GET",
new Uri($"https://storage.example.test/{request.ObjectKey}"),
new Dictionary<string, string>(),
DateTimeOffset.UtcNow.Add(request.ExpiresIn),
request.ExpiresIn,
"fake-signed-url"));
}
public Task<ObjectStorageWriteResult> WriteObjectAsync(
ObjectStorageWriteRequest request,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new ObjectStorageWriteResult(
request.Provider,
request.Bucket,
request.ObjectKey,
new Uri($"https://storage.example.test/{request.ObjectKey}"),
request.FileSizeBytes,
request.MimeType,
request.ChecksumSha256,
"fake-etag",
new Dictionary<string, string>(),
"fake-write"));
}
public Task<ObjectStorageMetadata> HeadObjectAsync(
ObjectStorageHeadRequest request,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new ObjectStorageMetadata(
request.Provider,
request.Bucket,
request.ObjectKey,
Exists: true,
MetadataSizeBytes ?? request.DeclaredFileSizeBytes,
request.DeclaredMimeType,
MetadataChecksumSha256 ?? request.DeclaredChecksumSha256,
"etag",
DateTimeOffset.UtcNow.ToString("O"),
new Dictionary<string, string>(),
"fake-head"));
}
}
}