Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ultimateauth-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ jobs:


identity-lab-security:
name: Identity Lab security (PostgreSQL)
name: Identity Lab security
runs-on: ubuntu-latest
timeout-minutes: 20

Expand All @@ -75,6 +75,7 @@ jobs:
--collect:"XPlat Code Coverage"
--logger "trx;LogFileName=identity-lab.trx"
--results-directory TestResults/IdentityLab
--settings tests/CodeBeam.UltimateAuth.IdentityLab.Tests/identity-lab.runsettings

- name: Generate Identity Lab coverage summary
if: always()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
<PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.12" />
<PackageReference Include="bunit" Version="2.11.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.12" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.16.0" />
</ItemGroup>
Expand Down
18 changes: 16 additions & 2 deletions tests/CodeBeam.UltimateAuth.IdentityLab.Tests/README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Identity Lab security suite

Requires .NET 10 SDK and Docker with permission to start containers. Run from the repository root:
The security and integration tests require .NET 10 SDK and Docker with permission to start containers. Run from the repository root:

```sh
dotnet test tests/CodeBeam.UltimateAuth.IdentityLab.Tests -c Release --logger trx
```

By default, each database test class starts PostgreSQL 17 through Testcontainers. Every test gets a new database, applies the actual LabDbContext migrations, and drops its database on disposal. Tests never use EF InMemory. Nothing connects to application production databases.
By default, each database test class starts PostgreSQL 17 through Testcontainers. Every test gets a new database, applies the actual LabDbContext migrations, and drops its database on disposal. Security and integration tests never use EF InMemory. Nothing connects to application production databases.

Alternatively set `IDENTITYLAB_TEST_POSTGRES` to a connection string for a **dedicated test PostgreSQL instance**. Its login needs CREATE DATABASE and permission to drop the databases it creates. Only databases named `identitylab_test_<generated GUID>` are created/dropped. This setting bypasses Docker; it does not bypass relational tests. Missing infrastructure fails fixture initialization rather than silently skipping tests.

Expand All @@ -15,3 +15,17 @@ Tests replace authenticated-context sources and selected framework store/client
`Regression/SystemRoleIdentityTests.cs` records an expected rejection that the inspected implementation does not enforce. It is intentionally neither skipped nor inverted to accept unsafe behavior. The `KnownDefect=SystemRoleIdentity` trait identifies it; normal local and CI runs include it. Expect that regression to fail until separately approved production work addresses the issue. Do not enable this suite as a required green check before defect triage.

See [coverage matrix](docs/security-coverage.md) and [execution report](docs/execution-report.md). The project is included in the solution. Repository CI excludes it from the general matrix test step and runs it in a dedicated .NET 10 PostgreSQL job; the dedicated job uploads TRX results even on failure.

## Page render suite

The `Rendering` tests use bUnit and need only the .NET 10 SDK; no Docker or PostgreSQL is required:

```sh
dotnet test tests/CodeBeam.UltimateAuth.IdentityLab.Tests/CodeBeam.UltimateAuth.IdentityLab.Tests.csproj -c Release --filter Category=Render
```

The real pages and child components render with MudBlazor services and a popover provider. External client calls use explicit Moq responses. Dashboard initialization uses real Lab services with a uniquely named EF InMemory LabDbContext store; this verifies component initialization and navigation branches, not relational behavior. Existing service regression tests and their PostgreSQL fixtures are unchanged. Direct page rendering does not exercise router authorization or layouts.

All 24 routable application page components have smoke test mappings (28 page-render cases and one route inventory guard). New pages need a real render test annotated with `[SmokeTestFor(typeof(PageComponent))]`; the route guard reports missing types and their route aliases. Existing behavioral render cases are preserved. No additional dependencies were introduced by the completion phase.

See [page inventory and validation limits](docs/page-render-coverage.md).
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
using CodeBeam.UltimateAuth.Authorization;
using CodeBeam.UltimateAuth.Authorization.Contracts;
using CodeBeam.UltimateAuth.Client.Diagnostics;
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.MultiTenancy;
using CodeBeam.UltimateAuth.Core.Runtime;
using CodeBeam.UltimateAuth.IdentityLab.Authorization;
using CodeBeam.UltimateAuth.IdentityLab.Contracts;
using CodeBeam.UltimateAuth.IdentityLab.Services;
using CodeBeam.UltimateAuth.Server.Auth;
using CodeBeam.UltimateAuth.Users.Contracts;
using Microsoft.Extensions.DependencyInjection;
using Moq;

namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Rendering;

