8bab8b54de
Implements AUDIT_REPORT.md H-3 (doc + retention), M-3, L-2/L-4 guidance:
- SECURITY.md: the deliberate data-at-rest posture (plaintext bodies + why, volume
encryption + backup guidance, DB TLS note, AllowedHosts note, production checklist,
delete-my-data procedure).
- Opt-in local data retention: DataRetention:{PurgeTrashedAfterDays,PurgeAllAfterDays}
(0 = disabled, no behaviour change) + daily RetentionWorker purging only the LOCAL
copy (Gmail untouched). 3 tests lock disabled-is-noop and purge-scoping.
- M-3: optional X.509 protection for the Data Protection key ring
(DataProtection:CertificatePath/Password) so keys are no longer necessarily
plaintext next to the ciphertext they protect.
Full suite: 51/51 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
74 lines
2.9 KiB
C#
74 lines
2.9 KiB
C#
using FluentAssertions;
|
|
using InboxIntel.Application.Abstractions;
|
|
using InboxIntel.Domain.Entities;
|
|
using InboxIntel.Infrastructure.Configuration;
|
|
using InboxIntel.Infrastructure.Persistence;
|
|
using InboxIntel.Infrastructure.Retention;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using Xunit;
|
|
|
|
namespace InboxIntel.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// AUDIT H-3 (retention): the purge removes only what the configuration targets and is a
|
|
/// no-op while disabled — locking the "no behaviour change until opted in" guarantee.
|
|
/// </summary>
|
|
public class RetentionServiceTests
|
|
{
|
|
private sealed class FakeCurrentUser : ICurrentUser
|
|
{
|
|
public Guid UserId => Guid.Empty; // worker scope
|
|
public bool IsAuthenticated => false;
|
|
}
|
|
|
|
private static AppDbContext NewDb(string name) =>
|
|
new(new DbContextOptionsBuilder<AppDbContext>().UseInMemoryDatabase(name).Options, new FakeCurrentUser());
|
|
|
|
private static async Task SeedAsync(AppDbContext db)
|
|
{
|
|
var user = Guid.NewGuid();
|
|
db.Emails.AddRange(
|
|
new Email { UserId = user, GmailMessageId = "old-trashed", IsTrashed = true, SentAtUtc = DateTimeOffset.UtcNow.AddDays(-100) },
|
|
new Email { UserId = user, GmailMessageId = "new-trashed", IsTrashed = true, SentAtUtc = DateTimeOffset.UtcNow.AddDays(-5) },
|
|
new Email { UserId = user, GmailMessageId = "old-kept", IsTrashed = false, SentAtUtc = DateTimeOffset.UtcNow.AddDays(-100) });
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
private static RetentionService Sut(AppDbContext db, int trashedDays = 0, int allDays = 0) =>
|
|
new(db, Options.Create(new DataRetentionOptions
|
|
{
|
|
PurgeTrashedAfterDays = trashedDays,
|
|
PurgeAllAfterDays = allDays
|
|
}), NullLogger<RetentionService>.Instance);
|
|
|
|
[Fact]
|
|
public async Task Disabled_retention_purges_nothing()
|
|
{
|
|
using var db = NewDb(nameof(Disabled_retention_purges_nothing));
|
|
await SeedAsync(db);
|
|
(await Sut(db).PurgeAsync()).Should().Be(0);
|
|
(await db.Emails.CountAsync()).Should().Be(3);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Trashed_purge_removes_only_old_trashed_emails()
|
|
{
|
|
using var db = NewDb(nameof(Trashed_purge_removes_only_old_trashed_emails));
|
|
await SeedAsync(db);
|
|
(await Sut(db, trashedDays: 30).PurgeAsync()).Should().Be(1);
|
|
var remaining = await db.Emails.Select(e => e.GmailMessageId).ToListAsync();
|
|
remaining.Should().BeEquivalentTo(new[] { "new-trashed", "old-kept" });
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Age_purge_removes_everything_past_the_cutoff()
|
|
{
|
|
using var db = NewDb(nameof(Age_purge_removes_everything_past_the_cutoff));
|
|
await SeedAsync(db);
|
|
(await Sut(db, allDays: 30).PurgeAsync()).Should().Be(2); // both 100-day-old emails
|
|
(await db.Emails.SingleAsync()).GmailMessageId.Should().Be("new-trashed");
|
|
}
|
|
}
|