refactor(db): migrate auth support tables

Move recovery codes, trusted devices, and revocable sessions into an additive provider-aware migration. Preserve existing security records and retain guarded MariaDB repairs for historical schemas.
This commit is contained in:
cesnimda
2026-08-30 16:52:07 +02:00
parent b425f1edc5
commit 2e2d649f6f
9 changed files with 209 additions and 113 deletions
@@ -102,6 +102,9 @@ public sealed class MigrationChainTests
Assert.Contains("CREATE TABLE IF NOT EXISTS `SystemEmailSettings`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `UserRuleSettings`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `GmailReviewDecisions`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `TwoFactorRecoveryCodes`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `TrustedDevices`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `UserSessions`", script, StringComparison.Ordinal);
Assert.Contains("`UiLanguage` varchar(16)", script, StringComparison.Ordinal);
Assert.All(
Regex.Matches(script, "CONSTRAINT `([^`]+)`").Select(match => match.Groups[1].Value),
@@ -223,6 +226,83 @@ public sealed class MigrationChainTests
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
}
[Fact]
public async Task Authentication_support_adoption_preserves_legacy_rows()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
await using var db = Context(connection);
var migrator = db.GetService<IMigrator>();
await migrator.MigrateAsync("20260830122000_AdoptGmailReviewDecisionsSchema");
await ExecuteAsync(connection, """
CREATE TABLE "TwoFactorRecoveryCodes" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_TwoFactorRecoveryCodes" PRIMARY KEY AUTOINCREMENT,
"UserId" TEXT NOT NULL,
"CodeHash" TEXT NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"UsedAtUtc" TEXT NULL
);
INSERT INTO "TwoFactorRecoveryCodes" ("UserId", "CodeHash", "CreatedAtUtc")
VALUES ('owner-fixture', 'recovery-hash', '2026-08-30T09:00:00+00:00');
CREATE TABLE "TrustedDevices" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_TrustedDevices" PRIMARY KEY AUTOINCREMENT,
"UserId" TEXT NOT NULL,
"TokenHash" TEXT NOT NULL,
"DeviceLabel" TEXT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"LastSeenAtUtc" TEXT NOT NULL,
"ExpiresAtUtc" TEXT NOT NULL
);
INSERT INTO "TrustedDevices"
("UserId", "TokenHash", "DeviceLabel", "CreatedAtUtc", "LastSeenAtUtc", "ExpiresAtUtc")
VALUES
('owner-fixture', 'device-hash', 'Fixture browser', '2026-08-30T09:00:00+00:00',
'2026-08-30T09:30:00+00:00', '2026-09-29T09:00:00+00:00');
CREATE TABLE "UserSessions" (
"Id" TEXT NOT NULL CONSTRAINT "PK_UserSessions" PRIMARY KEY,
"UserId" TEXT NOT NULL,
"DeviceLabel" TEXT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"LastSeenAtUtc" TEXT NOT NULL,
"ExpiresAtUtc" TEXT NOT NULL,
"RevokedAtUtc" TEXT NULL
);
INSERT INTO "UserSessions"
("Id", "UserId", "DeviceLabel", "CreatedAtUtc", "LastSeenAtUtc", "ExpiresAtUtc")
VALUES
('session-fixture', 'owner-fixture', 'Fixture session', '2026-08-30T09:00:00+00:00',
'2026-08-30T09:30:00+00:00', '2026-08-31T09:00:00+00:00');
""");
await migrator.MigrateAsync();
Assert.Equal("recovery-hash", await ScalarAsync<string>(connection,
"SELECT CodeHash FROM TwoFactorRecoveryCodes WHERE UserId = 'owner-fixture';"));
Assert.Equal("Fixture browser", await ScalarAsync<string>(connection,
"SELECT DeviceLabel FROM TrustedDevices WHERE TokenHash = 'device-hash';"));
Assert.Equal("Fixture session", await ScalarAsync<string>(connection,
"SELECT DeviceLabel FROM UserSessions WHERE Id = 'session-fixture';"));
await migrator.MigrateAsync("20260830122000_AdoptGmailReviewDecisionsSchema");
Assert.Equal("recovery-hash", await ScalarAsync<string>(connection,
"SELECT CodeHash FROM TwoFactorRecoveryCodes WHERE UserId = 'owner-fixture';"));
Assert.Equal("device-hash", await ScalarAsync<string>(connection,
"SELECT TokenHash FROM TrustedDevices WHERE UserId = 'owner-fixture';"));
Assert.Equal("session-fixture", await ScalarAsync<string>(connection,
"SELECT Id FROM UserSessions WHERE UserId = 'owner-fixture';"));
await migrator.MigrateAsync();
Assert.Equal(3L, await ScalarAsync<long>(connection, """
SELECT COUNT(*) FROM sqlite_master
WHERE type = 'index' AND name IN (
'IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc',
'IX_TrustedDevices_UserId',
'IX_UserSessions_UserId');
"""));
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
}
private static JobTrackerContext Context(SqliteConnection connection)
{
var currentUser = new Mock<ICurrentUserService>();