using System.Net; using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Application.Storage; using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.Identity; 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(); 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(); 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); } [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 LoginAsync( HttpClient client, (Guid TenantId, Guid UserId, string Phone) seed) { client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); } private static async Task ReadJsonAsync(HttpResponseMessage response) { var stream = await response.Content.ReadAsStreamAsync(); return await JsonDocument.ParseAsync(stream); } private sealed class FakeObjectStorageService : IObjectStorageService { public long? MetadataSizeBytes { get; init; } 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 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 { ["content-type"] = request.MimeType }, DateTimeOffset.UtcNow.Add(request.ExpiresIn), request.ExpiresIn, "fake-signed-url")); } public Task 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(), DateTimeOffset.UtcNow.Add(request.ExpiresIn), request.ExpiresIn, "fake-signed-url")); } public Task 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(), "fake-write")); } public Task 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(), "fake-head")); } } }