using System.IO.Compression; using System.Security.Claims; using System.Security.Cryptography; using System.Text; using System.Text.Json; using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class AccountDataExportTests { [Fact] public async Task Readable_zip_is_complete_checksummed_owner_isolated_and_redacted() { var root = Path.Combine(Path.GetTempPath(), $"jobtracker-account-export-{Guid.NewGuid():N}"); Directory.CreateDirectory(root); try { var (db, paths, service) = CreateService(root); await using (db) { var owner = new ApplicationUser { Id = "user-1", UserName = "owner@example.test", Email = "owner@example.test", DisplayName = "Synthetic Owner", PasswordHash = "PASSWORD_HASH_MUST_NOT_EXPORT", SecurityStamp = "SECURITY_STAMP_MUST_NOT_EXPORT", TotpSecretEncrypted = "TOTP_SECRET_MUST_NOT_EXPORT", GoogleEmail = "owner@gmail.test", AiEnabled = true, }; var other = new ApplicationUser { Id = "user-2", UserName = "other@example.test", Email = "other@example.test", DisplayName = "OTHER_TENANT_PRIVATE" }; var role = new IdentityRole("Pro") { Id = "role-pro", NormalizedName = "PRO" }; db.Users.AddRange(owner, other); db.Roles.Add(role); db.UserRoles.Add(new IdentityUserRole { UserId = owner.Id, RoleId = role.Id }); var company = new Company { OwnerUserId = owner.Id, Name = "Owner Company" }; db.Companies.Add(company); await db.SaveChangesAsync(); var application = new JobApplication { OwnerUserId = owner.Id, CompanyId = company.Id, JobTitle = "Owner Role", Notes = "Readable application note" }; var otherApplication = new JobApplication { OwnerUserId = other.Id, CompanyId = company.Id, JobTitle = "OTHER_TENANT_PRIVATE" }; db.JobApplications.AddRange(application, otherApplication); await db.SaveChangesAsync(); var attachmentPath = Path.Combine(paths.AttachmentsRoot, application.Id.ToString(), "evidence.txt"); Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!); await File.WriteAllTextAsync(attachmentPath, "owned attachment bytes"); db.Attachments.Add(new Attachment { JobApplicationId = application.Id, FileName = "evidence.txt", FilePath = attachmentPath, FileType = "text/plain", FileSize = new FileInfo(attachmentPath).Length }); var artifactRoot = Path.Combine(paths.CvArtifactsRoot, AppPaths.GetOwnerStorageKey(owner.Id)); Directory.CreateDirectory(artifactRoot); var artifactPath = Path.Combine(artifactRoot, "resume.txt"); await File.WriteAllTextAsync(artifactPath, "owned CV artifact bytes"); db.CvUploadArtifacts.Add(new CvUploadArtifact { OwnerUserId = owner.Id, OriginalFileName = "resume.txt", StoredFileName = "resume.txt", MimeType = "text/plain", ByteSize = new FileInfo(artifactPath).Length, Sha256 = "synthetic", StoragePath = artifactPath }); db.GmailConnections.Add(new GmailConnection { OwnerUserId = owner.Id, GmailAddress = owner.GoogleEmail!, Scope = "mail.read", EncryptedRefreshToken = "GMAIL_REFRESH_SECRET_MUST_NOT_EXPORT", EncryptedAccessToken = "GMAIL_ACCESS_SECRET_MUST_NOT_EXPORT" }); db.ImapConnections.Add(new ImapConnection { OwnerUserId = owner.Id, Host = "mail.example.test", Username = "owner", EncryptedPassword = "IMAP_SECRET_MUST_NOT_EXPORT" }); db.UserOperations.Add(new UserOperation { Id = Guid.NewGuid(), OwnerUserId = owner.Id, TaskType = "synthetic", Status = OperationStatuses.Running, LeaseToken = "LEASE_SECRET_MUST_NOT_EXPORT", CreatedAtUtc = DateTime.UtcNow, AvailableAtUtc = DateTime.UtcNow }); db.AiUsageRecords.Add(new AiUsageRecord { OwnerUserId = owner.Id, SourceType = "workspace", SourceId = "export-proof", TaskType = "ai.workspace", InputCharacterCount = 120, OutputCharacterCount = 40, EstimatedTokenCount = 40, CreatedAtUtc = DateTimeOffset.UtcNow, }); db.TwoFactorRecoveryCodes.Add(new TwoFactorRecoveryCode { UserId = owner.Id, CodeHash = "RECOVERY_HASH_MUST_NOT_EXPORT", CreatedAtUtc = DateTimeOffset.UtcNow }); await db.SaveChangesAsync(); var avatar = await AvatarStorage.StoreAsync(paths.DataRoot, owner.Id, [1, 2, 3, 4], "image/png", CancellationToken.None); owner.AvatarImageDataUrl = avatar; db.Users.Update(owner); var generatedCvRoot = Path.Combine(paths.GetOwnerCvExportsRoot(owner.Id), "20260815"); Directory.CreateDirectory(generatedCvRoot); await File.WriteAllTextAsync(Path.Combine(generatedCvRoot, "generated.pdf"), "synthetic generated PDF"); var dailyRoot = paths.GetOwnerDailyExportsRoot(null, owner.Id); Directory.CreateDirectory(dailyRoot); await File.WriteAllTextAsync(Path.Combine(dailyRoot, "daily_export_20260815.json"), "{\"owner\":true}"); await db.SaveChangesAsync(); var artifact = await service.CreateAsync(owner.Id, CancellationToken.None); Assert.True(File.Exists(artifact.StoragePath)); using var archive = ZipFile.OpenRead(artifact.StoragePath); Assert.NotNull(archive.GetEntry("manifest.json")); Assert.NotNull(archive.GetEntry("README.txt")); Assert.NotNull(archive.GetEntry("data/account.json")); Assert.NotNull(archive.GetEntry("data/applications.json")); Assert.NotNull(archive.GetEntry($"files/attachments/{application.Id}/evidence.txt")); Assert.NotNull(archive.GetEntry("files/cv-artifacts/1/resume.txt")); Assert.Contains(archive.Entries, item => item.FullName.StartsWith("files/avatar/", StringComparison.Ordinal)); Assert.NotNull(archive.GetEntry("files/generated-cv/20260815/generated.pdf")); Assert.NotNull(archive.GetEntry("files/daily-exports/daily_export_20260815.json")); var readable = string.Join('\n', archive.Entries .Where(item => item.FullName.EndsWith(".json", StringComparison.OrdinalIgnoreCase) || item.FullName.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) .Select(ReadText)); Assert.Contains("Synthetic Owner", readable); Assert.Contains("Readable application note", readable); Assert.Contains("export-proof", readable); Assert.DoesNotContain("OTHER_TENANT_PRIVATE", readable); Assert.DoesNotContain("PASSWORD_HASH_MUST_NOT_EXPORT", readable); Assert.DoesNotContain("SECURITY_STAMP_MUST_NOT_EXPORT", readable); Assert.DoesNotContain("TOTP_SECRET_MUST_NOT_EXPORT", readable); Assert.DoesNotContain("GMAIL_REFRESH_SECRET_MUST_NOT_EXPORT", readable); Assert.DoesNotContain("GMAIL_ACCESS_SECRET_MUST_NOT_EXPORT", readable); Assert.DoesNotContain("IMAP_SECRET_MUST_NOT_EXPORT", readable); Assert.DoesNotContain("LEASE_SECRET_MUST_NOT_EXPORT", readable); Assert.DoesNotContain("RECOVERY_HASH_MUST_NOT_EXPORT", readable); using var manifestDocument = JsonDocument.Parse(ReadText(archive.GetEntry("manifest.json")!)); var manifestEntries = manifestDocument.RootElement.GetProperty("entries").EnumerateArray().ToList(); Assert.Equal(archive.Entries.Count - 1, manifestEntries.Count); foreach (var manifestEntry in manifestEntries) { var zipEntry = archive.GetEntry(manifestEntry.GetProperty("path").GetString()!); Assert.NotNull(zipEntry); using var source = zipEntry!.Open(); var hash = Convert.ToHexString(SHA256.HashData(source)).ToLowerInvariant(); Assert.Equal(manifestEntry.GetProperty("sha256").GetString(), hash); Assert.Equal(manifestEntry.GetProperty("bytes").GetInt64(), zipEntry.Length); } } } finally { SqliteConnection.ClearAllPools(); if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } } [Fact] public async Task Account_export_requires_a_session_created_within_fifteen_minutes() { var root = Path.Combine(Path.GetTempPath(), $"jobtracker-account-export-auth-{Guid.NewGuid():N}"); Directory.CreateDirectory(root); try { var (db, _, service) = CreateService(root); await using (db) { var user = new ApplicationUser { Id = "user-1", UserName = "owner@example.test", Email = "owner@example.test" }; db.Users.Add(user); db.UserSessions.AddRange( new UserSession { Id = "fresh", UserId = user.Id, CreatedAtUtc = DateTimeOffset.UtcNow, LastSeenAtUtc = DateTimeOffset.UtcNow, ExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(1) }, new UserSession { Id = "stale", UserId = user.Id, CreatedAtUtc = DateTimeOffset.UtcNow.AddMinutes(-16), LastSeenAtUtc = DateTimeOffset.UtcNow, ExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(1) }); await db.SaveChangesAsync(); var stale = Controller(db, service, user.Id, "stale"); var staleResult = Assert.IsType(await stale.ExportAccount(CancellationToken.None)); Assert.Equal(StatusCodes.Status403Forbidden, staleResult.StatusCode); var fresh = Controller(db, service, user.Id, "fresh"); var file = Assert.IsType(await fresh.ExportAccount(CancellationToken.None)); Assert.Equal("application/zip", file.ContentType); Assert.EndsWith(".zip", file.FileDownloadName); await file.FileStream.DisposeAsync(); } } finally { SqliteConnection.ClearAllPools(); if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } } [Fact] public void Account_export_is_rate_limited() { var attribute = typeof(ExportController).GetMethod(nameof(ExportController.ExportAccount))!.GetCustomAttributes(typeof(EnableRateLimitingAttribute), inherit: true).Cast().Single(); Assert.Equal("account-data", attribute.PolicyName); } private static ExportController Controller(JobTrackerApi.Data.JobTrackerContext db, AccountDataExportService service, string userId, string sessionId) { var controller = new ExportController(db, service) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; controller.ControllerContext.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity( [ new Claim(ClaimTypes.NameIdentifier, userId), new Claim("sid", sessionId), ], "local")); return controller; } private static (JobTrackerApi.Data.JobTrackerContext Db, AppPaths Paths, AccountDataExportService Service) CreateService(string root) { var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Data:Root"] = root }).Build(); var environment = new Mock(); environment.SetupGet(item => item.ContentRootPath).Returns(root); var paths = new AppPaths(configuration, environment.Object); var currentUser = new Mock(); currentUser.SetupGet(item => item.UserId).Returns("user-1"); var options = new DbContextOptionsBuilder() .UseSqlite($"Data Source={Path.Combine(root, "account-export-tests.db")}") .Options; var db = new JobTrackerContext(options, currentUser.Object); db.Database.EnsureCreated(); var inventory = new AccountOwnedFileInventory(db, paths, new AttachmentStorage(paths)); return (db, paths, new AccountDataExportService(db, paths, inventory, TimeProvider.System)); } private static string ReadText(ZipArchiveEntry entry) { using var reader = new StreamReader(entry.Open(), Encoding.UTF8); return reader.ReadToEnd(); } }