## 后端安全加固 - 新增 UserUploadPathResolver: 用户上传文件路径安全解析, 防目录穿越 - LocalReportFileStorage: 文件存储路径安全加固 - local_account_file_cleanup: 账号删除时文件清理逻辑增强 - AuthService: 认证逻辑增强 - file_endpoints / report_endpoints: 文件访问接口安全加固 - ai_chat_endpoints / doctor_endpoints: 接口安全调整 - Program.cs: 服务注册调整 ## 前端认证与媒体 - 新增 authenticated_network_image.dart: 带认证的图片加载组件 - auth_provider: 认证状态管理大幅增强(+173) - api_client: 网络客户端增强(+124) - chat_provider: 聊天 provider 重构(+76) - omron_device_provider: 蓝牙设备 provider 增强(+53) - sse_handler: SSE 处理增强(+35) - consultation_provider / data_providers / conversation_history_provider: 调整 ## 页面调整 - remaining_pages: 健康档案/饮食记录等页面增强(+115) - home_page / chat_messages_view: 主页微调 - doctor 端多页微调(consultations/dashboard/followups/patient_detail/profile/report_detail/reports) - report_pages / settings_pages / notification_prefs_page: 微调 - device_scan_page / diet_capture_page / admin_home_page: 微调 ## 测试 - 新增 file_path_security_tests: 文件路径安全测试 - 新增 protected_media_url_test: 媒体URL保护测试 - 新增 user_session_identity_test: 用户会话身份测试 - account_deletion_tests / application_service_tests / auth_tests: 更新
123 lines
4.9 KiB
C#
123 lines
4.9 KiB
C#
using Health.Application.Users;
|
|
using Health.Domain.Entities;
|
|
using Health.Infrastructure.Users;
|
|
|
|
namespace Health.Tests;
|
|
|
|
public sealed class AccountDeletionTests
|
|
{
|
|
[Fact]
|
|
public async Task LocalCleanup_DeletesOwnedUploadsWithoutTouchingOtherFiles()
|
|
{
|
|
var root = Path.Combine(Path.GetTempPath(), $"health-account-delete-{Guid.NewGuid():N}");
|
|
var userId = Guid.NewGuid();
|
|
var userDirectory = Path.Combine(root, "users", userId.ToString("N"));
|
|
var reportPath = Path.Combine(root, "reports", "owned-report.pdf");
|
|
var chatFileName = $"{Guid.NewGuid()}.jpg";
|
|
var chatImagePath = Path.Combine(userDirectory, chatFileName);
|
|
var otherUserId = Guid.NewGuid();
|
|
var otherUserPath = Path.Combine(root, "users", otherUserId.ToString("N"), "keep.jpg");
|
|
var outsidePath = Path.Combine(Path.GetDirectoryName(root)!, "outside-account-file.txt");
|
|
|
|
try
|
|
{
|
|
Directory.CreateDirectory(userDirectory);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(reportPath)!);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(otherUserPath)!);
|
|
await File.WriteAllTextAsync(Path.Combine(userDirectory, "unlinked-upload.jpg"), "owned");
|
|
await File.WriteAllTextAsync(reportPath, "owned");
|
|
await File.WriteAllTextAsync(chatImagePath, "owned");
|
|
await File.WriteAllTextAsync(otherUserPath, "other");
|
|
await File.WriteAllTextAsync(outsidePath, "outside");
|
|
|
|
var cleanup = new LocalAccountFileCleanup(root);
|
|
var references = new AccountFileReferences(
|
|
["/uploads/reports/owned-report.pdf", "/uploads/../outside-account-file.txt"],
|
|
[$"{{\"imageUrl\":\"/uploads/users/{userId:N}/{chatFileName}\"}}", $"{{\"imageUrl\":\"/uploads/users/{otherUserId:N}/keep.jpg\"}}"]);
|
|
|
|
await cleanup.DeleteAsync(userId, references, CancellationToken.None);
|
|
|
|
Assert.False(Directory.Exists(userDirectory));
|
|
Assert.False(File.Exists(reportPath));
|
|
Assert.False(File.Exists(chatImagePath));
|
|
Assert.True(File.Exists(otherUserPath));
|
|
Assert.True(File.Exists(outsidePath));
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
|
if (File.Exists(outsidePath)) File.Delete(outsidePath);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UserService_CleansFilesBeforeDeletingDatabaseData()
|
|
{
|
|
var calls = new List<string>();
|
|
var references = new AccountFileReferences(["/uploads/reports/report.pdf"], []);
|
|
var repository = new FakeUserRepository(references, calls);
|
|
var cleanup = new FakeAccountFileCleanup(calls);
|
|
var service = new UserService(repository, cleanup);
|
|
|
|
await service.DeleteAccountAsync(Guid.NewGuid(), CancellationToken.None);
|
|
|
|
Assert.Equal(["references", "files", "database"], calls);
|
|
Assert.Same(references, cleanup.ReceivedReferences);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UserService_DoesNotDeleteDatabaseWhenFileCleanupFails()
|
|
{
|
|
var calls = new List<string>();
|
|
var repository = new FakeUserRepository(new AccountFileReferences([], []), calls);
|
|
var service = new UserService(repository, new FailingAccountFileCleanup(calls));
|
|
|
|
await Assert.ThrowsAsync<IOException>(() =>
|
|
service.DeleteAccountAsync(Guid.NewGuid(), CancellationToken.None));
|
|
|
|
Assert.Equal(["references", "files"], calls);
|
|
}
|
|
|
|
private sealed class FakeUserRepository(
|
|
AccountFileReferences references,
|
|
List<string> calls) : IUserRepository
|
|
{
|
|
public Task<User?> GetAsync(Guid userId, CancellationToken ct) => Task.FromResult<User?>(null);
|
|
|
|
public Task<AccountFileReferences> GetAccountFileReferencesAsync(Guid userId, CancellationToken ct)
|
|
{
|
|
calls.Add("references");
|
|
return Task.FromResult(references);
|
|
}
|
|
|
|
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
|
|
|
public Task DeleteAccountDataAsync(Guid userId, CancellationToken ct)
|
|
{
|
|
calls.Add("database");
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class FakeAccountFileCleanup(List<string> calls) : IAccountFileCleanup
|
|
{
|
|
public AccountFileReferences? ReceivedReferences { get; private set; }
|
|
|
|
public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
|
|
{
|
|
calls.Add("files");
|
|
ReceivedReferences = references;
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class FailingAccountFileCleanup(List<string> calls) : IAccountFileCleanup
|
|
{
|
|
public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
|
|
{
|
|
calls.Add("files");
|
|
throw new IOException("file is locked");
|
|
}
|
|
}
|
|
}
|