refactor(db): migrate Identity schema

Complete schema ownership by moving all ASP.NET Identity tables to an additive provider-aware migration. Preserve credentials, security state, preferences, roles, claims, external logins, tokens, indexes, and cascades.
This commit is contained in:
cesnimda
2026-08-30 22:02:35 +02:00
parent 90ca23de33
commit 058d13de47
10 changed files with 311 additions and 261 deletions
@@ -126,6 +126,11 @@ public sealed class MigrationChainTests
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerProjects`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerCertifications`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerLanguages`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetRoles`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetUserClaims`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetUserLogins`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetUserRoles`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `AspNetUserTokens`", script, StringComparison.Ordinal);
Assert.Contains("`UiLanguage` varchar(16)", script, StringComparison.Ordinal);
Assert.All(
Regex.Matches(script, "CONSTRAINT `([^`]+)`").Select(match => match.Groups[1].Value),
@@ -1015,6 +1020,89 @@ public sealed class MigrationChainTests
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
}
[Fact]
public async Task Identity_adoption_preserves_accounts_credentials_roles_logins_claims_and_tokens()
{
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("20260830133000_AdoptCareerProfileSchema");
await ExecuteAsync(connection, """
CREATE TABLE "AspNetRoles" (
"Id" TEXT NOT NULL PRIMARY KEY, "Name" TEXT NULL,
"NormalizedName" TEXT NULL, "ConcurrencyStamp" TEXT NULL);
CREATE TABLE "AspNetRoleClaims" (
"Id" INTEGER PRIMARY KEY AUTOINCREMENT, "RoleId" TEXT NOT NULL,
"ClaimType" TEXT NULL, "ClaimValue" TEXT NULL,
FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE);
CREATE TABLE "AspNetUserClaims" (
"Id" INTEGER PRIMARY KEY AUTOINCREMENT, "UserId" TEXT NOT NULL,
"ClaimType" TEXT NULL, "ClaimValue" TEXT NULL,
FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE);
CREATE TABLE "AspNetUserLogins" (
"LoginProvider" TEXT NOT NULL, "ProviderKey" TEXT NOT NULL,
"ProviderDisplayName" TEXT NULL, "UserId" TEXT NOT NULL,
PRIMARY KEY ("LoginProvider", "ProviderKey"),
FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE);
CREATE TABLE "AspNetUserRoles" (
"UserId" TEXT NOT NULL, "RoleId" TEXT NOT NULL,
PRIMARY KEY ("UserId", "RoleId"),
FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE,
FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE);
CREATE TABLE "AspNetUserTokens" (
"UserId" TEXT NOT NULL, "LoginProvider" TEXT NOT NULL, "Name" TEXT NOT NULL,
"Value" TEXT NULL, PRIMARY KEY ("UserId", "LoginProvider", "Name"),
FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE);
INSERT INTO "AspNetUsers"
("Id", "UserName", "NormalizedUserName", "Email", "NormalizedEmail",
"EmailConfirmed", "PasswordHash", "PhoneNumberConfirmed", "TwoFactorEnabled",
"LockoutEnabled", "AccessFailedCount", "AiEnabled", "ExternalAiProcessingAllowed",
"DeletionStatus", "EmailFollowUpRemindersEnabled", "UiLanguage")
VALUES ('user-fixture', 'connor', 'CONNOR', 'connor@example.test', 'CONNOR@EXAMPLE.TEST',
1, 'preserved-password-hash', 0, 1, 1, 0, 1, 0, 'active', 1, 'nb');
INSERT INTO "AspNetRoles" VALUES ('role-admin', 'Admin', 'ADMIN', 'role-stamp');
INSERT INTO "AspNetRoleClaims" ("RoleId", "ClaimType", "ClaimValue")
VALUES ('role-admin', 'permission', 'admin.manage');
INSERT INTO "AspNetUserClaims" ("UserId", "ClaimType", "ClaimValue")
VALUES ('user-fixture', 'locale', 'nb');
INSERT INTO "AspNetUserLogins" VALUES ('google', 'subject-1', 'Google', 'user-fixture');
INSERT INTO "AspNetUserRoles" VALUES ('user-fixture', 'role-admin');
INSERT INTO "AspNetUserTokens" VALUES ('user-fixture', 'authenticator', 'refresh', 'encrypted-token');
""");
await migrator.MigrateAsync();
Assert.Equal("preserved-password-hash", await ScalarAsync<string>(connection,
"SELECT PasswordHash FROM AspNetUsers WHERE Id = 'user-fixture';"));
Assert.Equal("nb", await ScalarAsync<string>(connection,
"SELECT UiLanguage FROM AspNetUsers WHERE Id = 'user-fixture';"));
Assert.Equal("admin.manage", await ScalarAsync<string>(connection,
"SELECT ClaimValue FROM AspNetRoleClaims WHERE RoleId = 'role-admin';"));
Assert.Equal("encrypted-token", await ScalarAsync<string>(connection,
"SELECT Value FROM AspNetUserTokens WHERE UserId = 'user-fixture';"));
await migrator.MigrateAsync("20260830133000_AdoptCareerProfileSchema");
Assert.Equal("subject-1", await ScalarAsync<string>(connection,
"SELECT ProviderKey FROM AspNetUserLogins WHERE UserId = 'user-fixture';"));
await migrator.MigrateAsync();
Assert.Equal(8L, await ScalarAsync<long>(connection, """
SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name IN (
'RoleNameIndex', 'IX_AspNetRoleClaims_RoleId', 'EmailIndex', 'UserNameIndex',
'IX_AspNetUsers_MicrosoftTenantId_MicrosoftObjectId',
'IX_AspNetUserClaims_UserId', 'IX_AspNetUserLogins_UserId', 'IX_AspNetUserRoles_RoleId');
"""));
await ExecuteAsync(connection, "DELETE FROM AspNetUsers WHERE Id = 'user-fixture';");
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM AspNetUserClaims;"));
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM AspNetUserLogins;"));
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM AspNetUserRoles;"));
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM AspNetUserTokens;"));
await ExecuteAsync(connection, "DELETE FROM AspNetRoles WHERE Id = 'role-admin';");
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM AspNetRoleClaims;"));
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
}
private static JobTrackerContext Context(SqliteConnection connection)
{
var currentUser = new Mock<ICurrentUserService>();
@@ -39,12 +39,12 @@ public sealed class StartupSchemaOwnershipTests
}
[Fact]
public void Compatibility_bootstraps_remain_reconciler_owned_until_migrated()
public void Compatibility_bootstraps_do_not_create_a_second_current_owner()
{
Assert.All(
StartupSchemaOwnership.MigrationCompatibilityBootstrapTables,
table => Assert.Contains(table, StartupSchemaOwnership.ReconcilerOwnedTables));
Assert.Empty(StartupSchemaOwnership.MigrationCompatibilityBootstrapTables
.Intersect(StartupSchemaOwnership.MigrationOwnedTables, StringComparer.Ordinal));
table => Assert.True(
StartupSchemaOwnership.MigrationOwnedTables.Contains(table) ^
StartupSchemaOwnership.ReconcilerOwnedTables.Contains(table)));
}
}