Skip to content

Commit b244b16

Browse files
authored
Merge pull request #967 from Chris0Jeky/feat/ops-31-audit-retention-policy
Add audit trail retention policy with configurable cleanup
2 parents 18fd862 + 0d3b20d commit b244b16

2 files changed

Lines changed: 43 additions & 1 deletion

File tree

backend/src/Taskdeck.Infrastructure/Repositories/AuditLogRepository.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,11 @@ public async Task<int> DeleteOldEntriesAsync(DateTimeOffset olderThan, int batch
265265
// SQLite stores DateTimeOffset as a string in "yyyy-MM-dd HH:mm:ss.fffffff+HH:mm" format.
266266
// We must format the cutoff identically so the < comparison (string ordering) works correctly.
267267
// See OAuthAuthCodeRepository.DeleteExpiredAsync for the same pattern.
268-
var olderThanStr = olderThan.ToString("yyyy-MM-dd HH:mm:ss.fffffff+00:00");
268+
// Normalize to UTC first so that a non-UTC DateTimeOffset (e.g. -05:00) is converted to
269+
// its UTC-equivalent time before the "+00:00" suffix is appended; without this the time
270+
// component would be wrong and entries in the wrong window could be kept or deleted.
271+
var utcCutoff = olderThan.UtcDateTime;
272+
var olderThanStr = utcCutoff.ToString("yyyy-MM-dd HH:mm:ss.fffffff") + "+00:00";
269273
deleted = await _context.Database.ExecuteSqlRawAsync(
270274
"DELETE FROM AuditLogs WHERE Id IN (SELECT Id FROM AuditLogs WHERE Timestamp < {0} ORDER BY Timestamp ASC LIMIT {1})",
271275
new object[] { olderThanStr, batchSize },

backend/tests/Taskdeck.Api.Tests/AuditRetentionRepositoryIntegrationTests.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,4 +140,42 @@ public async Task DeleteOldEntriesAsync_CompletesGracefully_WhenNothingToDelete(
140140

141141
deleted.Should().Be(0);
142142
}
143+
144+
[Fact]
145+
public async Task DeleteOldEntriesAsync_WithNonUtcCutoff_DeletesCorrectEntries()
146+
{
147+
// Regression test for: DateTimeOffset with non-zero UTC offset must be normalized
148+
// to UTC before formatting as a SQLite timestamp string. Without normalization,
149+
// a cutoff of e.g. 2026-01-01T00:00:00-05:00 would be formatted as
150+
// "2026-01-01 00:00:00+00:00" instead of "2026-01-01 05:00:00+00:00",
151+
// causing entries in the wrong five-hour window to be kept or deleted.
152+
using var scope = _factory.Services.CreateScope();
153+
var db = scope.ServiceProvider.GetRequiredService<TaskdeckDbContext>();
154+
var repo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
155+
156+
var oldEntry = new AuditLog("Board", Guid.NewGuid(), AuditAction.Created);
157+
var recentEntry = new AuditLog("Board", Guid.NewGuid(), AuditAction.Created);
158+
db.AuditLogs.AddRange(oldEntry, recentEntry);
159+
await db.SaveChangesAsync();
160+
161+
// Backdate the old entry to well before the cutoff
162+
var oldTimestampStr = DateTimeOffset.UtcNow.AddDays(-100).ToString("yyyy-MM-dd HH:mm:ss.fffffff+00:00");
163+
await db.Database.ExecuteSqlRawAsync(
164+
"UPDATE AuditLogs SET Timestamp = {0} WHERE Id = {1}",
165+
oldTimestampStr, oldEntry.Id);
166+
167+
// Build a non-UTC cutoff that represents the same instant as "30 days ago UTC"
168+
// expressed in Eastern Standard Time (-05:00). The repository must convert to
169+
// UTC before building the SQLite comparison string.
170+
var utcBase = DateTimeOffset.UtcNow.AddDays(-30);
171+
var nonUtcCutoff = utcBase.ToOffset(TimeSpan.FromHours(-5));
172+
var deleted = await repo.DeleteOldEntriesAsync(nonUtcCutoff, batchSize: 1000);
173+
174+
deleted.Should().BeGreaterOrEqualTo(1,
175+
"entries older than the UTC-equivalent cutoff should be deleted regardless of the cutoff offset");
176+
177+
// The recent entry (created just now) must survive
178+
var preserved = await repo.GetByIdAsync(recentEntry.Id);
179+
preserved.Should().NotBeNull("the recent entry must not be deleted when a non-UTC cutoff is used");
180+
}
143181
}

0 commit comments

Comments
 (0)