211 lines
12 KiB
C#
211 lines
12 KiB
C#
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<string> { 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.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.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<ObjectResult>(await stale.ExportAccount(CancellationToken.None));
|
|
Assert.Equal(StatusCodes.Status403Forbidden, staleResult.StatusCode);
|
|
|
|
var fresh = Controller(db, service, user.Id, "fresh");
|
|
var file = Assert.IsType<FileStreamResult>(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<EnableRateLimitingAttribute>().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<string, string?> { ["Data:Root"] = root }).Build();
|
|
var environment = new Mock<IHostEnvironment>();
|
|
environment.SetupGet(item => item.ContentRootPath).Returns(root);
|
|
var paths = new AppPaths(configuration, environment.Object);
|
|
var currentUser = new Mock<ICurrentUserService>();
|
|
currentUser.SetupGet(item => item.UserId).Returns("user-1");
|
|
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
|
.UseSqlite($"Data Source={Path.Combine(root, "account-export-tests.db")}")
|
|
.Options;
|
|
var db = new JobTrackerContext(options, currentUser.Object);
|
|
db.Database.EnsureCreated();
|
|
return (db, paths, new AccountDataExportService(db, paths, new AttachmentStorage(paths), TimeProvider.System));
|
|
}
|
|
|
|
private static string ReadText(ZipArchiveEntry entry)
|
|
{
|
|
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
|
|
return reader.ReadToEnd();
|
|
}
|
|
}
|