forked from xiongyuxing/tiku-backend.net
feat: add asset access signing endpoints
This commit is contained in:
@@ -3,13 +3,16 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Npgsql;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class ApiTestFactory(IWechatOAuthClient? wechatOAuthClient = null) : WebApplicationFactory<Program>
|
||||
public sealed class ApiTestFactory(
|
||||
IWechatOAuthClient? wechatOAuthClient = null,
|
||||
IObjectStorageService? objectStorageService = null) : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string databaseName = Guid.NewGuid().ToString();
|
||||
|
||||
@@ -34,6 +37,11 @@ public sealed class ApiTestFactory(IWechatOAuthClient? wechatOAuthClient = null)
|
||||
{
|
||||
services.AddSingleton(wechatOAuthClient);
|
||||
}
|
||||
|
||||
if (objectStorageService is not null)
|
||||
{
|
||||
services.AddSingleton(objectStorageService);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
340
Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs
Normal file
340
Tiku.IntegrationTests/Api/AssetAccessEndpointTests.cs
Normal file
@@ -0,0 +1,340 @@
|
||||
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<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";
|
||||
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<JsonDocument> 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<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<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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user