refactor(db): migrate email connections

Move Gmail, Microsoft Graph, and IMAP connection creation into an additive provider-aware migration. Preserve encrypted credentials and retain scoped historical repairs.
This commit is contained in:
cesnimda
2026-08-30 16:56:14 +02:00
parent 2e2d649f6f
commit 6c50e406b5
9 changed files with 274 additions and 163 deletions
@@ -105,6 +105,9 @@ public sealed class MigrationChainTests
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("CREATE TABLE IF NOT EXISTS `GmailConnections`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `MicrosoftGraphConnections`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `ImapConnections`", script, StringComparison.Ordinal);
Assert.Contains("`UiLanguage` varchar(16)", script, StringComparison.Ordinal);
Assert.All(
Regex.Matches(script, "CONSTRAINT `([^`]+)`").Select(match => match.Groups[1].Value),
@@ -303,6 +306,87 @@ public sealed class MigrationChainTests
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
}
[Fact]
public async Task Email_connection_adoption_preserves_encrypted_credentials()
{
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("20260830123000_AdoptAuthenticationSupportSchema");
await ExecuteAsync(connection, """
CREATE TABLE "GmailConnections" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_GmailConnections" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL, "GmailAddress" TEXT NOT NULL,
"EncryptedRefreshToken" TEXT NOT NULL, "EncryptedAccessToken" TEXT NULL,
"AccessTokenExpiresAt" TEXT NULL, "Scope" TEXT NOT NULL, "ConnectedAt" TEXT NOT NULL,
"LastSyncedAt" TEXT NULL, "LastSyncAttemptedAt" TEXT NULL,
"LastSyncSucceededAt" TEXT NULL, "LastSyncMode" TEXT NULL,
"LastSyncSource" TEXT NULL, "LastSyncStatus" TEXT NULL, "LastSyncError" TEXT NULL
);
INSERT INTO "GmailConnections"
("OwnerUserId", "GmailAddress", "EncryptedRefreshToken", "Scope", "ConnectedAt")
VALUES ('owner-fixture', 'gmail@example.invalid', 'gmail-secret', 'mail.read',
'2026-08-30T09:00:00+00:00');
CREATE TABLE "MicrosoftGraphConnections" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_MicrosoftGraphConnections" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL, "MailAddress" TEXT NOT NULL,
"EncryptedRefreshToken" TEXT NOT NULL, "EncryptedAccessToken" TEXT NULL,
"AccessTokenExpiresAt" TEXT NULL, "Scope" TEXT NOT NULL, "ConnectedAt" TEXT NOT NULL,
"LastSyncedAt" TEXT NULL, "LastSyncAttemptedAt" TEXT NULL,
"LastSyncSucceededAt" TEXT NULL, "LastSyncMode" TEXT NULL,
"LastSyncSource" TEXT NULL, "LastSyncStatus" TEXT NULL, "LastSyncError" TEXT NULL
);
INSERT INTO "MicrosoftGraphConnections"
("OwnerUserId", "MailAddress", "EncryptedRefreshToken", "Scope", "ConnectedAt")
VALUES ('owner-fixture', 'graph@example.invalid', 'graph-secret', 'Mail.Read',
'2026-08-30T09:00:00+00:00');
CREATE TABLE "ImapConnections" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_ImapConnections" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL, "Host" TEXT NOT NULL, "Port" INTEGER NOT NULL,
"UseSsl" INTEGER NOT NULL, "Username" TEXT NOT NULL, "EncryptedPassword" TEXT NOT NULL,
"ConnectedAt" TEXT NOT NULL, "LastSyncedAt" TEXT NULL,
"LastSyncAttemptedAt" TEXT NULL, "LastSyncSucceededAt" TEXT NULL,
"LastSyncMode" TEXT NULL, "LastSyncSource" TEXT NULL,
"LastSyncStatus" TEXT NULL, "LastSyncError" TEXT NULL
);
INSERT INTO "ImapConnections"
("OwnerUserId", "Host", "Port", "UseSsl", "Username", "EncryptedPassword", "ConnectedAt")
VALUES ('owner-fixture', 'imap.example.invalid', 993, 1, 'imap-user', 'imap-secret',
'2026-08-30T09:00:00+00:00');
""");
await migrator.MigrateAsync();
Assert.Equal("gmail-secret", await ScalarAsync<string>(connection,
"SELECT EncryptedRefreshToken FROM GmailConnections WHERE OwnerUserId = 'owner-fixture';"));
Assert.Equal("graph-secret", await ScalarAsync<string>(connection,
"SELECT EncryptedRefreshToken FROM MicrosoftGraphConnections WHERE OwnerUserId = 'owner-fixture';"));
Assert.Equal("imap-secret", await ScalarAsync<string>(connection,
"SELECT EncryptedPassword FROM ImapConnections WHERE OwnerUserId = 'owner-fixture';"));
await migrator.MigrateAsync("20260830123000_AdoptAuthenticationSupportSchema");
Assert.Equal("gmail-secret", await ScalarAsync<string>(connection,
"SELECT EncryptedRefreshToken FROM GmailConnections WHERE OwnerUserId = 'owner-fixture';"));
Assert.Equal("graph-secret", await ScalarAsync<string>(connection,
"SELECT EncryptedRefreshToken FROM MicrosoftGraphConnections WHERE OwnerUserId = 'owner-fixture';"));
Assert.Equal("imap-secret", await ScalarAsync<string>(connection,
"SELECT EncryptedPassword FROM ImapConnections WHERE OwnerUserId = 'owner-fixture';"));
await migrator.MigrateAsync();
Assert.Equal(5L, await ScalarAsync<long>(connection, """
SELECT COUNT(*) FROM sqlite_master
WHERE type = 'index' AND name IN (
'IX_GmailConnections_OwnerUserId',
'IX_GmailConnections_OwnerUserId_GmailAddress',
'IX_MicrosoftGraphConnections_OwnerUserId',
'IX_MicrosoftGraphConnections_OwnerUserId_MailAddress',
'IX_ImapConnections_OwnerUserId');
"""));
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
}
private static JobTrackerContext Context(SqliteConnection connection)
{
var currentUser = new Mock<ICurrentUserService>();