internal sealed partial class PageRenderContext
{
public static UserKey RenderUser => UserKey.FromString("render-user");

public void AddManagementServices()
{
AddLabServices();
var summaries = new Mock<IUserSummaryQueryStore>(MockBehavior.Strict);
summaries.Setup(x => x.GetByKeysAsync(It.IsAny<IReadOnlyCollection<UserKey>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new[] { new UserSummary
{
UserKey = RenderUser, DisplayName = "Render Person", UserName = "render-person",
PrimaryEmail = "render@example.test", Status = UserStatus.Active
} });
var summaryFactory = new Mock<IUserSummaryQueryStoreFactory>(MockBehavior.Strict);
summaryFactory.Setup(x => x.Create(TenantKeys.Single)).Returns(summaries.Object);
Services.AddSingleton(summaryFactory.Object);
Services.AddScoped<SandboxAccessService>();
Services.AddScoped<LabProvisioningRollbackGate>();
Services.AddScoped<LabUserProvisioningService>();
Services.AddScoped<SandboxUserService>();
Services.AddScoped<SandboxRepairService>();
Services.AddScoped<SandboxAccountDeletionService>();
var product = new Mock<IUAuthProductInfoProvider>(MockBehavior.Strict);
product.Setup(x => x.Get()).Returns(new UAuthProductInfo { Version = "render-test" });
Services.AddSingleton(product.Object);
}

public void SetupProfileAndAccess()
{
Users.Setup(x => x.GetMeAsync(It.IsAny<GetProfileRequest>()))
.ReturnsAsync(Success(new UserView
{
UserKey = RenderUser, DisplayName = "Render Person", UserName = "render-person",
PrimaryEmail = "render@example.test", Status = UserStatus.Active
}));
Identifiers.Setup(x => x.GetMyAsync(It.IsAny<PageRequest>()))
.ReturnsAsync(Success(Empty<UserIdentifierInfo>()));
Sessions.Setup(x => x.GetMyChainsAsync(It.IsAny<PageRequest>()))
.ReturnsAsync(Success(Empty<SessionChainSummary>()));
Authorization.Setup(x => x.GetMyRolesAsync(It.IsAny<PageRequest>()))
.ReturnsAsync(Success(new UserRolesGetResult { UserKey = RenderUser, Roles = Empty<UserRoleInfo>() }));
Authorization.Setup(x => x.GetMyPermissionsAsync())
.ReturnsAsync(Success(new UserPermissionsResult { Permissions = Array.Empty<Permission>() }));
}

public void AddAdminServices()
{
AddManagementServices();
var role = Role.Create(LabRoleIds.Admin, TenantKeys.Single, "Ladmin", null, DateTimeOffset.UtcNow);
var roles = new Mock<IRoleStore>(MockBehavior.Strict);
roles.Setup(x => x.GetByIdsAsync(It.IsAny<IReadOnlyCollection<RoleId>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new[] { role });
var roleFactory = new Mock<IRoleStoreFactory>(MockBehavior.Strict);
roleFactory.Setup(x => x.Create(TenantKeys.Single)).Returns(roles.Object);
var assignments = new Mock<IUserRoleStore>(MockBehavior.Strict);
assignments.Setup(x => x.GetAssignmentsAsync(RenderUser, It.IsAny<CancellationToken>()))
.ReturnsAsync(new[] { new UserRole { Tenant = TenantKeys.Single, UserKey = RenderUser, RoleId = role.Id } });
var assignmentFactory = new Mock<IUserRoleStoreFactory>(MockBehavior.Strict);
assignmentFactory.Setup(x => x.Create(TenantKeys.Single)).Returns(assignments.Object);
Services.AddSingleton(roleFactory.Object);
Services.AddSingleton(assignmentFactory.Object);
Services.AddSingleton(Mock.Of<ILabAdminGuard>());
Services.AddScoped<LabAdminService>();
Services.AddScoped<LabRepairService>();
}

public void AddDemoStoreServices()
{
AddLabServices();
var flow = new Mock<IAuthFlow>(MockBehavior.Strict);
flow.Setup(x => x.BeginAsync(It.IsAny<AuthFlowType>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Contexts.AuthenticatedFlow(RenderUser));
var contexts = new Mock<IAccessContextFactory>(MockBehavior.Strict);
contexts.Setup(x => x.CreateAsync(It.IsAny<AuthFlowContext>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<IDictionary<string, object>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Contexts.Create(LabDemoPermissions.ProductsRead, RenderUser));
Services.AddSingleton(flow.Object);
Services.AddSingleton(contexts.Object);
// An active sandbox member without demo role assignments is a valid empty-access state.
var assignments = new Mock<IUserRoleStore>(MockBehavior.Strict);
assignments.Setup(x => x.GetAssignmentsAsync(RenderUser, It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<UserRole>());
var factory = new Mock<IUserRoleStoreFactory>(MockBehavior.Strict);
factory.Setup(x => x.Create(TenantKeys.Single)).Returns(assignments.Object);
Services.AddSingleton(factory.Object);
var roles = new Mock<IRoleStore>(MockBehavior.Strict);
roles.Setup(x => x.GetByIdsAsync(It.IsAny<IReadOnlyCollection<RoleId>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<Role>());
var roleFactory = new Mock<IRoleStoreFactory>(MockBehavior.Strict);
roleFactory.Setup(x => x.Create(TenantKeys.Single)).Returns(roles.Object);
Services.AddSingleton(roleFactory.Object);
Services.AddScoped<LabDemoStoreService>();
}

public void AddDiagnosticsServices()
{
Services.AddSingleton(new UAuthClientDiagnostics());
Services.AddScoped<LabSessionDiagnosticsPolicyService>();
var chainId = SessionChainId.New();
var sessionId = AuthSessionId.Parse(new string('s', 32));
var now = DateTimeOffset.UtcNow;
var session = new SessionInfo(sessionId, now.AddMinutes(-1), now.AddHours(1), false);
var chain = new SessionChainSummary
{
ChainId = chainId, ActiveSessionId = sessionId, IsCurrentDevice = true,
State = SessionChainState.Active, CreatedAt = session.CreatedAt, LastSeenAt = now
};
Sessions.Setup(x => x.GetMyChainsAsync(It.IsAny<PageRequest>()))
.ReturnsAsync(Success(new PagedResult<SessionChainSummary>(new[] { chain }, 1, 1, 20, null, false)));
Sessions.Setup(x => x.GetMyChainDetailAsync(chainId))
.ReturnsAsync(Success(new SessionChainDetail
{
ChainId = chainId, ActiveSessionId = sessionId, State = SessionChainState.Active,
CreatedAt = session.CreatedAt, LastSeenAt = now, Sessions = new[] { session }
}));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
using System.Reflection;
using System.Security.Claims;
using Bunit;
using CodeBeam.UltimateAuth.Authorization;
using CodeBeam.UltimateAuth.Authorization.Contracts;
using CodeBeam.UltimateAuth.Client;
using CodeBeam.UltimateAuth.Client.Blazor;
using CodeBeam.UltimateAuth.Client.Blazor.Extensions;
using CodeBeam.UltimateAuth.Client.Options;
using CodeBeam.UltimateAuth.Client.Services;
using CodeBeam.UltimateAuth.Core.Abstractions;
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.MultiTenancy;
using CodeBeam.UltimateAuth.IdentityLab.Authorization;
using CodeBeam.UltimateAuth.IdentityLab.Domain;
using CodeBeam.UltimateAuth.IdentityLab.Persistence;
using CodeBeam.UltimateAuth.IdentityLab.Services;
using CodeBeam.UltimateAuth.Server.Auth;
using CodeBeam.UltimateAuth.Server.Options;
using CodeBeam.UltimateAuth.Server.Services;
using CodeBeam.UltimateAuth.Users.Contracts;
using CodeBeam.UltimateAuth.Users.Reference;
using Microsoft.AspNetCore.Components;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Moq;
using MudBlazor;
using MudBlazor.Services;
using MudExtensions.Services;

namespace CodeBeam.UltimateAuth.IdentityLab.Tests.Rendering;

internal sealed partial class PageRenderContext : BunitContext
{
public Mock<IUAuthClient> Client { get; } = new(MockBehavior.Strict);
public Mock<ISessionClient> Sessions { get; } = new(MockBehavior.Strict);
public Mock<IAuthorizationClient> Authorization { get; } = new(MockBehavior.Strict);
public Mock<IUserIdentifierClient> Identifiers { get; } = new(MockBehavior.Strict);
public Mock<IUserClient> Users { get; } = new(MockBehavior.Strict);

public PageRenderContext()
{
// MudBlazor's DOM measurement/focus calls have no browser in bUnit.
JSInterop.Mode = JSRuntimeMode.Loose;
Services.AddLogging();
Services.AddMudServices();
Services.AddMudExtensions();
Services.AddAuthorizationCore();
// Keep the real internal evaluator, without installing the production client host.
var registrations = new ServiceCollection();
registrations.AddUltimateAuthClientBlazor();
((IServiceCollection)Services).Add(registrations.Single(x => x.ServiceType == typeof(IUAuthAuthorizationEvaluator)));
Client.SetupGet(x => x.Sessions).Returns(Sessions.Object);
Client.SetupGet(x => x.Authorization).Returns(Authorization.Object);
Client.SetupGet(x => x.Users).Returns(Users.Object);
Client.SetupGet(x => x.Identifiers).Returns(Identifiers.Object);
Services.AddSingleton(Client.Object);
Services.AddSingleton(Options.Create(new UAuthClientOptions()));
Services.AddSingleton(Options.Create(new UAuthServerOptions()));
var device = new Mock<IDeviceIdProvider>();
device.Setup(x => x.GetOrCreateAsync(It.IsAny<CancellationToken>()))
.Returns(new ValueTask<DeviceId>(DeviceId.Create("render-test-device-0001")));
Services.AddSingleton(device.Object);
Services.AddSingleton(Mock.Of<IHubCredentialResolver>());
Services.AddSingleton(Mock.Of<IHubFlowReader>());
Services.AddSingleton(Mock.Of<IHubCapabilities>());
}

public IRenderedComponent<T> RenderPage<T>(UAuthState? state = null) where T : IComponent
{
// Register page-specific dependencies before the first render freezes DI.
Render<MudPopoverProvider>();
var authState = state ?? UAuthState.Anonymous();
return Render<CascadingValue<Task<Microsoft.AspNetCore.Components.Authorization.AuthenticationState>>>(p => p
.Add(x => x.Value, Task.FromResult(new Microsoft.AspNetCore.Components.Authorization.AuthenticationState(authState.ToClaimsPrincipal())))
.AddChildContent<CascadingValue<UAuthState>>(child => child
.Add(x => x.Value, authState)
.AddChildContent<T>())).FindComponent<T>();
}

public static UAuthState Authenticated(string role, params string[] permissions)
{
var state = UAuthState.Anonymous();
// Use the actual snapshot lifecycle; no production friend assembly or API changes.
typeof(UAuthState).GetMethod("ApplySnapshot", BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(state, [new AuthStateSnapshot
{
Identity = new AuthIdentitySnapshot
{
UserKey = UserKey.FromString("render-user"),
Tenant = TenantKeys.Single,
SessionState = SessionState.Active,
UserStatus = UserStatus.Active
},
Claims = ClaimsSnapshot.From(new[] { (Type: ClaimTypes.Role, Value: role) }
.Concat(permissions.Select(permission => (Type: "uauth:permission", Value: permission))).ToArray())
}, DateTimeOffset.UtcNow]);
return state;
}

public void AddLabServices()
{
// Only LabDbContext uses this isolated store. No server host, migrations or PostgreSQL.
var databaseName = Guid.NewGuid().ToString();
Services.AddDbContextFactory<LabDbContext>(o => o.UseInMemoryDatabase(databaseName));
Services.AddSingleton(Mock.Of<IAuthFlow>());
Services.AddSingleton(Mock.Of<IAccessContextFactory>());
Services.AddSingleton(Mock.Of<IUserApplicationService>());
Services.AddSingleton(Mock.Of<IUserIdentifierAvailabilityService>());
Services.AddSingleton(Mock.Of<IRoleStoreFactory>());
Services.AddSingleton(Mock.Of<IUserRoleStoreFactory>());
Services.AddSingleton(Mock.Of<IUserLifecycleStoreFactory>());
Services.AddScoped<LabAccessContextProvider>();
Services.AddScoped<SandboxService>();
Services.AddScoped<ActivityLogService>();
Services.AddScoped<LabRegistrationService>();
Services.AddScoped<LabRegistrationAvailabilityService>();
Services.AddScoped<LabDashboardService>();
Services.AddScoped<LabOwnerRepairService>();
}

public async Task SeedSandboxAsync(bool owner)
{
await using var db = await Services.GetRequiredService<IDbContextFactory<LabDbContext>>().CreateDbContextAsync();
var sandbox = new Sandbox
{
Name = "Render sandbox",
OwnerUserKey = UserKey.FromString(owner ? "render-user" : "other-owner"),
Settings = new SandboxSettings { MaxUsers = 5 }
};
sandbox.Members.Add(new SandboxMember
{
UserKey = UserKey.FromString("render-user"),
Role = owner ? SandboxMemberRole.Owner : SandboxMemberRole.Member
});
db.Sandboxes.Add(sandbox);
await db.SaveChangesAsync();
}

public static UAuthResult<T> Success<T>(T value) => new() { IsSuccess = true, Status = 200, Value = value };
public static PagedResult<T> Empty<T>() => new(Array.Empty<T>(), 0, 1, 20, null, false);
}
Loading
Loading