diff --git a/JobTrackerApi.Tests/MigrationChainTests.cs b/JobTrackerApi.Tests/MigrationChainTests.cs index 9929e9d..98ba3d0 100644 --- a/JobTrackerApi.Tests/MigrationChainTests.cs +++ b/JobTrackerApi.Tests/MigrationChainTests.cs @@ -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(); + 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(connection, + "SELECT PasswordHash FROM AspNetUsers WHERE Id = 'user-fixture';")); + Assert.Equal("nb", await ScalarAsync(connection, + "SELECT UiLanguage FROM AspNetUsers WHERE Id = 'user-fixture';")); + Assert.Equal("admin.manage", await ScalarAsync(connection, + "SELECT ClaimValue FROM AspNetRoleClaims WHERE RoleId = 'role-admin';")); + Assert.Equal("encrypted-token", await ScalarAsync(connection, + "SELECT Value FROM AspNetUserTokens WHERE UserId = 'user-fixture';")); + + await migrator.MigrateAsync("20260830133000_AdoptCareerProfileSchema"); + Assert.Equal("subject-1", await ScalarAsync(connection, + "SELECT ProviderKey FROM AspNetUserLogins WHERE UserId = 'user-fixture';")); + await migrator.MigrateAsync(); + Assert.Equal(8L, await ScalarAsync(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(connection, "SELECT COUNT(*) FROM AspNetUserClaims;")); + Assert.Equal(0L, await ScalarAsync(connection, "SELECT COUNT(*) FROM AspNetUserLogins;")); + Assert.Equal(0L, await ScalarAsync(connection, "SELECT COUNT(*) FROM AspNetUserRoles;")); + Assert.Equal(0L, await ScalarAsync(connection, "SELECT COUNT(*) FROM AspNetUserTokens;")); + await ExecuteAsync(connection, "DELETE FROM AspNetRoles WHERE Id = 'role-admin';"); + Assert.Equal(0L, await ScalarAsync(connection, "SELECT COUNT(*) FROM AspNetRoleClaims;")); + Assert.Empty(await db.Database.GetPendingMigrationsAsync()); + } + private static JobTrackerContext Context(SqliteConnection connection) { var currentUser = new Mock(); diff --git a/JobTrackerApi.Tests/StartupSchemaOwnershipTests.cs b/JobTrackerApi.Tests/StartupSchemaOwnershipTests.cs index a3f10c6..ac5edd5 100644 --- a/JobTrackerApi.Tests/StartupSchemaOwnershipTests.cs +++ b/JobTrackerApi.Tests/StartupSchemaOwnershipTests.cs @@ -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))); } } diff --git a/JobTrackerApi/Migrations/20260830134000_AdoptIdentitySchema.cs b/JobTrackerApi/Migrations/20260830134000_AdoptIdentitySchema.cs new file mode 100644 index 0000000..00c9674 --- /dev/null +++ b/JobTrackerApi/Migrations/20260830134000_AdoptIdentitySchema.cs @@ -0,0 +1,173 @@ +using System; +using JobTrackerApi.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobTrackerApi.Migrations; + +/// +/// Establishes one migration owner for the ASP.NET Identity aggregate while preserving all +/// existing users, credentials, roles, claims, external logins, and tokens. +/// +[DbContext(typeof(JobTrackerContext))] +[Migration("20260830134000_AdoptIdentitySchema")] +public sealed class AdoptIdentitySchema : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase) + ? MySqlDdl + : SqliteDdl); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + // Identity data may predate this migration; downgrade must never remove accounts. + } + + private const string SqliteDdl = """ + CREATE TABLE IF NOT EXISTS "AspNetRoles" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_AspNetRoles" PRIMARY KEY, + "Name" TEXT NULL, "NormalizedName" TEXT NULL, "ConcurrencyStamp" TEXT NULL); + CREATE TABLE IF NOT EXISTS "AspNetUsers" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_AspNetUsers" PRIMARY KEY, + "UserName" TEXT NULL, "NormalizedUserName" TEXT NULL, + "Email" TEXT NULL, "NormalizedEmail" TEXT NULL, "EmailConfirmed" INTEGER NOT NULL, + "PasswordHash" TEXT NULL, "SecurityStamp" TEXT NULL, "ConcurrencyStamp" TEXT NULL, + "PhoneNumber" TEXT NULL, "PhoneNumberConfirmed" INTEGER NOT NULL, + "TwoFactorEnabled" INTEGER NOT NULL, "LockoutEnd" TEXT NULL, + "LockoutEnabled" INTEGER NOT NULL, "AccessFailedCount" INTEGER NOT NULL, + "FirstName" TEXT NULL, "LastName" TEXT NULL, "DisplayName" TEXT NULL, + "ProfileCvText" TEXT NULL, "ProfileCvStructureJson" TEXT NULL, + "CurrentCvUploadArtifactId" INTEGER NULL, "CurrentCvExtractionRunId" INTEGER NULL, + "CurrentCvProfileVersion" INTEGER NULL, "AvatarImageDataUrl" TEXT NULL, + "GoogleSubject" TEXT NULL, "GoogleEmail" TEXT NULL, "GoogleLinkedAt" TEXT NULL, + "MicrosoftSubject" TEXT NULL, "MicrosoftEmail" TEXT NULL, "MicrosoftLinkedAt" TEXT NULL, + "MicrosoftTenantId" TEXT NULL, "MicrosoftObjectId" TEXT NULL, + "TotpSecretEncrypted" TEXT NULL, "TotpPendingSecretEncrypted" TEXT NULL, + "TotpEnabledAtUtc" TEXT NULL, "PendingEmail" TEXT NULL, + "PendingEmailRequestedAtUtc" TEXT NULL, "AiEnabled" INTEGER NOT NULL DEFAULT 1, + "ExternalAiProcessingAllowed" INTEGER NOT NULL DEFAULT 0, + "StripeCustomerId" TEXT NULL, "StripeSubscriptionId" TEXT NULL, + "StripeSubscriptionStatus" TEXT NULL, "StripeLastEventCreatedUtc" TEXT NULL, + "DeletionRequestedAtUtc" TEXT NULL, "DeletionStatus" TEXT NOT NULL DEFAULT 'active', + "EmailFollowUpRemindersEnabled" INTEGER NOT NULL DEFAULT 1, "UiLanguage" TEXT NULL); + CREATE TABLE IF NOT EXISTS "AspNetRoleClaims" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_AspNetRoleClaims" PRIMARY KEY AUTOINCREMENT, + "RoleId" TEXT NOT NULL, "ClaimType" TEXT NULL, "ClaimValue" TEXT NULL, + CONSTRAINT "FK_AspNetRoleClaims_AspNetRoles_RoleId" + FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE); + CREATE TABLE IF NOT EXISTS "AspNetUserClaims" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_AspNetUserClaims" PRIMARY KEY AUTOINCREMENT, + "UserId" TEXT NOT NULL, "ClaimType" TEXT NULL, "ClaimValue" TEXT NULL, + CONSTRAINT "FK_AspNetUserClaims_AspNetUsers_UserId" + FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE); + CREATE TABLE IF NOT EXISTS "AspNetUserLogins" ( + "LoginProvider" TEXT NOT NULL, "ProviderKey" TEXT NOT NULL, + "ProviderDisplayName" TEXT NULL, "UserId" TEXT NOT NULL, + CONSTRAINT "PK_AspNetUserLogins" PRIMARY KEY ("LoginProvider", "ProviderKey"), + CONSTRAINT "FK_AspNetUserLogins_AspNetUsers_UserId" + FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE); + CREATE TABLE IF NOT EXISTS "AspNetUserRoles" ( + "UserId" TEXT NOT NULL, "RoleId" TEXT NOT NULL, + CONSTRAINT "PK_AspNetUserRoles" PRIMARY KEY ("UserId", "RoleId"), + CONSTRAINT "FK_AspNetUserRoles_AspNetRoles_RoleId" + FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_AspNetUserRoles_AspNetUsers_UserId" + FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE); + CREATE TABLE IF NOT EXISTS "AspNetUserTokens" ( + "UserId" TEXT NOT NULL, "LoginProvider" TEXT NOT NULL, "Name" TEXT NOT NULL, + "Value" TEXT NULL, + CONSTRAINT "PK_AspNetUserTokens" PRIMARY KEY ("UserId", "LoginProvider", "Name"), + CONSTRAINT "FK_AspNetUserTokens_AspNetUsers_UserId" + FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE); + CREATE UNIQUE INDEX IF NOT EXISTS "RoleNameIndex" ON "AspNetRoles" ("NormalizedName"); + CREATE INDEX IF NOT EXISTS "IX_AspNetRoleClaims_RoleId" ON "AspNetRoleClaims" ("RoleId"); + CREATE INDEX IF NOT EXISTS "EmailIndex" ON "AspNetUsers" ("NormalizedEmail"); + CREATE UNIQUE INDEX IF NOT EXISTS "UserNameIndex" ON "AspNetUsers" ("NormalizedUserName"); + CREATE UNIQUE INDEX IF NOT EXISTS "IX_AspNetUsers_MicrosoftTenantId_MicrosoftObjectId" + ON "AspNetUsers" ("MicrosoftTenantId", "MicrosoftObjectId"); + CREATE INDEX IF NOT EXISTS "IX_AspNetUserClaims_UserId" ON "AspNetUserClaims" ("UserId"); + CREATE INDEX IF NOT EXISTS "IX_AspNetUserLogins_UserId" ON "AspNetUserLogins" ("UserId"); + CREATE INDEX IF NOT EXISTS "IX_AspNetUserRoles_RoleId" ON "AspNetUserRoles" ("RoleId"); + """; + + private const string MySqlDdl = """ + CREATE TABLE IF NOT EXISTS `AspNetRoles` ( + `Id` varchar(255) NOT NULL, `Name` varchar(256) NULL, + `NormalizedName` varchar(256) NULL, `ConcurrencyStamp` longtext NULL, + PRIMARY KEY (`Id`)) CHARACTER SET=utf8mb4; + CREATE TABLE IF NOT EXISTS `AspNetUsers` ( + `Id` varchar(255) NOT NULL, `UserName` varchar(256) NULL, + `NormalizedUserName` varchar(256) NULL, `Email` varchar(256) NULL, + `NormalizedEmail` varchar(256) NULL, `EmailConfirmed` tinyint(1) NOT NULL, + `PasswordHash` longtext NULL, `SecurityStamp` longtext NULL, + `ConcurrencyStamp` longtext NULL, `PhoneNumber` longtext NULL, + `PhoneNumberConfirmed` tinyint(1) NOT NULL, `TwoFactorEnabled` tinyint(1) NOT NULL, + `LockoutEnd` datetime(6) NULL, `LockoutEnabled` tinyint(1) NOT NULL, + `AccessFailedCount` int NOT NULL, `FirstName` longtext NULL, `LastName` longtext NULL, + `DisplayName` longtext NULL, `ProfileCvText` longtext NULL, + `ProfileCvStructureJson` longtext NULL, `CurrentCvUploadArtifactId` int NULL, + `CurrentCvExtractionRunId` int NULL, `CurrentCvProfileVersion` int NULL, + `AvatarImageDataUrl` longtext NULL, `GoogleSubject` longtext NULL, + `GoogleEmail` longtext NULL, `GoogleLinkedAt` datetime(6) NULL, + `MicrosoftSubject` longtext NULL, `MicrosoftEmail` longtext NULL, + `MicrosoftLinkedAt` datetime(6) NULL, `MicrosoftTenantId` varchar(36) NULL, + `MicrosoftObjectId` varchar(36) NULL, `TotpSecretEncrypted` longtext NULL, + `TotpPendingSecretEncrypted` longtext NULL, `TotpEnabledAtUtc` datetime(6) NULL, + `PendingEmail` varchar(320) NULL, `PendingEmailRequestedAtUtc` datetime(6) NULL, + `AiEnabled` tinyint(1) NOT NULL DEFAULT 1, + `ExternalAiProcessingAllowed` tinyint(1) NOT NULL DEFAULT 0, + `StripeCustomerId` varchar(255) NULL, `StripeSubscriptionId` varchar(255) NULL, + `StripeSubscriptionStatus` varchar(64) NULL, `StripeLastEventCreatedUtc` datetime(6) NULL, + `DeletionRequestedAtUtc` datetime(6) NULL, + `DeletionStatus` varchar(32) NOT NULL DEFAULT 'active', + `EmailFollowUpRemindersEnabled` tinyint(1) NOT NULL DEFAULT 1, + `UiLanguage` varchar(16) NULL, PRIMARY KEY (`Id`)) CHARACTER SET=utf8mb4; + CREATE TABLE IF NOT EXISTS `AspNetRoleClaims` ( + `Id` int NOT NULL AUTO_INCREMENT, `RoleId` varchar(255) NOT NULL, + `ClaimType` longtext NULL, `ClaimValue` longtext NULL, PRIMARY KEY (`Id`), + CONSTRAINT `FK_AspNetRoleClaims_AspNetRoles_RoleId` + FOREIGN KEY (`RoleId`) REFERENCES `AspNetRoles` (`Id`) ON DELETE CASCADE) + CHARACTER SET=utf8mb4; + CREATE TABLE IF NOT EXISTS `AspNetUserClaims` ( + `Id` int NOT NULL AUTO_INCREMENT, `UserId` varchar(255) NOT NULL, + `ClaimType` longtext NULL, `ClaimValue` longtext NULL, PRIMARY KEY (`Id`), + CONSTRAINT `FK_AspNetUserClaims_AspNetUsers_UserId` + FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE) + CHARACTER SET=utf8mb4; + CREATE TABLE IF NOT EXISTS `AspNetUserLogins` ( + `LoginProvider` varchar(255) NOT NULL, `ProviderKey` varchar(255) NOT NULL, + `ProviderDisplayName` longtext NULL, `UserId` varchar(255) NOT NULL, + PRIMARY KEY (`LoginProvider`, `ProviderKey`), + CONSTRAINT `FK_AspNetUserLogins_AspNetUsers_UserId` + FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE) + CHARACTER SET=utf8mb4; + CREATE TABLE IF NOT EXISTS `AspNetUserRoles` ( + `UserId` varchar(255) NOT NULL, `RoleId` varchar(255) NOT NULL, + PRIMARY KEY (`UserId`, `RoleId`), + CONSTRAINT `FK_AspNetUserRoles_AspNetRoles_RoleId` + FOREIGN KEY (`RoleId`) REFERENCES `AspNetRoles` (`Id`) ON DELETE CASCADE, + CONSTRAINT `FK_AspNetUserRoles_AspNetUsers_UserId` + FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE) + CHARACTER SET=utf8mb4; + CREATE TABLE IF NOT EXISTS `AspNetUserTokens` ( + `UserId` varchar(255) NOT NULL, `LoginProvider` varchar(255) NOT NULL, + `Name` varchar(255) NOT NULL, `Value` longtext NULL, + PRIMARY KEY (`UserId`, `LoginProvider`, `Name`), + CONSTRAINT `FK_AspNetUserTokens_AspNetUsers_UserId` + FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE) + CHARACTER SET=utf8mb4; + CREATE UNIQUE INDEX IF NOT EXISTS `RoleNameIndex` ON `AspNetRoles` (`NormalizedName`); + CREATE INDEX IF NOT EXISTS `IX_AspNetRoleClaims_RoleId` ON `AspNetRoleClaims` (`RoleId`); + CREATE INDEX IF NOT EXISTS `EmailIndex` ON `AspNetUsers` (`NormalizedEmail`); + CREATE UNIQUE INDEX IF NOT EXISTS `UserNameIndex` ON `AspNetUsers` (`NormalizedUserName`); + CREATE UNIQUE INDEX IF NOT EXISTS `IX_AspNetUsers_MicrosoftTenantId_MicrosoftObjectId` + ON `AspNetUsers` (`MicrosoftTenantId`, `MicrosoftObjectId`); + CREATE INDEX IF NOT EXISTS `IX_AspNetUserClaims_UserId` ON `AspNetUserClaims` (`UserId`); + CREATE INDEX IF NOT EXISTS `IX_AspNetUserLogins_UserId` ON `AspNetUserLogins` (`UserId`); + CREATE INDEX IF NOT EXISTS `IX_AspNetUserRoles_RoleId` ON `AspNetUserRoles` (`RoleId`); + """; +} diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index bc765c1..862f722 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -267,118 +267,13 @@ public static class StartupInitializationExtensions var provider = (app.Configuration["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant(); var useSqliteBootstrap = provider is not "mysql" and not "mariadb"; - static void EnsureIdentityTablesMySql(DbConnection c) - { - using var cmd = c.CreateCommand(); - cmd.CommandText = @" - CREATE TABLE IF NOT EXISTS `AspNetRoles` ( - `Id` varchar(255) NOT NULL, - `Name` varchar(256) NULL, - `NormalizedName` varchar(256) NULL, - `ConcurrencyStamp` longtext NULL, - PRIMARY KEY (`Id`) - ) CHARACTER SET=utf8mb4; - - CREATE TABLE IF NOT EXISTS `AspNetUsers` ( - `Id` varchar(255) NOT NULL, - `UserName` varchar(256) NULL, - `NormalizedUserName` varchar(256) NULL, - `Email` varchar(256) NULL, - `NormalizedEmail` varchar(256) NULL, - `EmailConfirmed` tinyint(1) NOT NULL, - `PasswordHash` longtext NULL, - `SecurityStamp` longtext NULL, - `ConcurrencyStamp` longtext NULL, - `PhoneNumber` longtext NULL, - `PhoneNumberConfirmed` tinyint(1) NOT NULL, - `TwoFactorEnabled` tinyint(1) NOT NULL, - `LockoutEnd` datetime(6) NULL, - `LockoutEnabled` tinyint(1) NOT NULL, - `AccessFailedCount` int NOT NULL, - `FirstName` longtext NULL, - `LastName` longtext NULL, - `DisplayName` longtext NULL, - `ProfileCvText` longtext NULL, - `ProfileCvStructureJson` longtext NULL, - `CurrentCvUploadArtifactId` int NULL, - `CurrentCvExtractionRunId` int NULL, - `CurrentCvProfileVersion` int NULL, - `AvatarImageDataUrl` longtext NULL, - `GoogleSubject` longtext NULL, - `GoogleEmail` longtext NULL, - `GoogleLinkedAt` datetime(6) NULL, - `MicrosoftSubject` longtext NULL, - `MicrosoftEmail` longtext NULL, - `MicrosoftLinkedAt` datetime(6) NULL, - `TotpSecretEncrypted` longtext NULL, - `TotpPendingSecretEncrypted` longtext NULL, - `TotpEnabledAtUtc` datetime(6) NULL, - PRIMARY KEY (`Id`) - ) CHARACTER SET=utf8mb4; - - CREATE TABLE IF NOT EXISTS `AspNetRoleClaims` ( - `Id` int NOT NULL AUTO_INCREMENT, - `RoleId` varchar(255) NOT NULL, - `ClaimType` longtext NULL, - `ClaimValue` longtext NULL, - PRIMARY KEY (`Id`), - CONSTRAINT `FK_AspNetRoleClaims_AspNetRoles_RoleId` FOREIGN KEY (`RoleId`) REFERENCES `AspNetRoles` (`Id`) ON DELETE CASCADE - ) CHARACTER SET=utf8mb4; - - CREATE TABLE IF NOT EXISTS `AspNetUserClaims` ( - `Id` int NOT NULL AUTO_INCREMENT, - `UserId` varchar(255) NOT NULL, - `ClaimType` longtext NULL, - `ClaimValue` longtext NULL, - PRIMARY KEY (`Id`), - CONSTRAINT `FK_AspNetUserClaims_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE - ) CHARACTER SET=utf8mb4; - - CREATE TABLE IF NOT EXISTS `AspNetUserLogins` ( - `LoginProvider` varchar(255) NOT NULL, - `ProviderKey` varchar(255) NOT NULL, - `ProviderDisplayName` longtext NULL, - `UserId` varchar(255) NOT NULL, - PRIMARY KEY (`LoginProvider`, `ProviderKey`), - CONSTRAINT `FK_AspNetUserLogins_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE - ) CHARACTER SET=utf8mb4; - - CREATE TABLE IF NOT EXISTS `AspNetUserRoles` ( - `UserId` varchar(255) NOT NULL, - `RoleId` varchar(255) NOT NULL, - PRIMARY KEY (`UserId`, `RoleId`), - CONSTRAINT `FK_AspNetUserRoles_AspNetRoles_RoleId` FOREIGN KEY (`RoleId`) REFERENCES `AspNetRoles` (`Id`) ON DELETE CASCADE, - CONSTRAINT `FK_AspNetUserRoles_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE - ) CHARACTER SET=utf8mb4; - - CREATE TABLE IF NOT EXISTS `AspNetUserTokens` ( - `UserId` varchar(255) NOT NULL, - `LoginProvider` varchar(255) NOT NULL, - `Name` varchar(255) NOT NULL, - `Value` longtext NULL, - PRIMARY KEY (`UserId`, `LoginProvider`, `Name`), - CONSTRAINT `FK_AspNetUserTokens_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE - ) CHARACTER SET=utf8mb4; - - CREATE UNIQUE INDEX IF NOT EXISTS `RoleNameIndex` ON `AspNetRoles` (`NormalizedName`); - CREATE INDEX IF NOT EXISTS `IX_AspNetRoleClaims_RoleId` ON `AspNetRoleClaims` (`RoleId`); - CREATE INDEX IF NOT EXISTS `EmailIndex` ON `AspNetUsers` (`NormalizedEmail`); - CREATE UNIQUE INDEX IF NOT EXISTS `UserNameIndex` ON `AspNetUsers` (`NormalizedUserName`); - CREATE INDEX IF NOT EXISTS `IX_AspNetUserClaims_UserId` ON `AspNetUserClaims` (`UserId`); - CREATE INDEX IF NOT EXISTS `IX_AspNetUserLogins_UserId` ON `AspNetUserLogins` (`UserId`); - CREATE INDEX IF NOT EXISTS `IX_AspNetUserRoles_RoleId` ON `AspNetUserRoles` (`RoleId`); - "; - cmd.ExecuteNonQuery(); - } - // The schema reconciler. Runs TWICE: once before Migrate() and once after. // - // Before: legacy databases need their Identity tables and hand-added columns present, and - // the legacy migration-history stamp written, or Migrate() collides with them. + // Before: legacy databases need hand-added columns repaired and the legacy + // migration-history stamp written, or Migrate() can collide with them. // After: on a brand-new database the migration-owned tables did not exist during the first - // pass, so everything that depends on them (reconciler-owned tables that FK into - // JobApplications, index and AUTO_INCREMENT repairs) was skipped. The second pass finds them - // and finishes the job. + // pass, so index, column, and AUTO_INCREMENT repairs were skipped. The second pass finds + // the migration-created tables and finishes compatibility repair. // // Every statement in here is existence-guarded, so running it twice is a no-op scan on an // already-correct database. docs/infrastructure/database-ownership.md. @@ -398,119 +293,6 @@ public static class StartupInitializationExtensions // ReconcileSchema runs twice; the second pass may inherit an already-open connection. if (conn.State != System.Data.ConnectionState.Open) conn.Open(); - static void EnsureIdentityTables(DbConnection c) - { - // EF migrations are used for the app schema. In some environments `dotnet ef` isn’t available, - // so create the ASP.NET Core Identity tables directly if they don’t exist yet. - Exec(c, """ - CREATE TABLE IF NOT EXISTS "AspNetRoles" ( - "Id" TEXT NOT NULL CONSTRAINT "PK_AspNetRoles" PRIMARY KEY, - "Name" TEXT NULL, - "NormalizedName" TEXT NULL, - "ConcurrencyStamp" TEXT NULL - ); - """); - - Exec(c, """ - CREATE TABLE IF NOT EXISTS "AspNetUsers" ( - "Id" TEXT NOT NULL CONSTRAINT "PK_AspNetUsers" PRIMARY KEY, - "UserName" TEXT NULL, - "NormalizedUserName" TEXT NULL, - "Email" TEXT NULL, - "NormalizedEmail" TEXT NULL, - "EmailConfirmed" INTEGER NOT NULL, - "PasswordHash" TEXT NULL, - "SecurityStamp" TEXT NULL, - "ConcurrencyStamp" TEXT NULL, - "PhoneNumber" TEXT NULL, - "PhoneNumberConfirmed" INTEGER NOT NULL, - "TwoFactorEnabled" INTEGER NOT NULL, - "LockoutEnd" TEXT NULL, - "LockoutEnabled" INTEGER NOT NULL, - "AccessFailedCount" INTEGER NOT NULL, - "FirstName" TEXT NULL, - "LastName" TEXT NULL, - "DisplayName" TEXT NULL, - "ProfileCvText" TEXT NULL, - "ProfileCvStructureJson" TEXT NULL, - "CurrentCvUploadArtifactId" INTEGER NULL, - "CurrentCvExtractionRunId" INTEGER NULL, - "CurrentCvProfileVersion" INTEGER NULL, - "AvatarImageDataUrl" TEXT NULL, - "GoogleSubject" TEXT NULL, - "GoogleEmail" TEXT NULL, - "GoogleLinkedAt" TEXT NULL, - "MicrosoftSubject" TEXT NULL, - "MicrosoftEmail" TEXT NULL, - "MicrosoftLinkedAt" TEXT NULL, - "TotpSecretEncrypted" TEXT NULL, - "TotpPendingSecretEncrypted" TEXT NULL, - "TotpEnabledAtUtc" TEXT NULL - ); - """); - - Exec(c, """ - CREATE TABLE IF NOT EXISTS "AspNetRoleClaims" ( - "Id" INTEGER NOT NULL CONSTRAINT "PK_AspNetRoleClaims" PRIMARY KEY AUTOINCREMENT, - "RoleId" TEXT NOT NULL, - "ClaimType" TEXT NULL, - "ClaimValue" TEXT NULL, - CONSTRAINT "FK_AspNetRoleClaims_AspNetRoles_RoleId" FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE - ); - """); - - Exec(c, """ - CREATE TABLE IF NOT EXISTS "AspNetUserClaims" ( - "Id" INTEGER NOT NULL CONSTRAINT "PK_AspNetUserClaims" PRIMARY KEY AUTOINCREMENT, - "UserId" TEXT NOT NULL, - "ClaimType" TEXT NULL, - "ClaimValue" TEXT NULL, - CONSTRAINT "FK_AspNetUserClaims_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE - ); - """); - - Exec(c, """ - CREATE TABLE IF NOT EXISTS "AspNetUserLogins" ( - "LoginProvider" TEXT NOT NULL, - "ProviderKey" TEXT NOT NULL, - "ProviderDisplayName" TEXT NULL, - "UserId" TEXT NOT NULL, - CONSTRAINT "PK_AspNetUserLogins" PRIMARY KEY ("LoginProvider", "ProviderKey"), - CONSTRAINT "FK_AspNetUserLogins_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE - ); - """); - - Exec(c, """ - CREATE TABLE IF NOT EXISTS "AspNetUserRoles" ( - "UserId" TEXT NOT NULL, - "RoleId" TEXT NOT NULL, - CONSTRAINT "PK_AspNetUserRoles" PRIMARY KEY ("UserId", "RoleId"), - CONSTRAINT "FK_AspNetUserRoles_AspNetRoles_RoleId" FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE, - CONSTRAINT "FK_AspNetUserRoles_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE - ); - """); - - Exec(c, """ - CREATE TABLE IF NOT EXISTS "AspNetUserTokens" ( - "UserId" TEXT NOT NULL, - "LoginProvider" TEXT NOT NULL, - "Name" TEXT NOT NULL, - "Value" TEXT NULL, - CONSTRAINT "PK_AspNetUserTokens" PRIMARY KEY ("UserId", "LoginProvider", "Name"), - CONSTRAINT "FK_AspNetUserTokens_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE - ); - """); - - Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "RoleNameIndex" ON "AspNetRoles" ("NormalizedName");"""); - Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetRoleClaims_RoleId" ON "AspNetRoleClaims" ("RoleId");"""); - Exec(c, """CREATE INDEX IF NOT EXISTS "EmailIndex" ON "AspNetUsers" ("NormalizedEmail");"""); - Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "UserNameIndex" ON "AspNetUsers" ("NormalizedUserName");"""); - Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserClaims_UserId" ON "AspNetUserClaims" ("UserId");"""); - Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserLogins_UserId" ON "AspNetUserLogins" ("UserId");"""); - Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserRoles_RoleId" ON "AspNetUserRoles" ("RoleId");"""); - } - - EnsureIdentityTables(conn); EnsureColumn(conn, "AspNetUsers", "FirstName", "ALTER TABLE AspNetUsers ADD COLUMN FirstName TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE AspNetUsers ADD COLUMN LastName TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE AspNetUsers ADD COLUMN DisplayName TEXT NULL;"); @@ -564,8 +346,8 @@ public static class StartupInitializationExtensions ReconcileCareerProfileColumns(conn); ReconcileAiInteractionUsageColumns(conn); - // Once the base app tables exist, provision this reconciler-owned schema set and - // stamp its historical migration before later migrations rebuild JobApplications. + // Once the base app tables exist, stamp the historical migration before later + // migrations rebuild JobApplications. var isLegacy = HasMigration(conn, "20260310174114_AddCorrespondence") && !HasMigration(conn, legacyMigrationId); @@ -649,8 +431,6 @@ public static class StartupInitializationExtensions DbConnection conn = db.Database.GetDbConnection(); // ReconcileSchema runs twice; the second pass may inherit an already-open connection. if (conn.State != System.Data.ConnectionState.Open) conn.Open(); - EnsureIdentityTablesMySql(conn); - static bool MySqlIntPrimaryKeyIsAutoIncrement(DbConnection c, string table, string column) { @@ -898,8 +678,8 @@ public static class StartupInitializationExtensions } } - // 1. Reconcile what already exists and create the remaining Identity-owned tables. This - // runs before Migrate() so legacy schemas are repaired before migrations inspect them. + // 1. Reconcile what already exists. This runs before Migrate() so legacy schemas are + // repaired before migrations inspect them. ReconcileSchema(); // 2. Apply one migration at a time, reconciling after each. Some historical migrations @@ -925,7 +705,7 @@ public static class StartupInitializationExtensions // historical migrations can add a later column (for example Companies.Source) // immediately before the migration that owns it, producing a duplicate-column // failure on a clean database. Apply the chain first, then use the common final - // reconciliation pass for provider-safe repairs and reconciler-owned tables. + // reconciliation pass for provider-safe legacy repairs. migrationDb.Database.Migrate(); } } @@ -935,9 +715,8 @@ public static class StartupInitializationExtensions throw; } - // 3. Reconcile again, now that the migration-owned tables exist: creates the - // reconciler-owned tables that reference them and applies the index / AUTO_INCREMENT - // repairs that pass 1 had to skip. Idempotent, so on an existing database it is a scan. + // 3. Reconcile again, now that migration-owned tables exist, applying the index and + // AUTO_INCREMENT repairs pass 1 had to skip. Existing databases receive a no-op scan. ReconcileSchema(); // Optional: seed an initial admin user for local username/password login. diff --git a/JobTrackerApi/Services/StartupSchemaOwnership.cs b/JobTrackerApi/Services/StartupSchemaOwnership.cs index bfff922..06f3b8a 100644 --- a/JobTrackerApi/Services/StartupSchemaOwnership.cs +++ b/JobTrackerApi/Services/StartupSchemaOwnership.cs @@ -15,6 +15,13 @@ internal static class StartupSchemaOwnership "AiWorkspaceNotes", "AiInteractions", "ApplicationChecklistItems", + "AspNetRoleClaims", + "AspNetRoles", + "AspNetUserClaims", + "AspNetUserLogins", + "AspNetUserRoles", + "AspNetUsers", + "AspNetUserTokens", "Attachments", "CareerCertifications", "CareerEducations", @@ -53,19 +60,10 @@ internal static class StartupSchemaOwnership "UserSessions", }; - internal static readonly IReadOnlySet ReconcilerOwnedTables = new HashSet(StringComparer.Ordinal) - { - "AspNetRoleClaims", - "AspNetRoles", - "AspNetUserClaims", - "AspNetUserLogins", - "AspNetUserRoles", - "AspNetUsers", - "AspNetUserTokens", - }; + internal static readonly IReadOnlySet ReconcilerOwnedTables = new HashSet(StringComparer.Ordinal); // Historical migrations contain guarded compatibility bootstraps for these tables so direct - // EF tooling can traverse the chain. Their current creation owner remains the reconciler. + // EF tooling can traverse the chain. The later adoption migration is the current owner. internal static readonly IReadOnlySet MigrationCompatibilityBootstrapTables = new HashSet(StringComparer.Ordinal) { "AspNetUsers" }; } diff --git a/docs/audits/audit-remediation-backlog.md b/docs/audits/audit-remediation-backlog.md index 9cc9a70..3042485 100644 --- a/docs/audits/audit-remediation-backlog.md +++ b/docs/audits/audit-remediation-backlog.md @@ -558,7 +558,7 @@ SEC-008 implements the same durable state machine with `.uploading` and ` ### P3-1 — Reduce dual schema ownership incrementally -**Status (2026-08-30): in progress.** All 49 model tables now have an executable, disjoint creation-owner classification. Twenty-eight formerly reconciler-created tables—including authentication support, email-provider connections, CV persistence/history, job-workspace AI notes, append-only AI interactions, workflow checklists, cover-letter history, durable interview preparation, and the complete Career Profile aggregate—have moved through additive provider-aware migrations with legacy-row preservation, downgrade/retry safety, startup-DDL removal, fresh SQLite runtime proof and generated MariaDB SQL. The Gmail decision migration also closes its missing MariaDB creation path. Only the seven ASP.NET Identity tables remain startup-created; see `docs/verification/jt-019-schema-ownership.md` and V-194–V-207. +**Status (2026-08-30): complete.** All 49 model tables have an executable, disjoint creation-owner classification and are migration-owned. All 35 formerly startup-created tables—including authentication/Identity, email-provider connections, CV persistence/history, job-workspace AI notes, workflow documents, durable interview preparation, and the complete Career Profile aggregate—moved through additive provider-aware migrations with legacy-row preservation, downgrade/retry safety, startup-DDL removal, fresh SQLite chain proof, and generated MariaDB SQL. The reconciler now performs guarded compatibility repair only. See `docs/verification/jt-019-schema-ownership.md` and V-194–V-208. - **Findings/scope:** JT-019; inventory reconciler operations and move stable schema ownership to EF migrations in small steps. - **Dependencies:** provider upgrade fixtures and P2-2 restore safety. diff --git a/docs/audits/verification-log.md b/docs/audits/verification-log.md index e290713..7c79f82 100644 --- a/docs/audits/verification-log.md +++ b/docs/audits/verification-log.md @@ -239,3 +239,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un | V-205 | Ownership/migration chain; populated adoption/downgrade/retry; document/FK/index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move append-only cover-letter revision history to migration ownership without losing recoverable documents | PASS — focused ownership/migration 17/17; manual and AI revisions plus source/action metadata survive adoption, downgrade and re-upgrade; owner/job/version index exists; deleting the parent application cascades through history; generated MariaDB SQL is provider-safe; startup no longer creates the table; full backend 733/733 | MariaDB SQL generated only; no provider account or production migration. Guarded malformed-empty/index/auto-increment repair remains temporarily. Sixteen reconciler-owned tables remain | JT-019 cover-letter history transfer complete; interview-preparation items and career-profile aggregate continue incrementally | | V-206 | Ownership/migration chain; populated adoption/downgrade/retry; practice-state/FK/index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move durable interview-preparation items to migration ownership without losing user or AI preparation content | PASS — focused ownership/migration 18/18; user-authored and AI-generated questions, answers, prepared state and source metadata survive adoption, downgrade and re-upgrade; owner/job/sort index exists; deleting the parent application cascades through preparation items; generated MariaDB SQL is provider-safe; startup no longer creates the table; full backend 734/734 | MariaDB SQL generated only; no provider account or production migration. Guarded malformed-empty/index/auto-increment repair remains temporarily. Fifteen reconciler-owned tables remain | JT-019 feature-table transfers complete; the Career Profile aggregate and Identity group remain dependency-aware batches | | V-207 | Ownership/migration chain; populated aggregate adoption/downgrade/retry; JSON/text/FK/index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move the canonical Career Profile, append-only history, and six relational child types to migration ownership without losing career data | PASS — focused ownership/migration 19/19; canonical and long-tail JSON, Norwegian text, version history, experience, education, skill, project, certification and language rows survive adoption, downgrade and re-upgrade; all 14 aggregate indexes exist; deleting the profile cascades through history and children; generated MariaDB SQL uses bounded indexed keys and provider-safe types; startup creates none of the eight tables; full backend 735/735 | MariaDB SQL generated only; no production migration. Guarded historical LongTailJson/index/auto-increment repairs remain temporarily. Seven reconciler-owned Identity tables remain | JT-019 feature and career transfers complete; isolate the Identity ownership boundary next | +| V-208 | Ownership/migration chain; populated Identity adoption/downgrade/retry; credential/preference/FK/index assertions; MariaDB script; full backend; fresh application startup | Repository root / disposable local SQLite | Complete JT-019 by moving the seven ASP.NET Identity tables to migration ownership without invalidating authentication data | PASS — focused ownership/migration 20/20; password hash, Bokmål preference, 2FA state, role assignment, role/user claims, external login and token survive adoption, downgrade and re-upgrade; all eight Identity indexes exist; user and role deletion cascades remain effective; generated MariaDB SQL is provider-safe; startup creates no model tables; full backend 736/736; a fresh application applies the complete chain, reaches Identity role initialization and listens normally | MariaDB SQL generated only; no production migration. The historical AspNetUsers compatibility bootstrap and guarded column/index repairs remain for chain traversal and legacy upgrades. Launch settings supplied an existing policy-invalid development admin password, producing a non-fatal seed warning | JT-019 complete; future work may retire individual repair statements only after provider-backed historical fixtures prove them redundant | diff --git a/docs/infrastructure/database-ownership.md b/docs/infrastructure/database-ownership.md index b7cb13c..30d87c1 100644 --- a/docs/infrastructure/database-ownership.md +++ b/docs/infrastructure/database-ownership.md @@ -65,7 +65,8 @@ already-correct database. Two consequences worth knowing: Created by EF migrations, never by the reconciler: `AccountDeletionFiles`, `AccountDeletionRequests`, `AiInteractions`, `AiUsageRecords`, `AiWorkspaceNotes`, -`ApplicationChecklistItems`, `Attachments`, `CareerCertifications`, `CareerEducations`, +`ApplicationChecklistItems`, `AspNetRoleClaims`, `AspNetRoles`, `AspNetUserClaims`, `AspNetUserLogins`, +`AspNetUserRoles`, `AspNetUsers`, `AspNetUserTokens`, `Attachments`, `CareerCertifications`, `CareerEducations`, `CareerExperiences`, `CareerLanguages`, `CareerProfiles`, `CareerProfileVersions`, `CareerProjects`, `CareerSkills`, `Companies`, `CoverLetterVersions`, `Correspondences`, `CvExtractionRuns`, `CvUploadArtifacts`, `CvVariants`, `CvVariantVersions`, @@ -132,6 +133,11 @@ child tables) moved in `20260830133000_AdoptCareerProfileSchema`. Canonical/long append-only history, stable child keys and ordering are retained; bounded MariaDB owner/item keys keep all aggregate indexes provider-safe. +The ASP.NET Identity aggregate moved in `20260830134000_AdoptIdentitySchema`. Accounts, password +hashes, security/2FA state, UI preferences, roles, claims, external logins, tokens, indexes, and +cascades are retained. The earlier guarded `AspNetUsers` bootstrap remains only so historical +standalone migration traversal can reach later additive user-column migrations. + The reconciler may **repair** these (add a missing column, add an index, fix a non-`AUTO_INCREMENT` primary key) and may seed the default `RuleSettings` row — but it must never `CREATE TABLE` them. It used to create `RuleSettings`, which is precisely why a clean install failed with @@ -139,12 +145,8 @@ It used to create `RuleSettings`, which is precisely why a clean install failed ### Reconciler-owned -The seven ASP.NET Identity tables are currently reconciler-owned, despite older wording that -called them migration-owned: `AspNetRoles`, `AspNetUsers`, `AspNetRoleClaims`, `AspNetUserClaims`, -`AspNetUserLogins`, `AspNetUserRoles`, and `AspNetUserTokens`. Guarded migration bootstraps for -`AspNetUsers` supports standalone traversal but does not yet transfer ownership. The historical -`AiInteractions` bootstrap remains in the migration chain, but its current owner is the later -provider-aware adoption migration. +None. The reconciler contains guarded historical repair logic, but it no longer creates EF model +tables. Compatibility bootstraps inside old migrations are chain prerequisites, not current owners. `StartupSchemaOwnership` is the executable inventory. Its tests require every EF model table to have exactly one creation owner and keep compatibility bootstraps out of the migration-owned set. @@ -222,9 +224,8 @@ everything inside it. Standalone EF tooling is also supported for a blank SQLite database. The historical initial migration now supplies the stable JobApplication columns required by later SQLite rebuilds, and -guarded compatibility bootstraps provide the reconciler-owned source tables used by later additive -migrations. Application startup may subsequently reconcile the remaining Identity and auxiliary -tables without losing rows. +guarded compatibility bootstraps provide source tables used by later additive migrations. +Application startup may subsequently apply guarded historical column/index repairs without losing rows. ## Production upgrade diff --git a/docs/verification/jt-019-schema-ownership.md b/docs/verification/jt-019-schema-ownership.md index e8d5628..2ce528d 100644 --- a/docs/verification/jt-019-schema-ownership.md +++ b/docs/verification/jt-019-schema-ownership.md @@ -44,6 +44,8 @@ cohesive table groups. It does not attempt to delete the legacy reconciler whole - Added `20260830133000_AdoptCareerProfileSchema` for the canonical profile, append-only history, and all six ordered relational child types. Legacy JSON, Norwegian text, stable keys, and cascade ownership are retained. +- Added `20260830134000_AdoptIdentitySchema` for the complete ASP.NET Identity aggregate, preserving + accounts, password hashes, 2FA state, preferences, roles, claims, external logins, and tokens. - Corrected the ownership runbook: new tables now default to migrations; the reconciler is legacy compatibility code to retire one dependency group at a time. @@ -87,14 +89,21 @@ migration. - A populated Career Profile with history, long-tail JSON, Norwegian text, experience, education, skills, projects, certifications, and languages survives adoption, downgrade, and re-upgrade; all 14 aggregate indexes and parent cascades remain effective. +- A populated Identity account with password hash, Bokmål preference, 2FA state, role assignment, + role/user claims, external login, and token survives adoption, downgrade, and re-upgrade; all eight + Identity indexes and user/role cascades remain effective. - Generated MariaDB SQL contains the provider-correct `SystemEmailSettings` DDL. -- Full backend: 735/735 passed after the Career Profile aggregate transfer. +- Full backend: 736/736 passed after the Identity aggregate transfer. +- Fresh application startup over a new disposable SQLite data root applied the complete chain, + queried/seeded Identity roles, and reached the healthy listening state with no schema failure. - Fresh application startup over a new disposable SQLite database applied `20260830120000_AdoptSystemEmailSettingsSchema` and reached the healthy listening state. ## Remaining JT-019 work -Seven model tables remain startup-created: the ASP.NET Identity group. +No EF model tables remain startup-created. JT-019's creation-ownership transfer is complete; the +startup reconciler now performs compatibility repair only and can be reduced further when historical +provider fixtures prove individual repairs obsolete. with parent dependencies. Transfer them in small dependency-aware migrations with blank, populated, retry and MariaDB runtime proof. Column/index repairs must remain until historical upgrade fixtures prove each one redundant. diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index c345ec1..d42ce38 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -57,6 +57,7 @@ Updated: 2026-08-30 - Moved append-only cover-letter revision history into a provider-aware migration; manual and AI text, source/action metadata, ordering, and application cascades are preserved. - Moved durable interview-preparation items into a provider-aware migration; user and AI content, answers, practice state, sources, ordering, and application cascades are preserved. - Moved the complete Career Profile aggregate into a provider-aware migration; canonical and long-tail JSON, version history, all six relational child types, stable ordering, indexes, and cascades are preserved. +- Completed JT-019 by moving all seven ASP.NET Identity tables into provider-aware migration ownership; accounts, credentials, 2FA state, preferences, roles, claims, external logins, tokens, indexes, and cascades are preserved. Startup schema code is now repair-only. ### In progress @@ -87,7 +88,7 @@ Updated: 2026-08-30 - Focused frontend: 2 suites, 6 tests passed. - Full frontend: 64 suites, 272 tests passed. - Next production build and TypeScript: passed. -- Full backend: 735/735 tests passed after the Career Profile aggregate JT-019 transfer. +- Full backend: 736/736 tests passed after completing the Identity/JT-019 ownership transfer. - Portable Playwright launcher: resolved the user-local .NET 9 SDK; backend Release build passed with 0 warnings/errors. - Playwright: initial full run 9/10 exposed the intentional mobile Settings control change; updated focused rerun passed 1/1. A final complete browser rerun remains in the end-of-batch gate. - Focused backend match/intelligence verification: 34/34 passed.