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

328 lines
13 KiB
C#

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.CreateSystemScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
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";
await factory.SeedAsync(
Tenant(tenantId, tenantId.ToString("N")),
new User
{
Id = userId,
Phone = phone,
Name = "Test User"
}.WithTestPassword(),
new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = TenantRole.Student,
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<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
{
var stream = await response.Content.ReadAsStreamAsync();
return await JsonDocument.ParseAsync(stream);
}
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<ObjectStorageSignedUrl> SignUploadAsync(
ObjectStorageUploadSignRequest request,
CancellationToken cancellationToken = default)
{
return Task.FromResult(Signed("PUT", request.Bucket, request.ObjectKey, request.ExpiresIn));
}
public Task<ObjectStorageSignedUrl> SignDownloadAsync(
ObjectStorageDownloadSignRequest request,
CancellationToken cancellationToken = default)
{
return Task.FromResult(Signed("GET", request.Bucket, request.ObjectKey, request.ExpiresIn));
}
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,
request.DeclaredFileSizeBytes,
request.DeclaredMimeType,
request.DeclaredChecksumSha256,
null,
null,
new Dictionary<string, string>(),
"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<string, string>(),
DateTimeOffset.UtcNow.Add(expiresIn),
expiresIn,
"fake-signed-url");
}
}
}