Compare commits
2 Commits
83206df9d7
...
4db8c08958
| Author | SHA1 | Date | |
|---|---|---|---|
| 4db8c08958 | |||
| 474654b1c1 |
@@ -6,11 +6,22 @@ namespace JobTrackerApi.Data
|
|||||||
{
|
{
|
||||||
public class JobTrackerContext : IdentityDbContext<ApplicationUser>
|
public class JobTrackerContext : IdentityDbContext<ApplicationUser>
|
||||||
{
|
{
|
||||||
public string? CurrentUserId { get; }
|
private readonly JobTrackerApi.Services.ICurrentUserService _currentUser;
|
||||||
|
|
||||||
|
// Evaluated live on each access, NOT captured in the constructor. The "local" JwtBearer
|
||||||
|
// OnTokenValidated resolves this request-scoped DbContext to run LocalSessionValidator BEFORE
|
||||||
|
// the authentication middleware assigns HttpContext.User. A constructor snapshot therefore froze
|
||||||
|
// CurrentUserId to null for the whole request, and the same scoped instance is reused by the
|
||||||
|
// controller — so every tenant-filtered read compiled to `WHERE FALSE` and returned nothing
|
||||||
|
// (created rows 404'd on read, lists came back empty) even though writes set OwnerUserId
|
||||||
|
// correctly from the controller-resolved user. Reading it live means the query filters see the
|
||||||
|
// authenticated user at query-execution time. Deny-on-null is preserved: it is still null for an
|
||||||
|
// unauthenticated principal.
|
||||||
|
public string? CurrentUserId => _currentUser.UserId;
|
||||||
|
|
||||||
public JobTrackerContext(DbContextOptions<JobTrackerContext> options, JobTrackerApi.Services.ICurrentUserService currentUser) : base(options)
|
public JobTrackerContext(DbContextOptions<JobTrackerContext> options, JobTrackerApi.Services.ICurrentUserService currentUser) : base(options)
|
||||||
{
|
{
|
||||||
CurrentUserId = currentUser.UserId;
|
_currentUser = currentUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DbSet<Company> Companies => Set<Company>();
|
public DbSet<Company> Companies => Set<Company>();
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using JobTrackerApi.Data;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
|
using JobTrackerApi.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
|
// Regression guard for the production tenant-read failure: the "local" JwtBearer OnTokenValidated
|
||||||
|
// resolves the request-scoped JobTrackerContext to run session validation BEFORE the auth middleware
|
||||||
|
// sets HttpContext.User. When CurrentUserId was captured in the DbContext constructor, that froze it
|
||||||
|
// to null for the whole request, and every global-query-filtered read returned WHERE FALSE — created
|
||||||
|
// rows 404'd on read and lists came back empty, while writes (which set OwnerUserId from the
|
||||||
|
// controller-resolved user) still succeeded.
|
||||||
|
//
|
||||||
|
// The fix makes CurrentUserId read ICurrentUserService.UserId live on each access. These tests fail
|
||||||
|
// if it reverts to a constructor snapshot.
|
||||||
|
public sealed class CurrentUserIdLiveEvaluationTests
|
||||||
|
{
|
||||||
|
private static (JobTrackerContext db, Mock<ICurrentUserService> user) New(string? initialUserId)
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
|
||||||
|
var user = new Mock<ICurrentUserService>();
|
||||||
|
user.SetupGet(s => s.UserId).Returns(initialUserId);
|
||||||
|
return (new JobTrackerContext(options, user.Object), user);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CurrentUserId_reflects_the_user_resolved_after_construction()
|
||||||
|
{
|
||||||
|
// Constructed while unauthenticated (UserId null), exactly as OnTokenValidated does.
|
||||||
|
var (db, user) = New(initialUserId: null);
|
||||||
|
Assert.Null(db.CurrentUserId);
|
||||||
|
|
||||||
|
// The auth middleware then assigns the principal; UserId becomes non-null on the SAME instance.
|
||||||
|
user.SetupGet(s => s.UserId).Returns("user-1");
|
||||||
|
|
||||||
|
Assert.Equal("user-1", db.CurrentUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_row_created_while_unauthenticated_context_is_readable_once_the_user_resolves()
|
||||||
|
{
|
||||||
|
var (db, user) = New(initialUserId: null);
|
||||||
|
|
||||||
|
// Write happens with an explicit owner (mirrors CreateAsync taking the controller-resolved id),
|
||||||
|
// while the context was built before the user resolved.
|
||||||
|
db.CvVariants.Add(new CvVariant
|
||||||
|
{
|
||||||
|
OwnerUserId = "user-1",
|
||||||
|
Name = "Test",
|
||||||
|
PublicSlug = "slug-1",
|
||||||
|
SettingsJson = "{}",
|
||||||
|
Version = 1,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Before the user resolves, the tenant filter denies (deny-on-null preserved).
|
||||||
|
Assert.Empty(await db.CvVariants.ToListAsync());
|
||||||
|
|
||||||
|
// Once the middleware sets the user, the same context reads the row — not WHERE FALSE.
|
||||||
|
user.SetupGet(s => s.UserId).Returns("user-1");
|
||||||
|
var visible = await db.CvVariants.ToListAsync();
|
||||||
|
Assert.Single(visible);
|
||||||
|
Assert.Equal("user-1", visible[0].OwnerUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -773,12 +773,20 @@ public static class StartupInitializationExtensions
|
|||||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfiles" PRIMARY KEY AUTOINCREMENT,
|
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfiles" PRIMARY KEY AUTOINCREMENT,
|
||||||
"OwnerUserId" TEXT NOT NULL,
|
"OwnerUserId" TEXT NOT NULL,
|
||||||
"ProfileJson" TEXT NOT NULL,
|
"ProfileJson" TEXT NOT NULL,
|
||||||
|
"LongTailJson" TEXT NOT NULL DEFAULT '',
|
||||||
"Version" INTEGER NOT NULL,
|
"Version" INTEGER NOT NULL,
|
||||||
"CreatedAtUtc" TEXT NOT NULL,
|
"CreatedAtUtc" TEXT NOT NULL,
|
||||||
"UpdatedAtUtc" TEXT NOT NULL
|
"UpdatedAtUtc" TEXT NOT NULL
|
||||||
);
|
);
|
||||||
""");
|
""");
|
||||||
|
|
||||||
|
// LongTailJson was added to the CareerProfile model (Phase 3) but this CREATE and
|
||||||
|
// the MySQL one were never updated to match, and no column-repair existed — so a
|
||||||
|
// CareerProfiles table created before this line lacks the column and /api/cv/outline
|
||||||
|
// (CareerProfileService.LoadStructuredAsync) fails with "Unknown column LongTailJson".
|
||||||
|
// Additive repair for existing databases.
|
||||||
|
EnsureColumn(c, "CareerProfiles", "LongTailJson", """ALTER TABLE "CareerProfiles" ADD COLUMN "LongTailJson" TEXT NOT NULL DEFAULT '';""");
|
||||||
|
|
||||||
Exec(c, """
|
Exec(c, """
|
||||||
CREATE TABLE IF NOT EXISTS "CareerProfileVersions" (
|
CREATE TABLE IF NOT EXISTS "CareerProfileVersions" (
|
||||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfileVersions" PRIMARY KEY AUTOINCREMENT,
|
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfileVersions" PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -1620,6 +1628,7 @@ public static class StartupInitializationExtensions
|
|||||||
`Id` int NOT NULL AUTO_INCREMENT,
|
`Id` int NOT NULL AUTO_INCREMENT,
|
||||||
`OwnerUserId` varchar(255) NOT NULL,
|
`OwnerUserId` varchar(255) NOT NULL,
|
||||||
`ProfileJson` longtext NOT NULL,
|
`ProfileJson` longtext NOT NULL,
|
||||||
|
`LongTailJson` longtext NOT NULL,
|
||||||
`Version` int NOT NULL,
|
`Version` int NOT NULL,
|
||||||
`CreatedAtUtc` datetime(6) NOT NULL,
|
`CreatedAtUtc` datetime(6) NOT NULL,
|
||||||
`UpdatedAtUtc` datetime(6) NOT NULL,
|
`UpdatedAtUtc` datetime(6) NOT NULL,
|
||||||
@@ -1628,6 +1637,11 @@ public static class StartupInitializationExtensions
|
|||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Additive repair for a CareerProfiles table created before LongTailJson was added
|
||||||
|
// to the model. Without it, /api/cv/outline fails with "Unknown column LongTailJson".
|
||||||
|
// DEFAULT '' backfills existing rows and matches the non-nullable model property.
|
||||||
|
EnsureMySqlColumn(conn, "CareerProfiles", "LongTailJson", "ALTER TABLE `CareerProfiles` ADD COLUMN `LongTailJson` longtext NOT NULL DEFAULT '';");
|
||||||
|
|
||||||
if (!HasMySqlTable(conn, "CareerProfileVersions") && HasMySqlTable(conn, "CareerProfiles"))
|
if (!HasMySqlTable(conn, "CareerProfileVersions") && HasMySqlTable(conn, "CareerProfiles"))
|
||||||
{
|
{
|
||||||
using var cmd = conn.CreateCommand();
|
using var cmd = conn.CreateCommand();
|
||||||
|
|||||||
Reference in New Issue
Block a user