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.Commerce; 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 AssetAccessEndpointTests { [Fact] public async Task Public_asset_can_be_downloaded_anonymously_and_is_audited() { var tenantId = Guid.NewGuid(); var assetId = Guid.NewGuid(); await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService()); await factory.SeedAsync( Tenant(tenantId, "master"), PublicAsset(tenantId, assetId)); using var client = factory.CreateClient(); using var response = await client.GetAsync($"/api/assets/{assetId}/download?tenantCode=master"); var body = await ReadJsonAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("https://storage.example.test/download.pdf", body.RootElement.GetProperty("url").GetProperty("url").GetString()); using var scope = factory.Services.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); Assert.Equal(1, dbContext.ContentAssets.Single(asset => asset.Id == assetId).DownloadCount); Assert.Contains(dbContext.ContentAssetAccessEvents, item => item.AssetId == assetId && item.Result == AssetAccessResult.Granted && item.AccessType == AssetAccessType.Download); } [Fact] public async Task Members_asset_requires_authentication() { var tenantId = Guid.NewGuid(); var assetId = Guid.NewGuid(); await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService()); await factory.SeedAsync( Tenant(tenantId, "master"), new ContentAsset { Id = assetId, TenantId = tenantId, Title = "会员资料", Visibility = ContentVisibility.Members, StorageProvider = AssetStorageProvider.LocalDev, Bucket = "tenant-assets", ObjectKey = $"{tenantId:N}/members.pdf", MimeType = "application/pdf", AssetType = ContentAssetType.Pdf, UploadStatus = AssetUploadStatus.Verified, SecurityScanStatus = AssetSecurityScanStatus.Passed }); using var client = factory.CreateClient(); using var response = await client.GetAsync($"/api/assets/{assetId}/download?tenantCode=master"); var body = await ReadJsonAsync(response); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); Assert.Equal("auth_required", body.RootElement.GetProperty("code").GetString()); } [Fact] public async Task Members_asset_can_be_downloaded_by_active_member() { var tenantId = Guid.NewGuid(); var assetId = Guid.NewGuid(); await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService()); var seed = await SeedLoginUserAsync(factory, tenantId); await factory.SeedAsync( new ContentAsset { Id = assetId, TenantId = tenantId, Title = "会员资料", Visibility = ContentVisibility.Members, StorageProvider = AssetStorageProvider.LocalDev, Bucket = "tenant-assets", ObjectKey = $"{tenantId:N}/members.pdf", MimeType = "application/pdf", AssetType = ContentAssetType.Pdf, UploadStatus = AssetUploadStatus.Verified, SecurityScanStatus = AssetSecurityScanStatus.Passed }); using var client = factory.CreateClient(); await LoginAsync(client, seed); using var response = await client.GetAsync($"/api/assets/{assetId}/download"); var body = await ReadJsonAsync(response); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(seed.UserId, body.RootElement.GetProperty("access").GetProperty("userId").GetGuid()); } [Fact] public async Task Svip_asset_requires_active_entitlement() { var tenantId = Guid.NewGuid(); var assetId = Guid.NewGuid(); await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService()); var seed = await SeedLoginUserAsync(factory, tenantId); await factory.SeedAsync( new ContentAsset { Id = assetId, TenantId = tenantId, Title = "SVIP 资料", Visibility = ContentVisibility.Svip, StorageProvider = AssetStorageProvider.LocalDev, Bucket = "tenant-assets", ObjectKey = $"{tenantId:N}/svip.pdf", MimeType = "application/pdf", AssetType = ContentAssetType.Pdf, UploadStatus = AssetUploadStatus.Verified, SecurityScanStatus = AssetSecurityScanStatus.Passed }); using var client = factory.CreateClient(); await LoginAsync(client, seed); using var deniedResponse = await client.GetAsync($"/api/assets/{assetId}/download"); await factory.SeedAsync(new Entitlement { TenantId = tenantId, UserId = seed.UserId, EntitlementType = "svip", ScopeType = EntitlementScopeType.Tenant, Status = EntitlementStatus.Active, StartsAt = DateTimeOffset.UtcNow.AddMinutes(-1) }); using var grantedResponse = await client.GetAsync($"/api/assets/{assetId}/download"); Assert.Equal(HttpStatusCode.Forbidden, deniedResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, grantedResponse.StatusCode); } [Fact] public async Task Preview_rejects_non_previewable_assets() { var tenantId = Guid.NewGuid(); var assetId = Guid.NewGuid(); await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService()); await factory.SeedAsync( Tenant(tenantId, "master"), new ContentAsset { Id = assetId, TenantId = tenantId, Title = "压缩包", Visibility = ContentVisibility.Public, StorageProvider = AssetStorageProvider.LocalDev, Bucket = "tenant-assets", ObjectKey = $"{tenantId:N}/archive.zip", MimeType = "application/zip", AssetType = ContentAssetType.Package, UploadStatus = AssetUploadStatus.Verified, SecurityScanStatus = AssetSecurityScanStatus.Passed }); using var client = factory.CreateClient(); using var response = await client.GetAsync($"/api/assets/{assetId}/preview?tenantCode=master"); var body = await ReadJsonAsync(response); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal("asset_preview_not_supported", body.RootElement.GetProperty("code").GetString()); } private static ContentAsset PublicAsset(Guid tenantId, Guid assetId) { return new ContentAsset { Id = assetId, TenantId = tenantId, Title = "公开资料", FileName = "download.pdf", Visibility = ContentVisibility.Public, StorageProvider = AssetStorageProvider.LocalDev, Bucket = "tenant-assets", ObjectKey = $"{tenantId:N}/download.pdf", MimeType = "application/pdf", AssetType = ContentAssetType.Pdf, UploadStatus = AssetUploadStatus.Verified, SecurityScanStatus = AssetSecurityScanStatus.Passed }; } private static Tenant Tenant(Guid id, string slug) { return new Tenant { Id = id, Slug = slug, Name = slug, Status = TenantStatus.Active, Metadata = JsonDefaults.Object() }; } private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLoginUserAsync( ApiTestFactory factory, Guid tenantId) { var userId = Guid.NewGuid(); var phone = "13800000000"; var passwordHash = new PasswordHasher().Hash("passw0rd!"); await factory.SeedAsync( Tenant(tenantId, tenantId.ToString("N")), new User { Id = userId, Phone = phone, Name = "Test User" }, new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.Student, Status = MembershipStatus.Active }, new UserIdentity { UserId = userId, Provider = "password", ProviderSubject = phone, Phone = phone, SecretPayload = CreateSecretPayload(passwordHash) }); return (tenantId, userId, phone); } private static async Task LoginAsync( HttpClient client, (Guid TenantId, Guid UserId, string Phone) seed) { var loginResponse = await client.PostAsJsonAsync( "/api/auth/login/password", new PasswordLoginDto { TenantId = seed.TenantId, Phone = seed.Phone, Password = "passw0rd!" }); var loginJson = await ReadJsonAsync(loginResponse); var accessToken = loginJson.RootElement .GetProperty("tokens") .GetProperty("accessToken") .GetString(); client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); } private static async Task ReadJsonAsync(HttpResponseMessage response) { var stream = await response.Content.ReadAsStreamAsync(); return await JsonDocument.ParseAsync(stream); } private static JsonElement CreateSecretPayload(string passwordHash) { using var document = JsonDocument.Parse( $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); return document.RootElement.Clone(); } private sealed class FakeObjectStorageService : IObjectStorageService { public string ConfiguredDefaultProvider() => ObjectStorageProviders.LocalDev; public string ConfiguredDefaultBucket() => "tenant-assets"; public string NormalizeProvider(string? value, string? fallback = null) => value ?? fallback ?? ObjectStorageProviders.LocalDev; 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(Signed("PUT", request.Bucket, request.ObjectKey, request.ExpiresIn)); } public Task SignDownloadAsync( ObjectStorageDownloadSignRequest request, CancellationToken cancellationToken = default) { return Task.FromResult(Signed("GET", request.Bucket, request.ObjectKey, request.ExpiresIn)); } 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, request.DeclaredFileSizeBytes, request.DeclaredMimeType, request.DeclaredChecksumSha256, null, null, new Dictionary(), "fake")); } private static ObjectStorageSignedUrl Signed( string method, string? bucket, string? objectKey, TimeSpan expiresIn) { return new ObjectStorageSignedUrl( ObjectStorageProviders.LocalDev, bucket, objectKey, method, new Uri("https://storage.example.test/download.pdf"), new Dictionary(), DateTimeOffset.UtcNow.Add(expiresIn), expiresIn, "fake-signed-url"); } } }