forked from xiongyuxing/tiku-backend.net
358 lines
14 KiB
C#
358 lines
14 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.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<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");
|
|
}
|
|
}
|
|
}
|