377 lines
14 KiB
C#
377 lines
14 KiB
C#
using System.Net;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Tiku.Application.Storage;
|
|
using Tiku.Domain.Commerce;
|
|
using Tiku.Domain.Common;
|
|
using Tiku.Domain.Content;
|
|
using Tiku.Domain.Identity;
|
|
using Tiku.Domain.Tenancy;
|
|
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/student/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/student/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/student/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/student/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/student/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/student/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());
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(AssetSecurityScanStatus.Pending)]
|
|
[InlineData(AssetSecurityScanStatus.Scanning)]
|
|
[InlineData(AssetSecurityScanStatus.Failed)]
|
|
[InlineData(AssetSecurityScanStatus.Skipped)]
|
|
public async Task Asset_access_fails_closed_until_security_scan_is_trusted(
|
|
AssetSecurityScanStatus scanStatus)
|
|
{
|
|
var tenantId = Guid.NewGuid();
|
|
var assetId = Guid.NewGuid();
|
|
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
|
|
var asset = PublicAsset(tenantId, assetId);
|
|
asset.SecurityScanStatus = scanStatus;
|
|
await factory.SeedAsync(Tenant(tenantId, "scan-gate"), asset);
|
|
using var client = factory.CreateClient();
|
|
|
|
using var response = await client.GetAsync($"/api/student/assets/{assetId}/download?tenantCode=scan-gate");
|
|
var body = await ReadJsonAsync(response);
|
|
|
|
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
|
Assert.Equal("asset_security_scan_not_passed", 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()
|
|
{
|
|
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)
|
|
{
|
|
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,
|
|
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");
|
|
}
|
|
}
|
|
} |