forked from xiongyuxing/tiku-backend.net
403 lines
16 KiB
C#
403 lines
16 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.Content;
|
|
using Tiku.Domain.Identity;
|
|
using Tiku.Domain.Tenancy;
|
|
using Tiku.Infrastructure.Auth;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.IntegrationTests.Api;
|
|
|
|
public sealed class AssetManagementEndpointTests
|
|
{
|
|
[Fact]
|
|
public async Task Tenant_content_upload_sign_requires_authentication()
|
|
{
|
|
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
|
|
using var client = factory.CreateClient();
|
|
|
|
using var response = await client.PostAsJsonAsync(
|
|
"/api/tenant-content/assets/uploads/sign",
|
|
new AssetUploadSignDto
|
|
{
|
|
FileName = "lesson.pdf",
|
|
MimeType = "application/pdf"
|
|
});
|
|
|
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Tenant_admin_can_sign_upload_and_create_pending_asset()
|
|
{
|
|
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
|
|
var seed = await SeedAdminAsync(factory);
|
|
using var client = factory.CreateClient();
|
|
await LoginAsync(client, seed);
|
|
|
|
using var response = await client.PostAsJsonAsync(
|
|
"/api/tenant-content/assets/uploads/sign",
|
|
new AssetUploadSignDto
|
|
{
|
|
FileName = "lesson.pdf",
|
|
MimeType = "application/pdf",
|
|
FileSizeBytes = 1024,
|
|
Title = "课程讲义",
|
|
AssetType = "pdf",
|
|
Visibility = "members"
|
|
});
|
|
var body = await ReadJsonAsync(response);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
Assert.Equal("PUT", body.RootElement.GetProperty("upload").GetProperty("method").GetString());
|
|
var item = body.RootElement.GetProperty("item");
|
|
var assetId = item.GetProperty("id").GetGuid();
|
|
Assert.Equal("Pending", item.GetProperty("uploadStatus").GetString());
|
|
Assert.Equal("AliyunOss", item.GetProperty("storageProvider").GetString());
|
|
Assert.StartsWith($"{seed.TenantId:N}/assets/", item.GetProperty("objectKey").GetString(), StringComparison.Ordinal);
|
|
|
|
using var listResponse = await client.GetAsync("/api/tenant-content/assets?uploadStatus=pending");
|
|
var list = await ReadJsonAsync(listResponse);
|
|
var listItem = Assert.Single(list.RootElement.GetProperty("items").EnumerateArray());
|
|
Assert.Equal(assetId, listItem.GetProperty("id").GetGuid());
|
|
|
|
using var scope = factory.Services.CreateScope();
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
var asset = dbContext.ContentAssets.Single(asset => asset.Id == assetId);
|
|
Assert.Equal(seed.TenantId, asset.TenantId);
|
|
Assert.Equal(seed.UserId, asset.CreatedBy);
|
|
Assert.Equal(AssetUploadStatus.Pending, asset.UploadStatus);
|
|
Assert.Equal(AssetSecurityScanStatus.Pending, asset.SecurityScanStatus);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Tenant_admin_can_confirm_upload_and_mark_asset_verified()
|
|
{
|
|
var storage = new FakeObjectStorageService
|
|
{
|
|
MetadataSizeBytes = 2048,
|
|
MetadataChecksumSha256 = new string('a', 64)
|
|
};
|
|
await using var factory = new ApiTestFactory(objectStorageService: storage);
|
|
var seed = await SeedAdminAsync(factory);
|
|
var assetId = Guid.NewGuid();
|
|
await factory.SeedAsync(new ContentAsset
|
|
{
|
|
Id = assetId,
|
|
TenantId = seed.TenantId,
|
|
Title = "课程讲义",
|
|
FileName = "lesson.pdf",
|
|
StorageProvider = AssetStorageProvider.AliyunOss,
|
|
Bucket = "tenant-assets",
|
|
ObjectKey = $"{seed.TenantId:N}/assets/lesson.pdf",
|
|
MimeType = "application/pdf",
|
|
AssetType = ContentAssetType.Pdf,
|
|
UploadStatus = AssetUploadStatus.Pending,
|
|
SecurityScanStatus = AssetSecurityScanStatus.Pending
|
|
});
|
|
using var client = factory.CreateClient();
|
|
await LoginAsync(client, seed);
|
|
|
|
using var response = await client.PostAsJsonAsync(
|
|
"/api/tenant-content/assets/uploads/confirm",
|
|
new AssetUploadConfirmDto
|
|
{
|
|
AssetId = assetId,
|
|
MimeType = "application/pdf",
|
|
FileSizeBytes = 2048,
|
|
ChecksumSha256 = new string('a', 64)
|
|
});
|
|
var body = await ReadJsonAsync(response);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
Assert.Equal("Verified", body.RootElement.GetProperty("item").GetProperty("uploadStatus").GetString());
|
|
Assert.Equal(2048, body.RootElement.GetProperty("metadata").GetProperty("sizeBytes").GetInt64());
|
|
|
|
using var scope = factory.Services.CreateScope();
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
|
var asset = dbContext.ContentAssets.Single(asset => asset.Id == assetId);
|
|
Assert.Equal(AssetUploadStatus.Verified, asset.UploadStatus);
|
|
Assert.Equal(seed.UserId, asset.VerifiedBy);
|
|
Assert.Equal(2048, asset.VerifiedSizeBytes);
|
|
Assert.Equal(new string('a', 64), asset.VerifiedChecksumSha256);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_jobs_are_scoped_to_current_tenant_and_include_detail()
|
|
{
|
|
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
|
|
var seed = await SeedAdminAsync(factory);
|
|
var otherTenantId = Guid.NewGuid();
|
|
var jobId = Guid.NewGuid();
|
|
var itemId = Guid.NewGuid();
|
|
await factory.SeedAsync(
|
|
new Tenant
|
|
{
|
|
Id = otherTenantId,
|
|
Slug = otherTenantId.ToString("N"),
|
|
Name = "Other Tenant",
|
|
Status = TenantStatus.Active,
|
|
Metadata = JsonDefaults.Object()
|
|
},
|
|
new ContentImportJob
|
|
{
|
|
Id = jobId,
|
|
TenantId = seed.TenantId,
|
|
SourceName = "questions.xlsx",
|
|
ImportType = ContentImportType.Questions,
|
|
SourceFormat = ImportSourceFormat.Excel,
|
|
Status = ContentImportStatus.CompletedWithErrors,
|
|
TotalCount = 1,
|
|
ErrorCount = 1
|
|
},
|
|
new ContentImportItem
|
|
{
|
|
Id = itemId,
|
|
TenantId = seed.TenantId,
|
|
JobId = jobId,
|
|
RowNo = 1,
|
|
Status = ContentImportItemStatus.Invalid,
|
|
IssuesCount = 1
|
|
},
|
|
new ContentImportIssue
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
TenantId = seed.TenantId,
|
|
JobId = jobId,
|
|
ItemId = itemId,
|
|
RowNo = 1,
|
|
Severity = ImportIssueSeverity.Error,
|
|
Code = "missing_answer",
|
|
Message = "答案不能为空"
|
|
},
|
|
new ContentImportJob
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
TenantId = otherTenantId,
|
|
SourceName = "other.xlsx"
|
|
});
|
|
using var client = factory.CreateClient();
|
|
await LoginAsync(client, seed);
|
|
|
|
using var listResponse = await client.GetAsync("/api/tenant-content/import-jobs?status=completedWithErrors");
|
|
using var detailResponse = await client.GetAsync($"/api/tenant-content/import-jobs/{jobId}");
|
|
var list = await ReadJsonAsync(listResponse);
|
|
var detail = await ReadJsonAsync(detailResponse);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
|
var listItem = Assert.Single(list.RootElement.GetProperty("items").EnumerateArray());
|
|
Assert.Equal(jobId, listItem.GetProperty("id").GetGuid());
|
|
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
|
|
Assert.Equal(jobId, detail.RootElement.GetProperty("job").GetProperty("id").GetGuid());
|
|
Assert.Single(detail.RootElement.GetProperty("items").EnumerateArray());
|
|
Assert.Single(detail.RootElement.GetProperty("issues").EnumerateArray());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Tenant_admin_can_upsert_asset_sign_access_and_query_asset_events()
|
|
{
|
|
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
|
|
var seed = await SeedAdminAsync(factory);
|
|
using var client = factory.CreateClient();
|
|
await LoginAsync(client, seed);
|
|
|
|
var upsertResponse = await client.PutAsJsonAsync(
|
|
"/api/tenant-content/assets",
|
|
new UpsertAssetDto
|
|
{
|
|
Title = "管理侧资料",
|
|
FileName = "admin.pdf",
|
|
AssetType = "pdf",
|
|
Visibility = "members",
|
|
Provider = "local_dev",
|
|
Bucket = "tenant-assets",
|
|
ObjectKey = $"{seed.TenantId:N}/admin.pdf",
|
|
PreviewObjectKey = $"{seed.TenantId:N}/admin-preview.pdf",
|
|
MimeType = "application/pdf",
|
|
FileSizeBytes = 512
|
|
});
|
|
var upsert = await ReadJsonAsync(upsertResponse);
|
|
var assetId = upsert.RootElement.GetProperty("item").GetProperty("id").GetGuid();
|
|
await factory.SeedAsync(new ContentAssetSecurityScanEvent
|
|
{
|
|
TenantId = seed.TenantId,
|
|
AssetId = assetId,
|
|
Provider = "local",
|
|
ScanStatus = AssetSecurityScanStatus.Passed,
|
|
RiskLevel = AssetSecurityRiskLevel.None
|
|
});
|
|
|
|
var downloadResponse = await client.PostAsJsonAsync(
|
|
"/api/tenant-content/assets/sign-download",
|
|
new AssetAccessSignDto { AssetId = assetId, ExpiresInSeconds = 120 });
|
|
var previewResponse = await client.PostAsJsonAsync(
|
|
"/api/tenant-content/assets/sign-preview",
|
|
new AssetAccessSignDto { AssetId = assetId, ExpiresInSeconds = 120 });
|
|
var accessEventsResponse = await client.GetAsync($"/api/tenant-content/assets/access-events?assetId={assetId}");
|
|
var scanEventsResponse = await client.GetAsync($"/api/tenant-content/assets/security-scan-events?assetId={assetId}");
|
|
var accessEvents = await ReadJsonAsync(accessEventsResponse);
|
|
var scanEvents = await ReadJsonAsync(scanEventsResponse);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, upsertResponse.StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, downloadResponse.StatusCode);
|
|
Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode);
|
|
Assert.Equal(2, accessEvents.RootElement.GetProperty("items").GetArrayLength());
|
|
Assert.Single(scanEvents.RootElement.GetProperty("items").EnumerateArray());
|
|
}
|
|
|
|
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminAsync(ApiTestFactory factory)
|
|
{
|
|
var tenantId = Guid.NewGuid();
|
|
var userId = Guid.NewGuid();
|
|
var phone = "13900000000";
|
|
var passwordHash = new PasswordHasher().Hash("passw0rd!");
|
|
|
|
await factory.SeedAsync(
|
|
new Tenant
|
|
{
|
|
Id = tenantId,
|
|
Slug = tenantId.ToString("N"),
|
|
Name = "Test Tenant",
|
|
Status = TenantStatus.Active,
|
|
Metadata = JsonDefaults.Object()
|
|
},
|
|
new User
|
|
{
|
|
Id = userId,
|
|
Phone = phone,
|
|
Name = "Tenant Admin"
|
|
},
|
|
new TenantMembership
|
|
{
|
|
TenantId = tenantId,
|
|
UserId = userId,
|
|
Role = TenantRole.TenantAdmin,
|
|
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 long? MetadataSizeBytes { get; init; }
|
|
|
|
public string? MetadataChecksumSha256 { get; init; }
|
|
|
|
public string ConfiguredDefaultProvider() => ObjectStorageProviders.AliyunOss;
|
|
|
|
public string ConfiguredDefaultBucket() => "tenant-assets";
|
|
|
|
public string NormalizeProvider(string? value, string? fallback = null)
|
|
{
|
|
return value ?? fallback ?? ObjectStorageProviders.AliyunOss;
|
|
}
|
|
|
|
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(new ObjectStorageSignedUrl(
|
|
request.Provider,
|
|
request.Bucket,
|
|
request.ObjectKey,
|
|
"PUT",
|
|
new Uri($"https://storage.example.test/{request.ObjectKey}"),
|
|
new Dictionary<string, string> { ["content-type"] = request.MimeType },
|
|
DateTimeOffset.UtcNow.Add(request.ExpiresIn),
|
|
request.ExpiresIn,
|
|
"fake-signed-url"));
|
|
}
|
|
|
|
public Task<ObjectStorageSignedUrl> SignDownloadAsync(
|
|
ObjectStorageDownloadSignRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return Task.FromResult(new ObjectStorageSignedUrl(
|
|
request.Provider,
|
|
request.Bucket,
|
|
request.ObjectKey,
|
|
"GET",
|
|
new Uri($"https://storage.example.test/{request.ObjectKey}"),
|
|
new Dictionary<string, string>(),
|
|
DateTimeOffset.UtcNow.Add(request.ExpiresIn),
|
|
request.ExpiresIn,
|
|
"fake-signed-url"));
|
|
}
|
|
|
|
public Task<ObjectStorageMetadata> HeadObjectAsync(
|
|
ObjectStorageHeadRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return Task.FromResult(new ObjectStorageMetadata(
|
|
request.Provider,
|
|
request.Bucket,
|
|
request.ObjectKey,
|
|
Exists: true,
|
|
MetadataSizeBytes ?? request.DeclaredFileSizeBytes,
|
|
request.DeclaredMimeType,
|
|
MetadataChecksumSha256 ?? request.DeclaredChecksumSha256,
|
|
"etag",
|
|
DateTimeOffset.UtcNow.ToString("O"),
|
|
new Dictionary<string, string>(),
|
|
"fake-head"));
|
|
}
|
|
}
|
|
}
|