Files
AI-Health/backend/tests/Health.Tests/account_deletion_tests.cs
MingNian 9cea41705e feat: 文件存储安全加固 + 认证增强 + 媒体URL保护 + provider 重构
## 后端安全加固
- 新增 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: 更新
2026-07-20 10:19:01 +08:00

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