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:
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user