feat: add asset access signing endpoints

This commit is contained in:
xiong
2026-07-26 14:54:50 +08:00
parent 0ff97e86cd
commit 06471151cd
9 changed files with 957 additions and 1 deletions

View File

@@ -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);
}
});
}

View 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");
}
}
}