diff --git a/.docker/docker-compose.yml b/.docker/docker-compose.yml index 2240fa4c..e6d6635b 100644 --- a/.docker/docker-compose.yml +++ b/.docker/docker-compose.yml @@ -5,8 +5,6 @@ services: dockerfile: .docker/Dockerfile container_name: ultimateauth restart: always - ports: - - "8081:8080" networks: - edge diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 37f752d9..dc07c26c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -3,23 +3,19 @@ Thank you for contributing to **UltimateAuth**! Please complete the following checklist to help us review your PR effectively. ---- ## πŸ“˜ Summary Describe what this PR does and why it’s needed. ---- ## πŸ” Details Explain any important implementation details, design decisions, or considerations. ---- ## 🧩 Related Issues Link any related issues: ---- ## πŸ›  Changes - [ ] New feature diff --git a/.github/codecov.yml b/.github/codecov.yml index 70dbff6f..c52c4b6c 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -5,9 +5,9 @@ coverage: status: project: default: - target: 50% - threshold: 5% + target: 70% + threshold: 0% patch: default: - target: 20% + target: 50% threshold: 0% diff --git a/Directory.Build.props b/Directory.Build.props index d9414fe5..982a9ce8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.1.0-rc.2 + 0.1.0-rc.3 $(NoWarn);1591 CodeBeam diff --git a/README.md b/README.md index 930e8a4f..af08c023 100644 --- a/README.md +++ b/README.md @@ -33,15 +33,15 @@ UltimateAuth is an open-source auth framework with platform-level capabilities t | Phase | Version | Scope | Status | Release Date | | ----------------------- | ------------- | ----------------------------------------- | -------------- | ------------ | | First Preview | 0.1.0-preview | "Stable" Preview Core | βœ… Completed | 07.04.2026 | -| First Release* | 0.1.0 | Fully Documented & Quality Tested | 🟑 In Progress | Q2 2026 | -| Product Expansion | 0.2.0 | Full Auth Modes | 🟑 In Progress | Q2 2026 | -| Security Expansion | 0.3.0 | MFA, Reauth, Rate Limiting | πŸ”œ Planned | Q2 2026 | -| Infrastructure Expansion| 0.4.0 | Redis, Distributed Cache, Password Hasher | πŸ”œ Planned | Q2 2026 | -| Multi-Tenant Expansion | 0.5.0 | Multi tenant management | πŸ”œ Planned | Q3 2026 | -| Extensibility Expansion | 0.6.0 | Audit, events, hooks | πŸ”œ Planned | Q3 2026 | -| Performance Expansion | 0.7.0 | Benchmarks, caching | πŸ”œ Planned | Q3 2026 | -| Ecosystem Expansion | 0.8.0 | Migration tools | πŸ”œ Planned | Q4 2026 | -| v1.0 | 1.0.0 | Locked API, align with .NET 11 | πŸ”œ Planned | Q4 2026 | +| First Release* | 0.1.0 | Fully Documented & Quality Tested | βœ… Completed | 04.10.2026 | +| Product Expansion | 0.2.0 | Full Auth Modes | 🟑 In Progress | Q4 2026 | +| Security Expansion | 0.3.0 | MFA, Reauth, Rate Limiting | 🟑 In Progress | Q4 2026 | +| Infrastructure Expansion| 0.4.0 | Redis, Distributed Cache, Password Hasher | πŸ”œ Planned | Q1 2027 | +| Multi-Tenant Expansion | 0.5.0 | Multi tenant management | πŸ”œ Planned | Q1 2027 | +| Extensibility Expansion | 0.6.0 | Audit, events, hooks | πŸ”œ Planned | Q1 2027 | +| Performance Expansion | 0.7.0 | Benchmarks, caching | πŸ”œ Planned | Q1 2027 | +| Ecosystem Expansion | 0.8.0 | Migration tools | πŸ”œ Planned | Q2 2027 | +| v1.0 | 1.0.0 | Locked API, align with .NET 11 | πŸ”œ Planned | Q2 2027 | *v 0.1.0 already provides a skeleton of multi tenancy, MFA, reauth etc. Expansion releases will enhance these areas. @@ -51,19 +51,6 @@ UltimateAuth is an open-source auth framework with platform-level capabilities t We keep it up-to-date with current priorities, planned features, and progress. Feel free to follow, comment, or contribute ideas. -
- -> UltimateAuth is currently in the final stage of the first preview release (v 0.1.0-preview). - -> Core architecture is complete and validated through working samples. - -> Ongoing work: -> - Final API surface review -> - Developer experience improvements -> - EF Core integration polishing -> - Documentation refinement -
- --- ## 🌟 Why UltimateAuth @@ -99,7 +86,7 @@ Modern security built-in by default: - Session reuse detection - Device tracking - Hardened auth flows -- Safe defaults with extensibility +- Safe defaults ### 5) Extensible & Lightweight @@ -115,7 +102,8 @@ Designed specifically for real-world .NET environments: - Blazor Server - Blazor WASM -- .NET MAUI +- Blazor Web App +- .NET MAUI & Hybrid Apps - Backend APIs Traditional auth solutions struggle here β€” UltimateAuth embraces it. @@ -124,56 +112,49 @@ Traditional auth solutions struggle here β€” UltimateAuth embraces it. # πŸš€ Quick Start > ⏱ Takes ~2 minutes to get started +> +> **This Quick Start uses a Blazor Server application with in-memory persistence.** +It is intentionally designed as the simplest path to a working UltimateAuth application. -### 1) Install packages +> For Entity Framework Core, Blazor WebAssembly, Blazor Web App, Resource API, persistent storage, and other real-world configurations, see the [Real-World Setup guide](https://github.com/CodeBeamOrg/UltimateAuth/blob/dev/docs/content/getting-started/real-world-setup.md). + +### 1) Install UltimateAuth -1.1 Core Packages ```bash -dotnet add package CodeBeam.UltimateAuth.Server +dotnet add package CodeBeam.UltimateAuth.InMemory.Bundle dotnet add package CodeBeam.UltimateAuth.Client.Blazor ``` -1.2 Persistence & Reference Packages (Choose One) -```bash -dotnet add package CodeBeam.UltimateAuth.InMemory.Bundle (for debug & development) -dotnet add package CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle (for production) -``` -### 2) Configure services (in program.cs) -Server registration: -```csharp -builder.Services - .AddUltimateAuthServer() - .AddUltimateAuthEntityFrameworkCore(db => - { - // use with your database provider - db.UseSqlite("Data Source=uauth.db"); - }); -// OR +### 2) Configure UltimateAuth + +Register UltimateAuth in `Program.cs`: +```csharp +// Server registration builder.Services .AddUltimateAuthServer() - .AddUltimateAuthInMemory(); // Development + .AddUltimateAuthInMemory(); +// Client registration +builder.Services.AddUltimateAuthClientBlazor(); ``` -Client registration: -```csharp -builder.Services.AddUltimateAuthClientBlazor(); -``` **Usage by application type:** - **Blazor Server App** β†’ Use both Server and Client registrations - **Blazor WASM / MAUI** β†’ Use Client only -- **Auth Server / Resource API** β†’ Use Server only +- **UAuthHub (Auth Server) / Resource API** β†’ Use Server only + +### 3) Configure the Application Pipeline +Add the UltimateAuth middleware and endpoints: -### 3) Configure pipeline ```csharp // app.UseHttpsRedirection(); // app.UseStaticFiles(); app.UseUltimateAuthWithAspNetCore(); // Includes UseAuthentication() and UseAuthorization() -// Place Antiforgery or something else needed +// Place Antiforgery or something else before endpoint registration if needed app.MapUltimateAuthEndpoints(); app.MapRazorComponents() @@ -181,54 +162,89 @@ app.MapRazorComponents() .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient()); ``` -### 4) Add UAuth Script -Place this in `App.razor` or `index.html` in your Blazor client application: -```csharp - +### 4) Add UAuthApp +UltimateAuth uses `UAuthApp` as the root integration point for its client authentication state and Blazor lifecycle. + +Replace the default router in your `App.razor` or `Routes.razor` with: + +```razor +@using CodeBeam.UltimateAuth.Client.Blazor + + + + @* Add application-wide UI providers or other root components here. *@ + + + +

Not authorized.

+
+
``` -### 5) πŸ—„οΈ Database Setup (EF Core) +`UAuthApp` can provide the built-in router, authentication state, and UltimateAuth client lifecycle integration for your component tree. -After configuring UltimateAuth with Entity Framework Core, you need to create and apply database migrations. +> Need full control over routing? -5.1) Install EF Core tools (if not installed) -```bash -dotnet tool install --global dotnet-ef +> UAuthApp also supports applications that provide their own Blazor Router. See the Blazor Routing guide for advanced routing configuration. + +### 5) Add the UltimateAuth Client Script +Place this in `App.razor` or `index.html` in your Blazor client application: +```html + ``` -5.2) Add migration -```bash -dotnet ef migrations add InitUAuth + +### 6) Optional: Blazor Usings +Add this in `_Imports.razor`: +```csharp +@using CodeBeam.UltimateAuth.Client.Blazor ``` -5.3) Update database +### 7) Optional: Add Sample Data +For the fastest way to try auth process, install the UltimateAuth sample seed package: + ```bash -dotnet ef database update +dotnet add package CodeBeam.UltimateAuth.Sample.Seed ``` -πŸ’‘ Visual Studio (PMC alternative) -If you are using Visual Studio, you can run these commands in Package Manager Console: +Register the development seed: + ```bash -Add-Migration InitUAuth -Context UAuthDbContext -Update-Database -Context UAuthDbContext +builder.Services.AddUltimateAuthSampleSeed(); ``` -⚠️ Notes -- Migrations must be created in your application project, not in the UltimateAuth packages -- You are responsible for managing migrations in production -- Automatic database initialization is not enabled by default -### 6) Optional: Blazor Usings -Add this in `_Imports.razor` +Then seed the application during development: + ```csharp -@using CodeBeam.UltimateAuth.Client.Blazor +if (app.Environment.IsDevelopment()) +{ + await app.SeedUltimateAuthAsync(); +} ``` -### βœ… Done +The development seed includes ready-to-use accounts: + +| Identifier | Secret | +|------------|----------| +| `admin` | `admin` | +| `user` | `user` | + +You can use these credentials to test the auth flows immediately. + +> Development only: Sample users and credentials are intended for evaluation and local development. Do not use them in production. + +### βœ… You're Ready --- ## πŸ’‘ Usage -Inject IUAuthClient and simply call methods. +**One Client. Your Auth Application API.** + +For most application-level authentication and identity operations, start with `IUAuthClient`. + +`IUAuthClient` provides a single entry point to UltimateAuth capabilities such as authentication flows, users, sessions, tokens, profiles, credentials, and authorization β€” without requiring your application code to manage the underlying authentication transport. + +> UltimateAuth treats authentication and identity as application services. Your application works with explicit operations and structured results while UltimateAuth handles the underlying authentication flow. ### Examples Login @@ -239,8 +255,8 @@ private async Task Login() { var request = new LoginRequest { - Identifier = "UAuthUser", - Secret = "UAuthPassword", + Identifier = "admin", + Secret = "admin", }; await UAuthClient.Flows.LoginAsync(request); } @@ -254,9 +270,9 @@ private async Task Register() { var request = new CreateUserRequest { - UserName = _username, - Password = _password, - Email = _email, + UserName = "NewUser", + Password = "NewUserPassword", + Email = "newuser@example.com", }; var result = await UAuthClient.Users.CreateAsync(request); @@ -282,11 +298,14 @@ private async Task LogoutOthersAsync() } ``` -UltimateAuth turns Auth into a simple application service β€” not a separate system you fight against. -- No manual token handling -- No custom HTTP plumbing -- No fragile redirect logic -- All built-in with extensible options. +With `IUAuthClient`, common application code doesn't need to manually orchestrate: +- token handling +- authentication HTTP calls +- session operations +- redirect plumbing +- client-specific authentication flows + +Start with the simple API. Drop down to UltimateAuth's extensibility points when your application needs more control. --- diff --git a/docs/content/getting-started/real-world-setup.md b/docs/content/getting-started/real-world-setup.md index 92afaeef..27f9e35c 100644 --- a/docs/content/getting-started/real-world-setup.md +++ b/docs/content/getting-started/real-world-setup.md @@ -18,8 +18,9 @@ In real applications, you will typically configure: This guide shows how to set up UltimateAuth for real-world scenarios. ## πŸ—„οΈ Using Entity Framework Core +For production, you should use a persistent store. (In-memory provider is volatile and automatically resets on each restart.) -For production, you should use a persistent store. In this setup, you no longer need the `CodeBeam.UltimateAuth.InMemory.Bundle` package. +In this setup, you no longer need the `CodeBeam.UltimateAuth.InMemory.Bundle` package. ### Install Packages @@ -34,26 +35,74 @@ builder.Services .AddUltimateAuthEntityFrameworkCore(db => { db.UseSqlite("Data Source=uauth.db"); - // or UseSqlServer / UseNpgsql + // or UseSqlServer(...) / UseNpgsql(...) / UseMySql(...) etc. }); builder.Services .AddUltimateAuthClientBlazor(); ``` -### Create Database & Migrations +### Database Migrations + +UltimateAuth integrates with Entity Framework Core, but database migrations belong to your application. + +UltimateAuth does not automatically create or apply migrations on your behalf. This keeps your database schema lifecycle under your control and allows migrations to follow the same deployment and review process as the rest of your application. + +After configuring the Entity Framework Core provider, create the initial migration and update the database using either the .NET CLI or Visual Studio Package Manager Console. + +#### Option A β€” .NET CLI + +If you use the .NET CLI: + +```bash +dotnet ef migrations add InitUAuth --context UAuthDbContext +dotnet ef database update --context UAuthDbContext +``` + +If the dotnet ef command is not available, install the EF Core CLI tool: + ```bash -dotnet ef migrations add InitUAuth -dotnet ef database update +dotnet tool install --global dotnet-ef ``` -or -If you are using Visual Studio, you can run these commands in Package Manager Console*: +Your project also needs the Entity Framework Core design package: + +```bash +dotnet add package Microsoft.EntityFrameworkCore.Design +``` + +#### Option B β€” Visual Studio Package Manager Console + +If you use Visual Studio, you can perform the same operation from Tools β†’ NuGet Package Manager β†’ Package Manager Console: + ```bash Add-Migration InitUAuth -Context UAuthDbContext Update-Database -Context UAuthDbContext ``` -*Needs `Microsoft.EntityFrameworkCore.Design` and `Microsoft.EntityFrameworkCore.Tools` + +For Package Manager Console tooling, make sure the required EF Core tooling package is available: + +```bash +Install-Package Microsoft.EntityFrameworkCore.Tools +``` + +### Who Owns the Migrations? + +Your application does. + +This is intentional. UltimateAuth provides the authentication and identity model through its Entity Framework Core integration, while your application remains responsible for managing the resulting database schema. + +This means you can: + +- review migrations before applying them, +- include UltimateAuth schema changes in your normal deployment process, +- control when database changes are applied, +- maintain migration history alongside your application, +- use the database provider and deployment strategy appropriate for your environment. + +When upgrading UltimateAuth, review the release notes for persistence-related schema changes and create a new migration when required. + +> **Tip:** Treat UltimateAuth model changes like any other Entity Framework Core model change: upgrade the package, create a migration, review the generated migration, and apply it through your normal deployment process. ## Configure Services With Options UltimateAuth provides rich options for server and client service registration. diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/real-world-setup.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/real-world-setup.json index 867b1a17..f2964dbf 100644 --- a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/real-world-setup.json +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/getting-started/real-world-setup.json @@ -1,7 +1,7 @@ { "Slug": "getting-started/real-world-setup", "Title": "Real World Setup", - "Html": "\n\u003Cp\u003EThe Quick Start uses an in-memory setup for simplicity.\nIn real-world applications, you should replace it with a persistent configuration as shown below.\u003C/p\u003E\n\u003Cp\u003EIn real applications, you will typically configure:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EA persistent database\u003C/li\u003E\n\u003Cli\u003EAn appropriate client profile\u003C/li\u003E\n\u003Cli\u003EA suitable authentication mode\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThis guide shows how to set up UltimateAuth for real-world scenarios.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022using-entity-framework-core\u0022\u003E\uD83D\uDDC4\uFE0F Using Entity Framework Core\u003C/h2\u003E\n\u003Cp\u003EFor production, you should use a persistent store. In this setup, you no longer need the \u003Ccode\u003ECodeBeam.UltimateAuth.InMemory.Bundle\u003C/code\u003E package.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022install-packages\u0022\u003EInstall Packages\u003C/h3\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-services\u0022\u003EConfigure Services\u003C/h3\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services\n .AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(\u0026quot;Data Source=uauth.db\u0026quot;);\n // or UseSqlServer / UseNpgsql\n });\n\nbuilder.Services\n .AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022create-database-migrations\u0022\u003ECreate Database \u0026amp; Migrations\u003C/h3\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet ef migrations add InitUAuth\ndotnet ef database update\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003Eor\u003C/p\u003E\n\u003Cp\u003EIf you are using Visual Studio, you can run these commands in Package Manager Console*:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003EAdd-Migration InitUAuth -Context UAuthDbContext\nUpdate-Database -Context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003E*Needs \u003Ccode\u003EMicrosoft.EntityFrameworkCore.Design\u003C/code\u003E and \u003Ccode\u003EMicrosoft.EntityFrameworkCore.Tools\u003C/code\u003E\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-services-with-options\u0022\u003EConfigure Services With Options\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth provides rich options for server and client service registration.\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(o =\u0026gt; {\n o.Diagnostics.EnableRefreshDetails = true;\n o.Login.MaxFailedAttempts = 4;\n o.Identifiers.AllowMultipleUsernames = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-standalone-wasm-setup\u0022\u003EBlazor Standalone WASM Setup\u003C/h2\u003E\n\u003Cp\u003EBlazor WASM applications run entirely on the client and cannot securely handle credentials.\nFor this reason, UltimateAuth uses a dedicated Auth server called \u003Cstrong\u003EUAuthHub\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EWASM \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthClientBlazor(o =\u0026gt;\n{\n o.Endpoints.BasePath = \u0026quot;https://localhost:6110/auth\u0026quot;; // UAuthHub URL\n o.Pkce.ReturnUrl = \u0026quot;https://localhost:6130/home\u0026quot;; // Your (WASM) application domain \u002B return path\n});\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EUAuthHub \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthInMemory()\n .AddUAuthHub(o =\u0026gt; o.AllowedClientOrigins.Add(\u0026quot;https://localhost:6130\u0026quot;)); // WASM application\u0027s URL\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EUAuthHub Pipeline Configuration\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.MapUltimateAuthEndpoints();\napp.MapUAuthHub();\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-web-app-setup\u0022\u003EBlazor Web App Setup\u003C/h2\u003E\n\u003Cp\u003EA blazor web app contains two projects that includes host and client. You need to arrange them both.\u003C/p\u003E\n\u003Cp\u003EIn the host project:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthClientBlazor(o =\u0026gt;\n{\n o.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;; // UAuthHub URL\n o.Pkce.ReturnUrl = \u0026quot;https://localhost:6132/home\u0026quot;; // Current application domain \u002B path\n});\n\n// In pipeline configuration\napp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveWebAssemblyRenderMode()\n .AddAdditionalAssemblies(UAuthAssemblies.BlazorClient().First());\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EIn the client project:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthClientBlazor(o =\u0026gt;\n{\n o.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;; // UAuthHub URL\n o.Pkce.ReturnUrl = \u0026quot;https://localhost:6132/home\u0026quot;; // Current application domain \u002B path\n});\n\nbuilder.Services.AddScoped(sp =\u0026gt; new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });\n\n// Optional if you use external API calls in your client project.\nbuilder.Services.AddHttpClient(\u0026quot;resourceApi\u0026quot;, client =\u0026gt;\n{\n client.BaseAddress = new Uri(\u0026quot;https://localhost:6122\u0026quot;);\n});\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003EIf you want to use embedded UAuthHub in host project, you can register server services as shown in quickstart.\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003E\u2139\uFE0F UltimateAuth automatically selects the appropriate authentication mode (PureOpaque, Hybrid, etc.) based on the client type.\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022resourceapi-setup\u0022\u003EResourceApi Setup\u003C/h2\u003E\n\u003Cp\u003EYou may want to secure your custom API with UltimateAuth. UltimateAuth provides a lightweight option for this case. (ResourceApi doesn\u0027t have to be a blazor application, it can be any server-side project like MVC.)\u003C/p\u003E\n\u003Cp\u003EResourceApi\u0027s \u003Ccode\u003EProgram.cs\u003C/code\u003E\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthResourceApi(o =\u0026gt;\n {\n o.UAuthHubBaseUrl = \u0026quot;https://localhost:6110\u0026quot;;\n o.AllowedClientOrigins.Add(\u0026quot;https://localhost:6130\u0026quot;);\n });\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EConfigure pipeline:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseUltimateAuthResourceApiWithAspNetCore();\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003ENotes:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EResourceApi should connect with an UAuthHub, not a pure-server. Make sure \u003Ccode\u003E.AddUAuthHub()\u003C/code\u003E after calling \u003Ccode\u003Ebuilder.Services.AddUltimateAuthServer()\u003C/code\u003E.\u003C/li\u003E\n\u003Cli\u003EUltimateAuth automatically configures CORS based on the provided origins.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EUse ResourceApi when:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EYou have a separate backend API\u003C/li\u003E\n\u003Cli\u003EYou want to validate sessions or tokens externally\u003C/li\u003E\n\u003Cli\u003EYour API is not hosting UltimateAuth directly\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022how-to-think-about-setup\u0022\u003E\uD83E\uDDE0 How to Think About Setup\u003C/h2\u003E\n\u003Cp\u003EIn UltimateAuth:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EThe \u003Cstrong\u003EServer\u003C/strong\u003E manages authentication flows and sessions\u003C/li\u003E\n\u003Cli\u003EThe \u003Cstrong\u003EClient\u003C/strong\u003E interacts through flows (not tokens directly)\u003C/li\u003E\n\u003Cli\u003EThe \u003Cstrong\u003EStorage layer\u003C/strong\u003E (InMemory / EF Core) defines persistence\u003C/li\u003E\n\u003Cli\u003EThe \u003Cstrong\u003EApplication type\u003C/strong\u003E determines runtime behavior\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003E\uD83D\uDC49 You configure the system once, and UltimateAuth adapts automatically.\u003C/p\u003E\n", + "Html": "\n\u003Cp\u003EThe Quick Start uses an in-memory setup for simplicity.\nIn real-world applications, you should replace it with a persistent configuration as shown below.\u003C/p\u003E\n\u003Cp\u003EIn real applications, you will typically configure:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EA persistent database\u003C/li\u003E\n\u003Cli\u003EAn appropriate client profile\u003C/li\u003E\n\u003Cli\u003EA suitable authentication mode\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EThis guide shows how to set up UltimateAuth for real-world scenarios.\u003C/p\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022using-entity-framework-core\u0022\u003E\uD83D\uDDC4\uFE0F Using Entity Framework Core\u003C/h2\u003E\n\u003Cp\u003EFor production, you should use a persistent store. (In-memory provider is volatile and automatically resets on each restart.)\u003C/p\u003E\n\u003Cp\u003EIn this setup, you no longer need the \u003Ccode\u003ECodeBeam.UltimateAuth.InMemory.Bundle\u003C/code\u003E package.\u003C/p\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022install-packages\u0022\u003EInstall Packages\u003C/h3\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-services\u0022\u003EConfigure Services\u003C/h3\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services\n .AddUltimateAuthServer()\n .AddUltimateAuthEntityFrameworkCore(db =\u0026gt;\n {\n db.UseSqlite(\u0026quot;Data Source=uauth.db\u0026quot;);\n // or UseSqlServer(...) / UseNpgsql(...) / UseMySql(...) etc.\n });\n\nbuilder.Services\n .AddUltimateAuthClientBlazor();\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022database-migrations\u0022\u003EDatabase Migrations\u003C/h3\u003E\n\u003Cp\u003EUltimateAuth integrates with Entity Framework Core, but database migrations belong to your application.\u003C/p\u003E\n\u003Cp\u003EUltimateAuth does not automatically create or apply migrations on your behalf. This keeps your database schema lifecycle under your control and allows migrations to follow the same deployment and review process as the rest of your application.\u003C/p\u003E\n\u003Cp\u003EAfter configuring the Entity Framework Core provider, create the initial migration and update the database using either the .NET CLI or Visual Studio Package Manager Console.\u003C/p\u003E\n\u003Ch4 id=\u0022option-a.net-cli\u0022\u003EOption A \u2014 .NET CLI\u003C/h4\u003E\n\u003Cp\u003EIf you use the .NET CLI:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet ef migrations add InitUAuth --context UAuthDbContext\ndotnet ef database update --context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EIf the dotnet ef command is not available, install the EF Core CLI tool:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet tool install --global dotnet-ef\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EYour project also needs the Entity Framework Core design package:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003Edotnet add package Microsoft.EntityFrameworkCore.Design\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch4 id=\u0022option-b-visual-studio-package-manager-console\u0022\u003EOption B \u2014 Visual Studio Package Manager Console\u003C/h4\u003E\n\u003Cp\u003EIf you use Visual Studio, you can perform the same operation from Tools \u2192 NuGet Package Manager \u2192 Package Manager Console:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003EAdd-Migration InitUAuth -Context UAuthDbContext\nUpdate-Database -Context UAuthDbContext\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EFor Package Manager Console tooling, make sure the required EF Core tooling package is available:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-bash\u0022\u003EInstall-Package Microsoft.EntityFrameworkCore.Tools\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch3 class=\u0022mud-scrollspy-section\u0022 id=\u0022who-owns-the-migrations\u0022\u003EWho Owns the Migrations?\u003C/h3\u003E\n\u003Cp\u003EYour application does.\u003C/p\u003E\n\u003Cp\u003EThis is intentional. UltimateAuth provides the authentication and identity model through its Entity Framework Core integration, while your application remains responsible for managing the resulting database schema.\u003C/p\u003E\n\u003Cp\u003EThis means you can:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003Ereview migrations before applying them,\u003C/li\u003E\n\u003Cli\u003Einclude UltimateAuth schema changes in your normal deployment process,\u003C/li\u003E\n\u003Cli\u003Econtrol when database changes are applied,\u003C/li\u003E\n\u003Cli\u003Emaintain migration history alongside your application,\u003C/li\u003E\n\u003Cli\u003Euse the database provider and deployment strategy appropriate for your environment.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EWhen upgrading UltimateAuth, review the release notes for persistence-related schema changes and create a new migration when required.\u003C/p\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003E\u003Cstrong\u003ETip:\u003C/strong\u003E Treat UltimateAuth model changes like any other Entity Framework Core model change: upgrade the package, create a migration, review the generated migration, and apply it through your normal deployment process.\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022configure-services-with-options\u0022\u003EConfigure Services With Options\u003C/h2\u003E\n\u003Cp\u003EUltimateAuth provides rich options for server and client service registration.\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer(o =\u0026gt; {\n o.Diagnostics.EnableRefreshDetails = true;\n o.Login.MaxFailedAttempts = 4;\n o.Identifiers.AllowMultipleUsernames = true;\n});\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-standalone-wasm-setup\u0022\u003EBlazor Standalone WASM Setup\u003C/h2\u003E\n\u003Cp\u003EBlazor WASM applications run entirely on the client and cannot securely handle credentials.\nFor this reason, UltimateAuth uses a dedicated Auth server called \u003Cstrong\u003EUAuthHub\u003C/strong\u003E.\u003C/p\u003E\n\u003Cp\u003EWASM \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthClientBlazor(o =\u0026gt;\n{\n o.Endpoints.BasePath = \u0026quot;https://localhost:6110/auth\u0026quot;; // UAuthHub URL\n o.Pkce.ReturnUrl = \u0026quot;https://localhost:6130/home\u0026quot;; // Your (WASM) application domain \u002B return path\n});\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EUAuthHub \u003Ccode\u003EProgram.cs\u003C/code\u003E:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthServer()\n .AddUltimateAuthInMemory()\n .AddUAuthHub(o =\u0026gt; o.AllowedClientOrigins.Add(\u0026quot;https://localhost:6130\u0026quot;)); // WASM application\u0027s URL\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EUAuthHub Pipeline Configuration\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.MapUltimateAuthEndpoints();\napp.MapUAuthHub();\n\u003C/code\u003E\u003C/pre\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022blazor-web-app-setup\u0022\u003EBlazor Web App Setup\u003C/h2\u003E\n\u003Cp\u003EA blazor web app contains two projects that includes host and client. You need to arrange them both.\u003C/p\u003E\n\u003Cp\u003EIn the host project:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthClientBlazor(o =\u0026gt;\n{\n o.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;; // UAuthHub URL\n o.Pkce.ReturnUrl = \u0026quot;https://localhost:6132/home\u0026quot;; // Current application domain \u002B path\n});\n\n// In pipeline configuration\napp.MapRazorComponents\u0026lt;App\u0026gt;()\n .AddInteractiveWebAssemblyRenderMode()\n .AddAdditionalAssemblies(UAuthAssemblies.BlazorClient().First());\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EIn the client project:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthClientBlazor(o =\u0026gt;\n{\n o.Endpoints.BasePath = \u0026quot;https://localhost:6112/auth\u0026quot;; // UAuthHub URL\n o.Pkce.ReturnUrl = \u0026quot;https://localhost:6132/home\u0026quot;; // Current application domain \u002B path\n});\n\nbuilder.Services.AddScoped(sp =\u0026gt; new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });\n\n// Optional if you use external API calls in your client project.\nbuilder.Services.AddHttpClient(\u0026quot;resourceApi\u0026quot;, client =\u0026gt;\n{\n client.BaseAddress = new Uri(\u0026quot;https://localhost:6122\u0026quot;);\n});\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003EIf you want to use embedded UAuthHub in host project, you can register server services as shown in quickstart.\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Cblockquote\u003E\n\u003Cp\u003E\u2139\uFE0F UltimateAuth automatically selects the appropriate authentication mode (PureOpaque, Hybrid, etc.) based on the client type.\u003C/p\u003E\n\u003C/blockquote\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022resourceapi-setup\u0022\u003EResourceApi Setup\u003C/h2\u003E\n\u003Cp\u003EYou may want to secure your custom API with UltimateAuth. UltimateAuth provides a lightweight option for this case. (ResourceApi doesn\u0027t have to be a blazor application, it can be any server-side project like MVC.)\u003C/p\u003E\n\u003Cp\u003EResourceApi\u0027s \u003Ccode\u003EProgram.cs\u003C/code\u003E\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Ebuilder.Services.AddUltimateAuthResourceApi(o =\u0026gt;\n {\n o.UAuthHubBaseUrl = \u0026quot;https://localhost:6110\u0026quot;;\n o.AllowedClientOrigins.Add(\u0026quot;https://localhost:6130\u0026quot;);\n });\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003EConfigure pipeline:\u003C/p\u003E\n\u003Cpre\u003E\u003Ccode class=\u0022language-csharp\u0022\u003Eapp.UseUltimateAuthResourceApiWithAspNetCore();\n\u003C/code\u003E\u003C/pre\u003E\n\u003Cp\u003ENotes:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EResourceApi should connect with an UAuthHub, not a pure-server. Make sure \u003Ccode\u003E.AddUAuthHub()\u003C/code\u003E after calling \u003Ccode\u003Ebuilder.Services.AddUltimateAuthServer()\u003C/code\u003E.\u003C/li\u003E\n\u003Cli\u003EUltimateAuth automatically configures CORS based on the provided origins.\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003EUse ResourceApi when:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EYou have a separate backend API\u003C/li\u003E\n\u003Cli\u003EYou want to validate sessions or tokens externally\u003C/li\u003E\n\u003Cli\u003EYour API is not hosting UltimateAuth directly\u003C/li\u003E\n\u003C/ul\u003E\n\u003Ch2 class=\u0022mud-scrollspy-section\u0022 id=\u0022how-to-think-about-setup\u0022\u003E\uD83E\uDDE0 How to Think About Setup\u003C/h2\u003E\n\u003Cp\u003EIn UltimateAuth:\u003C/p\u003E\n\u003Cul\u003E\n\u003Cli\u003EThe \u003Cstrong\u003EServer\u003C/strong\u003E manages authentication flows and sessions\u003C/li\u003E\n\u003Cli\u003EThe \u003Cstrong\u003EClient\u003C/strong\u003E interacts through flows (not tokens directly)\u003C/li\u003E\n\u003Cli\u003EThe \u003Cstrong\u003EStorage layer\u003C/strong\u003E (InMemory / EF Core) defines persistence\u003C/li\u003E\n\u003Cli\u003EThe \u003Cstrong\u003EApplication type\u003C/strong\u003E determines runtime behavior\u003C/li\u003E\n\u003C/ul\u003E\n\u003Cp\u003E\uD83D\uDC49 You configure the system once, and UltimateAuth adapts automatically.\u003C/p\u003E\n", "Headings": [ { "Id": "using-entity-framework-core", @@ -19,8 +19,13 @@ "Level": 1 }, { - "Id": "create-database-migrations", - "Text": "Create Database \u0026 Migrations", + "Id": "database-migrations", + "Text": "Database Migrations", + "Level": 1 + }, + { + "Id": "who-owns-the-migrations", + "Text": "Who Owns the Migrations?", "Level": 1 }, { diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore.csproj b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore.csproj index f6e72309..3bf1515b 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore.csproj +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore.csproj @@ -1,4 +1,4 @@ - +ο»Ώ net10.0 @@ -9,19 +9,19 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Layout/MainLayout.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Layout/MainLayout.razor index ac680f3b..8eec2be6 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Layout/MainLayout.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Layout/MainLayout.razor @@ -1,9 +1,9 @@ -ο»Ώ@inherits UAuthHubLayoutComponentBase +ο»Ώ@inherits UAuthHubLayoutBase @inject IUAuthClient UAuthClient @inject ISnackbar Snackbar @inject NavigationManager Nav -@if (!IsHubAuthorized) +@if (!IsHubActive) { diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Layout/MainLayout.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Layout/MainLayout.razor.cs index e5886028..4b196823 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Layout/MainLayout.razor.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Layout/MainLayout.razor.cs @@ -52,7 +52,7 @@ private void HandleSignInClick() if (uri.AbsolutePath.EndsWith("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/login", StringComparison.OrdinalIgnoreCase)) { - Nav.NavigateTo("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/login?focus=1", replace: true, forceLoad: true); + Nav.NavigateTo("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/login?uauth_focus=1", replace: true, forceLoad: true); return; } diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Pages/Home.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Pages/Home.razor.cs index 0cc81888..7897aca7 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Pages/Home.razor.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.EFCore/Components/Pages/Home.razor.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Client.Blazor; using CodeBeam.UltimateAuth.Client.Runtime; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Stores; using MudBlazor; @@ -56,7 +57,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (HubSessionId.TryParse(HubKey, out var hubSessionId)) { - await ReloadState(); + await ReloadStateAsync(); } await _loginForm.ReloadAsync(); @@ -140,20 +141,20 @@ private async Task ResolveReturnUrlAsync() if (!string.IsNullOrWhiteSpace(fromContext)) return fromContext; - var uri = Nav.ToAbsoluteUri(Nav.Uri); + var uri = Navigation.ToAbsoluteUri(Navigation.Uri); var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); - if (query.TryGetValue("return_url", out var ru) && !string.IsNullOrWhiteSpace(ru)) + if (query.TryGetValue(UAuthConstants.Query.ReturnUrl, out var ru) && !string.IsNullOrWhiteSpace(ru)) return ru!; - if (query.TryGetValue("hub", out var hubKey) && !string.IsNullOrWhiteSpace(hubKey)) + if (query.TryGetValue(UAuthConstants.Query.Hub, out var hubKey) && !string.IsNullOrWhiteSpace(hubKey)) { var artifact = await AuthStore.GetAsync(new AuthArtifactKey(hubKey!)); if (artifact is HubFlowArtifact flow && !string.IsNullOrWhiteSpace(flow.ReturnUrl)) return flow.ReturnUrl!; } - return Nav.Uri; + return Navigation.Uri; } private async void StartCountdown() diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj index a5ccdc5e..5e9e2eee 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub.csproj @@ -9,10 +9,10 @@
- - - - + + + + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor index ac680f3b..8eec2be6 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor @@ -1,9 +1,9 @@ -ο»Ώ@inherits UAuthHubLayoutComponentBase +ο»Ώ@inherits UAuthHubLayoutBase @inject IUAuthClient UAuthClient @inject ISnackbar Snackbar @inject NavigationManager Nav -@if (!IsHubAuthorized) +@if (!IsHubActive) { diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs index 7242adbd..4b35a909 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/MainLayout.razor.cs @@ -52,7 +52,7 @@ private void HandleSignInClick() if (uri.AbsolutePath.EndsWith("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/login", StringComparison.OrdinalIgnoreCase)) { - Nav.NavigateTo("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/login?focus=1", replace: true, forceLoad: true); + Nav.NavigateTo("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/login?uauth_focus=1", replace: true, forceLoad: true); return; } diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs index c3258e6a..a5f93cee 100644 --- a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs @@ -2,6 +2,7 @@ using CodeBeam.UltimateAuth.Client.Blazor; using CodeBeam.UltimateAuth.Client.Runtime; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Server.Stores; using MudBlazor; @@ -56,7 +57,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (HubSessionId.TryParse(HubKey, out var hubSessionId)) { - await ReloadState(); + await ReloadStateAsync(); } await _loginForm.ReloadAsync(); @@ -140,20 +141,20 @@ private async Task ResolveReturnUrlAsync() if (!string.IsNullOrWhiteSpace(fromContext)) return fromContext; - var uri = Nav.ToAbsoluteUri(Nav.Uri); + var uri = Navigation.ToAbsoluteUri(Navigation.Uri); var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); - if (query.TryGetValue("return_url", out var ru) && !string.IsNullOrWhiteSpace(ru)) + if (query.TryGetValue(UAuthConstants.Query.ReturnUrl, out var ru) && !string.IsNullOrWhiteSpace(ru)) return ru!; - if (query.TryGetValue("hub", out var hubKey) && !string.IsNullOrWhiteSpace(hubKey)) + if (query.TryGetValue(UAuthConstants.Query.Hub, out var hubKey) && !string.IsNullOrWhiteSpace(hubKey)) { var artifact = await AuthStore.GetAsync(new AuthArtifactKey(hubKey!)); if (artifact is HubFlowArtifact flow && !string.IsNullOrWhiteSpace(flow.ReturnUrl)) return flow.ReturnUrl!; } - return Nav.Uri; + return Navigation.Uri; } private async void StartCountdown() diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.csproj b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.csproj index 90bfe9d6..98b4e775 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.csproj +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.csproj @@ -9,19 +9,19 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/AuthorizedTestPage.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/AuthorizedTestPage.razor index 7218a9c1..a5e4fde7 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/AuthorizedTestPage.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/AuthorizedTestPage.razor @@ -1,6 +1,6 @@ ο»Ώ@page "/authorized-test" @attribute [UAuthAuthorize] -@inherits UAuthFlowPageBase +@inherits UAuthPageBase diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor index 74cb1b79..162c37ea 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor @@ -1,6 +1,6 @@ ο»Ώ@page "/home" @attribute [UAuthAuthorize] -@inherits UAuthFlowPageBase +@inherits UAuthPageBase @inject IUAuthClient UAuthClient @inject UAuthClientDiagnostics Diagnostics diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor.cs index ab0018b8..5764a674 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor.cs @@ -12,7 +12,7 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Pages; -public partial class Home : UAuthFlowPageBase +public partial class Home : UAuthPageBase { private string _selectedAuthState = "UAuthState"; private ClaimsPrincipal? _aspNetCoreState; diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor index f1d587c7..5eec659a 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor @@ -1,6 +1,6 @@ ο»Ώ@page "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/login" @attribute [UAuthLoginPage] -@inherits UAuthFlowPageBase +@inherits UAuthPageBase @implements IDisposable @inject IUAuthClient UAuthClient @@ -34,7 +34,7 @@ - + Continue diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor.cs index 0559a29b..011bdec3 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor.cs @@ -7,7 +7,7 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Pages; -public partial class Login : UAuthFlowPageBase +public partial class Login : UAuthPageBase { private string? _username; private string? _password; diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Register.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Register.razor index 881cae5c..20b38b75 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Register.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Register.razor @@ -1,5 +1,5 @@ ο»Ώ@page "/register" -@inherits UAuthFlowPageBase +@inherits UAuthPageBase @implements IDisposable @inject IUAuthClient UAuthClient diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor index 753878b8..2623395b 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor @@ -1,5 +1,5 @@ ο»Ώ@page "/reset" -@inherits UAuthFlowPageBase +@inherits UAuthPageBase @inject IUAuthClient UAuthClient @inject ISnackbar Snackbar diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor.cs index 71a4d93e..2cd5e204 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor.cs @@ -37,7 +37,7 @@ private async Task ResetPasswordAsync() if (result.IsSuccess) { Snackbar.Add("Credential reset successfully. Please log in with your new password.", Severity.Success); - Nav.NavigateTo("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/login"); + Navigation.NavigateTo("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/login"); } else { diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm index f64b9c90..1c694283 100644 Binary files a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm and b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-shm differ diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal index 05f8b783..b5380fc8 100644 Binary files a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal and b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal differ diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj index daf780dd..5345f4b9 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj @@ -8,10 +8,10 @@ - - - - + + + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs index 47d68df7..92d16814 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs @@ -52,7 +52,7 @@ private void HandleSignInClick() if (uri.AbsolutePath.EndsWith("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/login", StringComparison.OrdinalIgnoreCase)) { - Nav.NavigateTo("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/login?focus=1", replace: true, forceLoad: true); + Nav.NavigateTo("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/login?uauth_focus=1", replace: true, forceLoad: true); return; } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor index d0a06c06..0b3772f8 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor @@ -1,6 +1,7 @@ ο»Ώ@page "/authorized-test" +@using CodeBeam.UltimateAuth.Core.Defaults @attribute [UAuthAuthorize] -@inherits UAuthFlowPageBase +@inherits UAuthPageBase @@ -19,6 +20,10 @@ + + This is admin view content. + + UltimateAuth protects this resource based on your session and permissions. diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor index 02cb0f28..0e90fd7a 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -1,6 +1,6 @@ ο»Ώ@page "/home" @attribute [UAuthAuthorize] -@inherits UAuthFlowPageBase +@inherits UAuthPageBase @inject IUAuthClient UAuthClient @inject UAuthClientDiagnostics Diagnostics diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs index 3faeca19..415e586a 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs @@ -12,7 +12,7 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages; -public partial class Home : UAuthFlowPageBase +public partial class Home : UAuthPageBase { private string _selectedAuthState = "UAuthState"; private ClaimsPrincipal? _aspNetCoreState; diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor index f1d587c7..5eec659a 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor @@ -1,6 +1,6 @@ ο»Ώ@page "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/login" @attribute [UAuthLoginPage] -@inherits UAuthFlowPageBase +@inherits UAuthPageBase @implements IDisposable @inject IUAuthClient UAuthClient @@ -34,7 +34,7 @@ - + Continue diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs index 0cbc8441..d9f06ffa 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs @@ -7,7 +7,7 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages; -public partial class Login : UAuthFlowPageBase +public partial class Login : UAuthPageBase { private string? _username; private string? _password; diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor index 881cae5c..20b38b75 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor @@ -1,5 +1,5 @@ ο»Ώ@page "/register" -@inherits UAuthFlowPageBase +@inherits UAuthPageBase @implements IDisposable @inject IUAuthClient UAuthClient diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor index 753878b8..2623395b 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor @@ -1,5 +1,5 @@ ο»Ώ@page "/reset" -@inherits UAuthFlowPageBase +@inherits UAuthPageBase @inject IUAuthClient UAuthClient @inject ISnackbar Snackbar diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs index 9bcaf5f7..681ff884 100644 --- a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs @@ -37,7 +37,7 @@ private async Task ResetPasswordAsync() if (result.IsSuccess) { Snackbar.Add("Credential reset successfully. Please log in with your new password.", Severity.Success); - Nav.NavigateTo("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/login"); + Navigation.NavigateTo("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/login"); } else { diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.csproj b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.csproj index 4b88fd56..db5ea39b 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.csproj +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.csproj @@ -8,12 +8,12 @@ - - - - - - + + + + + + diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor.cs index 8567fb06..06946b76 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor.cs @@ -52,7 +52,7 @@ private void HandleSignInClick() if (uri.AbsolutePath.EndsWith("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/login", StringComparison.OrdinalIgnoreCase)) { - Nav.NavigateTo("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/login?focus=1", replace: true, forceLoad: true); + Nav.NavigateTo("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/login?uauth_focus=1", replace: true, forceLoad: true); return; } diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/AuthorizedTestPage.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/AuthorizedTestPage.razor index 2dd294b2..cf9519f6 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/AuthorizedTestPage.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/AuthorizedTestPage.razor @@ -1,6 +1,6 @@ ο»Ώ@page "/authorized-test" @attribute [UAuthAuthorize] -@inherits UAuthFlowPageBase +@inherits UAuthPageBase diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor index 4db6dfcf..8b20171f 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor @@ -1,6 +1,6 @@ ο»Ώ@page "/home" @attribute [UAuthAuthorize] -@inherits UAuthFlowPageBase +@inherits UAuthPageBase @inject IUAuthClient UAuthClient @inject UAuthClientDiagnostics Diagnostics diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs index 6c8122d8..4464b6ff 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs @@ -12,7 +12,7 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Pages; -public partial class Home : UAuthFlowPageBase +public partial class Home : UAuthPageBase { private string _selectedAuthState = "UAuthState"; private ClaimsPrincipal? _aspNetCoreState; diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor index 5c3245d9..595addf8 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor @@ -1,6 +1,6 @@ ο»Ώ@page "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/login" @attribute [UAuthLoginPage] -@inherits UAuthFlowPageBase +@inherits UAuthPageBase @implements IDisposable @inject IUAuthClient UAuthClient @@ -34,7 +34,7 @@ - + Continue diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs index b644ee9a..78129102 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs @@ -8,7 +8,7 @@ namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Pages; -public partial class Login : UAuthFlowPageBase +public partial class Login : UAuthPageBase { private string? _username; private string? _password; @@ -83,7 +83,7 @@ private async Task StartPkceLogin() { string? returnUrl = null; if (!string.IsNullOrEmpty(ReturnUrl)) - returnUrl = Nav.BaseUri + ReturnUrl.TrimStart('/'); + returnUrl = Navigation.BaseUri + ReturnUrl.TrimStart('/'); await UAuthClient.Flows.BeginPkceAsync(returnUrl); } diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Register.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Register.razor index 881cae5c..20b38b75 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Register.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Register.razor @@ -1,5 +1,5 @@ ο»Ώ@page "/register" -@inherits UAuthFlowPageBase +@inherits UAuthPageBase @implements IDisposable @inject IUAuthClient UAuthClient diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor index 753878b8..2623395b 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor @@ -1,5 +1,5 @@ ο»Ώ@page "/reset" -@inherits UAuthFlowPageBase +@inherits UAuthPageBase @inject IUAuthClient UAuthClient @inject ISnackbar Snackbar diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs index b76e12b1..43e7b541 100644 --- a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs @@ -37,7 +37,7 @@ private async Task ResetPasswordAsync() if (result.IsSuccess) { Snackbar.Add("Credential reset successfully. Please log in with your new password.", Severity.Success); - Nav.NavigateTo("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/login"); + Navigation.NavigateTo("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/login"); } else { diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/CodeBeam.UAuth.Sample.IntWasm.Client.csproj b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/CodeBeam.UAuth.Sample.IntWasm.Client.csproj index 93156414..e4408ae2 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/CodeBeam.UAuth.Sample.IntWasm.Client.csproj +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/CodeBeam.UAuth.Sample.IntWasm.Client.csproj @@ -11,10 +11,10 @@ - - - - + + + + diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Layout/MainLayout.razor.cs b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Layout/MainLayout.razor.cs index dfefa793..1e788a96 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Layout/MainLayout.razor.cs +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Layout/MainLayout.razor.cs @@ -52,7 +52,7 @@ private void HandleSignInClick() if (uri.AbsolutePath.EndsWith("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/login", StringComparison.OrdinalIgnoreCase)) { - Nav.NavigateTo("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/login?focus=1", replace: true, forceLoad: true); + Nav.NavigateTo("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/login?uauth_focus=1", replace: true, forceLoad: true); return; } diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/AuthorizedTestPage.razor b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/AuthorizedTestPage.razor index 9d78b597..962555ed 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/AuthorizedTestPage.razor +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/AuthorizedTestPage.razor @@ -1,6 +1,6 @@ ο»Ώ@page "/authorized-test" @attribute [UAuthAuthorize] -@inherits UAuthFlowPageBase +@inherits UAuthPageBase diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Home.razor b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Home.razor index b2aba719..2503b3af 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Home.razor +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Home.razor @@ -1,6 +1,6 @@ ο»Ώ@page "/home" @attribute [UAuthAuthorize] -@inherits UAuthFlowPageBase +@inherits UAuthPageBase @inject IUAuthClient UAuthClient @inject UAuthClientDiagnostics Diagnostics diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Home.razor.cs b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Home.razor.cs index c296ed00..f14b1df3 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Home.razor.cs +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Home.razor.cs @@ -12,7 +12,7 @@ namespace CodeBeam.UAuth.Sample.IntWasm.Client.Pages; -public partial class Home : UAuthFlowPageBase +public partial class Home : UAuthPageBase { private string _selectedAuthState = "UAuthState"; private ClaimsPrincipal? _aspNetCoreState; diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor index 7da73973..43e409a7 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor @@ -1,7 +1,7 @@ ο»Ώ@page "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/login" @using CodeBeam.UltimateAuth.Client.Runtime @attribute [UAuthLoginPage] -@inherits UAuthFlowPageBase +@inherits UAuthPageBase @implements IDisposable @inject IUAuthClient UAuthClient @@ -35,7 +35,7 @@ - + Continue diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor.cs b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor.cs index 970f9128..2cb3f032 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor.cs +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Login.razor.cs @@ -8,7 +8,7 @@ namespace CodeBeam.UAuth.Sample.IntWasm.Client.Pages; -public partial class Login : UAuthFlowPageBase +public partial class Login : UAuthPageBase { private string? _username; private string? _password; @@ -83,7 +83,7 @@ private async Task StartPkceLogin() { string? returnUrl = null; if (!string.IsNullOrEmpty(ReturnUrl)) - returnUrl = Nav.BaseUri + ReturnUrl.TrimStart('/'); + returnUrl = Navigation.BaseUri + ReturnUrl.TrimStart('/'); await UAuthClient.Flows.BeginPkceAsync(returnUrl); } diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Register.razor b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Register.razor index bb174660..41c7e05b 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Register.razor +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/Register.razor @@ -1,6 +1,6 @@ ο»Ώ@page "/register" @using CodeBeam.UltimateAuth.Client.Runtime -@inherits UAuthFlowPageBase +@inherits UAuthPageBase @implements IDisposable @inject IUAuthClient UAuthClient diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor index 753878b8..2623395b 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor @@ -1,5 +1,5 @@ ο»Ώ@page "/reset" -@inherits UAuthFlowPageBase +@inherits UAuthPageBase @inject IUAuthClient UAuthClient @inject ISnackbar Snackbar diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor.cs b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor.cs index fc9942f0..be79c63a 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor.cs +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.Client/Pages/ResetCredential.razor.cs @@ -37,7 +37,7 @@ private async Task ResetPasswordAsync() if (result.IsSuccess) { Snackbar.Add("Credential reset successfully. Please log in with your new password.", Severity.Success); - Nav.NavigateTo("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/login"); + Navigation.NavigateTo("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/login"); } else { diff --git a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.csproj b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.csproj index f309e0b0..e03668c9 100644 --- a/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.csproj +++ b/samples/int-wasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm/CodeBeam.UAuth.Sample.IntWasm.csproj @@ -11,7 +11,7 @@ - +
diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore.csproj b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore.csproj index 2d474a92..20471edc 100644 --- a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore.csproj +++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore/CodeBeam.UltimateAuth.Sample.ResourceApi.EfCore.csproj @@ -1,4 +1,4 @@ - +ο»Ώ net10.0 @@ -7,7 +7,7 @@ - + diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/CodeBeam.UltimateAuth.Sample.ResourceApi.csproj b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/CodeBeam.UltimateAuth.Sample.ResourceApi.csproj index 1c868dd7..cde66c01 100644 --- a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/CodeBeam.UltimateAuth.Sample.ResourceApi.csproj +++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/CodeBeam.UltimateAuth.Sample.ResourceApi.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authorization/AccessScope.cs similarity index 100% rename from src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs rename to src/CodeBeam.UltimateAuth.Core/Contracts/Authorization/AccessScope.cs diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authorization/AuthorizationMatchMode.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authorization/AuthorizationMatchMode.cs new file mode 100644 index 00000000..8711835c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authorization/AuthorizationMatchMode.cs @@ -0,0 +1,8 @@ +ο»Ώnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum AuthorizationMatchMode +{ + Any = 0, + All = 1, + Category = 2 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs index 7c057ff2..e004fb60 100644 --- a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs @@ -10,8 +10,8 @@ public sealed record PkceCompleteRequest [JsonPropertyName("code_verifier")] public required string CodeVerifier { get; init; } - public required string Identifier { get; init; } + public required string Secret { get; init; } [JsonPropertyName("return_url")] diff --git a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs index 37881b9d..31de832e 100644 --- a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs @@ -36,8 +36,11 @@ public static class Form public static class Query { - public const string ReturnUrl = "return_url"; - public const string Hub = "hub"; + public const string Payload = "uauth"; + public const string Focus = "uauth_focus"; + public const string ReturnUrl = "uauth_return_url"; + public const string Identifier = "uauth_identifier"; + public const string Hub = "uauth_hub"; } public static class Headers diff --git a/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs b/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs index ed166fcc..d3c09386 100644 --- a/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs +++ b/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs @@ -1,3 +1,4 @@ ο»Ώusing System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs index dd7b1e36..cbaaf154 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs @@ -19,17 +19,15 @@ public sealed class LogoutEndpointHandler : ILogoutEndpointHandler private readonly IUAuthFlowService _flow; private readonly IAccessContextFactory _accessContextFactory; private readonly ISessionApplicationService _sessionApplicationService; - private readonly IClock _clock; private readonly IUAuthCookieManager _cookieManager; private readonly IAuthRedirectResolver _redirectResolver; - public LogoutEndpointHandler(IAuthFlowContextAccessor authContext, IUAuthFlowService flow, IAccessContextFactory accessContextFactory, ISessionApplicationService sessionApplicationService, IClock clock, IUAuthCookieManager cookieManager, IAuthRedirectResolver redirectResolver) + public LogoutEndpointHandler(IAuthFlowContextAccessor authContext, IUAuthFlowService flow, IAccessContextFactory accessContextFactory, ISessionApplicationService sessionApplicationService, IUAuthCookieManager cookieManager, IAuthRedirectResolver redirectResolver) { _authContext = authContext; _flow = flow; _accessContextFactory = accessContextFactory; _sessionApplicationService = sessionApplicationService; - _clock = clock; _cookieManager = cookieManager; _redirectResolver = redirectResolver; } @@ -151,7 +149,7 @@ public async Task LogoutAllSelfAsync(HttpContext ctx) flow, UAuthActions.Flows.LogoutAllSelf, resource: "flows", - resourceId: userKey); + resourceId: userKey.Value); await _sessionApplicationService.LogoutAllDevicesAsync(access, userKey, ctx.RequestAborted); return Results.Ok(); diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs index 9743e39e..0270c215 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs @@ -163,6 +163,11 @@ public async Task CompleteAsync(HttpContext ctx) if (request is null) return Results.BadRequest("Invalid PKCE payload."); + if (string.IsNullOrWhiteSpace(request.AuthorizationCode) || string.IsNullOrWhiteSpace(request.CodeVerifier)) + { + return Results.BadRequest("authorization_code and code_verifier are required."); + } + var result = await _pkceService.CompleteAsync( auth, new PkceCompleteRequest @@ -269,7 +274,7 @@ public async Task CompleteAsync(HttpContext ctx) var codeVerifier = form?["code_verifier"].FirstOrDefault(); var identifier = form?["Identifier"].FirstOrDefault(); var secret = form?["Secret"].FirstOrDefault(); - var returnUrl = form?["return_url"].FirstOrDefault(); + var returnUrl = form?[UAuthConstants.Form.ReturnUrl].FirstOrDefault(); if (string.IsNullOrWhiteSpace(authorizationCode)) throw new UAuthValidationException("authorization_code is required"); diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs index f95fd6fa..b0875a33 100644 --- a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs @@ -82,7 +82,7 @@ public async Task ValidateAsync(HttpContext context, CancellationToken ); } - var snapshot = await _snapshotFactory.CreateAsync(result); + var snapshot = await _snapshotFactory.CreateAsync(result, ct); return Results.Ok(new AuthValidationResult { diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs index 54c018d5..1b353d97 100644 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -131,8 +131,15 @@ public async Task LoginAsync(AuthFlowContext flow, LoginRequest req { var chain = await sessionStore.GetChainByDeviceAsync(userKey.Value, deviceId, ct); - if (chain is not null && !chain.IsRevoked) - chainId = chain.ChainId; + if (chain is not null) + { + var chainState = chain.GetState(now, _options.Session.IdleTimeout); + + if (chainState == SessionState.Active) + { + chainId = chain.ChainId; + } + } } // TODO: Add accountState here, currently it only checks factor state diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshDecisionResolver.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshDecisionResolver.cs deleted file mode 100644 index 8108d0bb..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshDecisionResolver.cs +++ /dev/null @@ -1,24 +0,0 @@ -ο»Ώusing CodeBeam.UltimateAuth.Core; - -namespace CodeBeam.UltimateAuth.Server.Flows; - -/// -/// Resolves refresh behavior based on AuthMode. -/// This class is the single source of truth for refresh capability. -/// -public static class RefreshDecisionResolver -{ - public static RefreshDecision Resolve(UAuthMode mode) - { - return mode switch - { - UAuthMode.PureOpaque => RefreshDecision.SessionTouch, - - UAuthMode.Hybrid - or UAuthMode.SemiHybrid - or UAuthMode.PureJwt => RefreshDecision.TokenRotation, - - _ => RefreshDecision.NotSupported - }; - } -} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HandleHubEntry.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HandleHubEntry.cs index 9af186da..ec1ba774 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HandleHubEntry.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HandleHubEntry.cs @@ -25,7 +25,7 @@ internal static async Task HandleHubEntry(HttpContext ctx, IAuthStore s var authorizationCode = form["authorization_code"].ToString(); var codeVerifier = form["code_verifier"].ToString(); var deviceId = form["device_id"].ToString(); - var returnUrl = form["return_url"].ToString(); + var returnUrl = form[UAuthConstants.Form.ReturnUrl].ToString(); if (!Enum.TryParse(form["__uauth_client_profile"], ignoreCase: true, out var clientProfile)) { diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs index 7c0c35e0..0fb5e56c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs @@ -92,8 +92,13 @@ await kernel.ExecuteAsync(async _ => //chain = await kernel.GetChainAsync(context.ChainId.Value) // ?? throw new UAuthNotFoundException("Chain not found."); - if (chain.IsRevoked) - throw new UAuthValidationException("Chain revoked."); + var chainState = chain.GetState(now, _options.Session.IdleTimeout); + + if (chainState != SessionState.Active) + throw new UAuthValidationException("Chain is not active."); + + //if (chain.IsRevoked) + // throw new UAuthValidationException("Chain revoked."); if (chain.UserKey != context.UserKey || chain.Tenant != context.Tenant) throw new UAuthValidationException("Invalid chain ownership."); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RedirectDecision.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RedirectDecision.cs index cbee309e..294ce070 100644 --- a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RedirectDecision.cs +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RedirectDecision.cs @@ -5,7 +5,7 @@ public sealed class RedirectDecision public bool Enabled { get; } public string? TargetUrl { get; } - private RedirectDecision(bool enabled, string? targetUrl) + internal RedirectDecision(bool enabled, string? targetUrl) { Enabled = enabled; TargetUrl = targetUrl; diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs index 92f0e6d6..ad04b6bf 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs @@ -13,14 +13,12 @@ public sealed class RefreshTokenRotationService : IRefreshTokenRotationService private readonly IRefreshTokenValidator _validator; private readonly IRefreshTokenStoreFactory _storeFactory; private readonly ITokenIssuer _tokenIssuer; - private readonly IClock _clock; - public RefreshTokenRotationService(IRefreshTokenValidator validator, IRefreshTokenStoreFactory storeFactory, ITokenIssuer tokenIssuer, IClock clock) + public RefreshTokenRotationService(IRefreshTokenValidator validator, IRefreshTokenStoreFactory storeFactory, ITokenIssuer tokenIssuer) { _validator = validator; _storeFactory = storeFactory; _tokenIssuer = tokenIssuer; - _clock = clock; } // TODO: Handle reuse detection and make flow knows situation, but don't make security branch. @@ -37,25 +35,27 @@ public async Task RotateAsync(AuthFlowContext flo }, ct); - if (!validation.IsValid) - return new RefreshTokenRotationExecution() { Result = RefreshTokenRotationResult.Failed() }; - - var store = _storeFactory.Create(validation.Tenant); - if (validation.IsReuseDetected) { + var store1 = _storeFactory.Create(validation.Tenant); + if (validation.ChainId is not null) { - await store.RevokeByChainAsync(validation.ChainId.Value, context.Now, ct); + await store1.RevokeByChainAsync(validation.ChainId.Value, context.Now, ct); } else if (validation.SessionId is not null) { - await store.RevokeBySessionAsync(validation.SessionId.Value, context.Now, ct); + await store1.RevokeBySessionAsync(validation.SessionId.Value, context.Now, ct); } return new RefreshTokenRotationExecution() { Result = RefreshTokenRotationResult.Failed() }; } + if (!validation.IsValid) + return new RefreshTokenRotationExecution() { Result = RefreshTokenRotationResult.Failed() }; + + var store = _storeFactory.Create(validation.Tenant); + if (validation.UserKey is not UserKey userKey) throw new UAuthValidationException("Validated refresh token does not contain a UserKey."); @@ -85,7 +85,8 @@ public async Task RotateAsync(AuthFlowContext flo Result = RefreshTokenRotationResult.Failed() }; - // Never issue new refresh token before revoke old. Upperline doesn't persist token currently. + // Generate the replacement token without persisting it. + // Revoke the current token and persist its replacement atomically. await store.ExecuteAsync(async ct2 => { await store.RevokeAsync(validation.TokenHash, context.Now, refreshToken.TokenHash, ct2); @@ -97,7 +98,7 @@ await store.ExecuteAsync(async ct2 => userKey: userKey, sessionId: sessionId, chainId: validation.ChainId, - createdAt: _clock.UtcNow, + createdAt: context.Now, expiresAt: refreshToken.ExpiresAt ); diff --git a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs index d7640693..27f42a3a 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs @@ -33,12 +33,12 @@ public async Task> GetUserChainsAsync(AccessCon chains = request.SortBy switch { nameof(SessionChainSummary.ChainId) => request.Descending - ? chains.OrderByDescending(x => x.ChainId).ToList() - : chains.OrderBy(x => x.Version).ToList(), + ? chains.OrderByDescending(x => x.ChainId.Value).ToList() + : chains.OrderBy(x => x.ChainId.Value).ToList(), nameof(SessionChainSummary.CreatedAt) => request.Descending ? chains.OrderByDescending(x => x.CreatedAt).ToList() - : chains.OrderBy(x => x.Version).ToList(), + : chains.OrderBy(x => x.CreatedAt).ToList(), nameof(SessionChainSummary.LastSeenAt) => request.Descending ? chains.OrderByDescending(x => x.LastSeenAt).ToList() @@ -179,6 +179,12 @@ public async Task RevokeUserChainAsync(AccessContext context, User var isCurrent = context.ActorChainId == chainId; var store = _storeFactory.Create(context.ResourceTenant); + var chain = await store.GetChainAsync(chainId, innerCt) + ?? throw new UAuthNotFoundException("chain_not_found"); + + if (chain.UserKey != userKey) + throw new UAuthValidationException("User conflict."); + await store.ExecuteAsync(async innerCt2 => { await store.RevokeChainCascadeAsync(chainId, _clock.UtcNow); }); diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs index 2e35a6cb..698eb13c 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs @@ -9,9 +9,7 @@ public sealed class UAuthSessionQueryService : ISessionQueryService private readonly ISessionStoreFactory _storeFactory; private readonly IAuthFlowContextAccessor _authFlow; - public UAuthSessionQueryService( - ISessionStoreFactory storeFactory, - IAuthFlowContextAccessor authFlow) + public UAuthSessionQueryService(ISessionStoreFactory storeFactory, IAuthFlowContextAccessor authFlow) { _storeFactory = storeFactory; _authFlow = authFlow; @@ -19,22 +17,22 @@ public UAuthSessionQueryService( public Task GetSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) { - return CreateKernel().GetSessionAsync(sessionId); + return CreateKernel().GetSessionAsync(sessionId, ct); } public Task> GetSessionsByChainAsync(SessionChainId chainId, CancellationToken ct = default) { - return CreateKernel().GetSessionsByChainAsync(chainId); + return CreateKernel().GetSessionsByChainAsync(chainId, ct); } public Task> GetChainsByUserAsync(UserKey userKey, CancellationToken ct = default) { - return CreateKernel().GetChainsByUserAsync(userKey); + return CreateKernel().GetChainsByUserAsync(userKey, ct: ct); } public Task ResolveChainIdAsync(AuthSessionId sessionId, CancellationToken ct = default) { - return CreateKernel().GetChainIdBySessionAsync(sessionId); + return CreateKernel().GetChainIdBySessionAsync(sessionId, ct); } private ISessionStore CreateKernel() diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs index 787e5281..40aab932 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs @@ -27,7 +27,7 @@ public UAuthSessionValidator(ISessionStoreFactory storeFactory, IUserClaimsProvi public async Task ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default) { var kernel = _storeFactory.Create(context.Tenant); - var session = await kernel.GetSessionAsync(context.SessionId); + var session = await kernel.GetSessionAsync(context.SessionId, ct); if (session is null) return SessionValidationResult.Invalid(SessionState.NotFound, sessionId: context.SessionId); @@ -36,7 +36,7 @@ public async Task ValidateSessionAsync(SessionValidatio if (state != SessionState.Active) return SessionValidationResult.Invalid(state, session.UserKey, session.SessionId, session.ChainId); - var chain = await kernel.GetChainAsync(session.ChainId); + var chain = await kernel.GetChainAsync(session.ChainId, ct); if (chain is null || chain.IsRevoked) return SessionValidationResult.Invalid(SessionState.Revoked, session.UserKey, session.SessionId, session.ChainId); @@ -53,7 +53,7 @@ public async Task ValidateSessionAsync(SessionValidatio if (chain.Tenant != context.Tenant) return SessionValidationResult.Invalid(SessionState.SecurityMismatch, chain.UserKey, session.SessionId, chain.ChainId); - var root = await kernel.GetRootByUserAsync(session.UserKey); + var root = await kernel.GetRootByUserAsync(session.UserKey, ct); if (root is null || root.IsRevoked) return SessionValidationResult.Invalid(SessionState.Revoked, chain.UserKey, session.SessionId, chain.ChainId, root?.RootId); diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs index d7c52993..98f4ee94 100644 --- a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs @@ -38,6 +38,21 @@ public EfCoreAuthenticationSecurityStateStore(TDbContext db, TenantExecutionCont public async Task AddAsync(AuthenticationSecurityState state, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + + if (state.Tenant != _tenant) + throw new InvalidOperationException("Tenant mismatch."); + + var exists = await DbSet.AnyAsync(x => + x.Tenant == _tenant && + x.UserKey == state.UserKey && + x.Scope == state.Scope && + x.CredentialType == state.CredentialType, + ct); + + if (exists) + throw new UAuthConflictException("security_state_already_exists"); + var entity = AuthenticationSecurityStateMapper.ToProjection(state); DbSet.Add(entity); @@ -47,6 +62,11 @@ public async Task AddAsync(AuthenticationSecurityState state, CancellationToken public async Task UpdateAsync(AuthenticationSecurityState state, long expectedVersion, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + + if (state.Tenant != _tenant) + throw new InvalidOperationException("Tenant mismatch."); + var entity = await DbSet .SingleOrDefaultAsync(x => x.Tenant == _tenant && diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs index 503ed884..219b9a53 100644 --- a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs @@ -70,11 +70,14 @@ public Task UpdateAsync(AuthenticationSecurityState state, long expectedVersion, var key = (state.UserKey, state.Scope, state.CredentialType); - if (!_index.TryGetValue(key, out var id) || id != state.Id) + if (!_index.TryGetValue(key, out var id)) + throw new UAuthNotFoundException("security_state_not_found"); + + if (id != state.Id) throw new UAuthConflictException("security_state_index_corrupted"); if (!_byId.TryGetValue(state.Id, out var current)) - throw new UAuthNotFoundException("security_state_not_found"); + throw new UAuthConflictException("security_state_index_corrupted"); if (current.SecurityVersion != expectedVersion) throw new UAuthConflictException("security_state_version_conflict"); diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs index b6f8d893..9412811b 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs @@ -34,8 +34,7 @@ public async Task AddAsync(Role role, CancellationToken ct = default) var exists = await DbSetRole .AnyAsync(x => x.Tenant == _tenant && - x.NormalizedName == role.NormalizedName && - x.DeletedAt == null, + x.NormalizedName == role.NormalizedName, ct); if (exists) @@ -186,7 +185,8 @@ public async Task> GetByIdsAsync( .AsNoTracking() .Where(x => x.Tenant == _tenant && - roleIds.Contains(x.Id)) + roleIds.Contains(x.Id) && + x.DeletedAt == null) .ToListAsync(ct); var roleIdsSet = entities.Select(x => x.Id).ToList(); @@ -214,6 +214,8 @@ public async Task> GetByIdsAsync( return result.AsReadOnly(); } + + // TODO: Add UltimateAuth standard: tiebreaker for sorting fields that are not unique. public async Task> QueryAsync(RoleQuery query, CancellationToken ct = default) { var normalized = query.Normalize(); diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/AssemblyVisibility.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +ο»Ώusing System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs index 11bde034..5dc2e15e 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs @@ -17,8 +17,7 @@ public InMemoryRoleStore(TenantExecutionContext tenant) : base(tenant) protected override void BeforeAdd(Role entity) { if (TenantValues().Any(r => - r.NormalizedName == entity.NormalizedName && - !r.IsDeleted)) + r.NormalizedName == entity.NormalizedName)) { throw new UAuthConflictException("role_already_exists"); } @@ -30,8 +29,7 @@ protected override void BeforeSave(Role entity, Role current, long expectedVersi { if (TenantValues().Any(r => r.NormalizedName == entity.NormalizedName && - r.Id != entity.Id && - !r.IsDeleted)) + r.Id != entity.Id)) { throw new UAuthConflictException("role_name_already_exists"); } diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/AssemblyVisibility.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +ο»Ώusing System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs index ae13fda7..08ba606f 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs @@ -136,7 +136,7 @@ public async Task RemoveRoleAsync(UserKey userKey, HttpContext ctx) if (!flow.IsAuthenticated) return Results.Unauthorized(); - var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); var accessContext = await _accessContextFactory.CreateAsync( flow, diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs index 11b3d28b..9a3051d1 100644 --- a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs @@ -94,7 +94,32 @@ public async Task> GetRolesAsync(AccessContext context var total = joined.Count; - var pageItems = joined.Skip((request.PageNumber - 1) * request.PageSize).Take(request.PageSize).ToList(); + IEnumerable ordered = request.SortBy switch + { + nameof(UserRoleInfo.Name) => + request.Descending + ? joined + .OrderByDescending(x => x.Name) + .ThenBy(x => x.RoleId.Value) + : joined + .OrderBy(x => x.Name) + .ThenBy(x => x.RoleId.Value), + + nameof(UserRoleInfo.AssignedAt) => + request.Descending + ? joined + .OrderByDescending(x => x.AssignedAt) + .ThenBy(x => x.RoleId.Value) + : joined + .OrderBy(x => x.AssignedAt) + .ThenBy(x => x.RoleId.Value), + + _ => joined + .OrderBy(x => x.Name) + .ThenBy(x => x.RoleId.Value) + }; + + var pageItems = ordered.Skip((request.PageNumber - 1) * request.PageSize).Take(request.PageSize).ToList(); return new PagedResult( pageItems, diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Attributes/UAuthAuthorizeAttribute.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Attributes/UAuthAuthorizeAttribute.cs index 971364ac..ae253156 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Attributes/UAuthAuthorizeAttribute.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Attributes/UAuthAuthorizeAttribute.cs @@ -1,8 +1,37 @@ ο»Ώnamespace CodeBeam.UltimateAuth.Client.Blazor; +/// +/// Declares UltimateAuth authorization requirements for a Blazor component or page. +/// +/// +/// +/// The attribute can be used to associate role and permission requirements with routable or authorization-aware Blazor components. +/// +/// +/// Role and permission values are expressed as comma-separated lists. The effective +/// authorization behavior is determined by the UltimateAuth authorization pipeline that consumes this metadata. +/// +/// +/// This attribute describes authorization requirements only. It does not perform authorization by itself. +/// +/// [AttributeUsage(AttributeTargets.Class)] public sealed class UAuthAuthorizeAttribute : Attribute { + /// + /// Gets or sets the comma-separated roles associated with the authorization requirement. + /// + /// + /// A , empty, or whitespace value indicates that no explicit role requirement is declared by this property. + /// public string? Roles { get; set; } + + /// + /// Gets or sets the comma-separated UltimateAuth permissions associated with the authorization requirement. + /// + /// + /// A , empty, or whitespace value indicates that no explicit + /// permission requirement is declared by this property. + /// public string? Permissions { get; set; } } diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/CodeBeam.UltimateAuth.Client.Blazor.csproj b/src/client/CodeBeam.UltimateAuth.Client.Blazor/CodeBeam.UltimateAuth.Client.Blazor.csproj index 02509da9..73d5c800 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/CodeBeam.UltimateAuth.Client.Blazor.csproj +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/CodeBeam.UltimateAuth.Client.Blazor.csproj @@ -2,7 +2,6 @@ net8.0;net9.0;net10.0 - $(NoWarn);1591 CodeBeam.UltimateAuth.Client.Blazor diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthReactiveComponentBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthComponentBase.cs similarity index 59% rename from src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthReactiveComponentBase.cs rename to src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthComponentBase.cs index 38d8ade6..d2462d35 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthReactiveComponentBase.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthComponentBase.cs @@ -2,28 +2,41 @@ namespace CodeBeam.UltimateAuth.Client.Blazor; -public abstract class UAuthReactiveComponentBase : ComponentBase, IDisposable +/// +/// Base class for Blazor components that participate in UltimateAuth authentication state and authorization lifecycle. +/// +public abstract class UAuthComponentBase : ComponentBase, IDisposable { private UAuthState? _previousState; private bool _rendered; + /// + /// Gets the current UltimateAuth authentication state supplied by UAuthApp. + /// [CascadingParameter] protected UAuthState AuthState { get; set; } = default!; - [Inject] protected NavigationManager Nav { get; set; } = default!; + /// + /// Gets the Blazor navigation service. + /// + [Inject] protected NavigationManager Navigation { get; set; } = default!; /// - /// Automatically re-render when UAuthState changes. - /// Can be overridden to disable. + /// Automatically re-render when UAuthState changes. Can be overridden to disable. /// protected virtual bool AutoRefreshOnAuthStateChanged => true; + /// + /// Called when the component's parameters have been set. This method ensures that the component is properly registered + /// with the current and evaluates authorization requirements. + /// + /// protected override void OnParametersSet() { base.OnParametersSet(); if (AuthState is null) - throw new InvalidOperationException($"{GetType().Name} requires a cascading parameter of type {nameof(AuthState)}. " + + throw new InvalidOperationException($"{GetType().Name} requires a cascading parameter of type {nameof(UAuthState)}. " + $"Make sure it is used inside ."); if (!ReferenceEquals(_previousState, AuthState)) @@ -38,12 +51,20 @@ protected override void OnParametersSet() EvaluateAuthorization(); } + /// + /// Called after the component has been rendered. This method sets the _rendered flag to true on the first render. + /// + /// + /// protected override async Task OnAfterRenderAsync(bool firstRender) { await base.OnAfterRenderAsync(firstRender); if (firstRender) + { _rendered = true; + // Never call EvaluateAuthorization() here, because it breaks UAuthAuthorize attribute behavior. + } } private void OnAuthStateChanged(UAuthStateChangeReason reason) @@ -101,16 +122,25 @@ private void EvaluateAuthorization() } } + /// + /// Called when the component requires authentication but the current user is not authenticated. + /// protected virtual void OnUnauthorized() { - Nav.NavigateTo("/"); + Navigation.NavigateTo("/"); } + /// + /// Called when the current user is authenticated but does not satisfy the authorization requirements of the component. + /// protected virtual void OnForbidden() { - Nav.NavigateTo("/forbidden"); + Navigation.NavigateTo("/forbidden"); } + /// + /// Disposes of the component and unsubscribes from the event to prevent memory leaks. + /// public virtual void Dispose() { if (_previousState is not null) diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthFlowPageBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthFlowPageBase.cs deleted file mode 100644 index c1305221..00000000 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthFlowPageBase.cs +++ /dev/null @@ -1,103 +0,0 @@ -ο»Ώusing CodeBeam.UltimateAuth.Core.Contracts; -using Microsoft.AspNetCore.WebUtilities; -using System.Text; -using System.Text.Json; - -namespace CodeBeam.UltimateAuth.Client.Blazor; - -public abstract class UAuthFlowPageBase : UAuthReactiveComponentBase -{ - protected AuthFlowPayload? UAuthPayload { get; private set; } - protected string? ReturnUrl { get; private set; } - protected bool ShouldFocus { get; private set; } - protected string? Identifier { get; private set; } - - - protected virtual bool ClearQueryAfterParse => true; - - private bool _needsClear; - private string? _lastParsedUri; - private bool _payloadConsumed; - - protected override void OnParametersSet() - { - base.OnParametersSet(); - - var currentUri = Nav.Uri; - - if (string.Equals(_lastParsedUri, currentUri, StringComparison.Ordinal)) - return; - - _lastParsedUri = currentUri; - - _payloadConsumed = false; - - var uri = Nav.ToAbsoluteUri(currentUri); - var query = QueryHelpers.ParseQuery(uri.Query); - - ShouldFocus = query.TryGetValue("focus", out var focus) && focus == "1"; - ReturnUrl = query.TryGetValue("returnUrl", out var ru) ? ru.ToString() : null; - Identifier = query.TryGetValue("identifier", out var id) ? id.ToString() : null; - - UAuthPayload = null; - - if (query.TryGetValue("uauth", out var raw) && !string.IsNullOrWhiteSpace(raw)) - { - try - { - var bytes = WebEncoders.Base64UrlDecode(raw!); - var json = Encoding.UTF8.GetString(bytes); - UAuthPayload = JsonSerializer.Deserialize(json); - } - catch - { - UAuthPayload = null; - } - } - - _needsClear = ClearQueryAfterParse && uri.Query.Length > 1; - } - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - await base.OnAfterRenderAsync(firstRender); - - if (TryConsumePayload(out var payload)) - await OnUAuthPayloadAsync(payload!); - - if (ConsumeFocus()) - await OnFocusRequestedAsync(); - - if (_needsClear) - { - _needsClear = false; - var clean = new Uri(Nav.Uri).GetLeftPart(UriPartial.Path); - Nav.NavigateTo(clean, replace: true); - } - } - - protected bool ConsumeFocus() - { - if (!ShouldFocus) - return false; - - ShouldFocus = false; - return true; - } - - protected bool TryConsumePayload(out AuthFlowPayload? payload) - { - if (_payloadConsumed || UAuthPayload is null) - { - payload = null; - return false; - } - - _payloadConsumed = true; - payload = UAuthPayload; - return true; - } - - protected virtual Task OnUAuthPayloadAsync(AuthFlowPayload payload) => Task.CompletedTask; - protected virtual Task OnFocusRequestedAsync() => Task.CompletedTask; -} \ No newline at end of file diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubLayoutBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubLayoutBase.cs new file mode 100644 index 00000000..086dd486 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubLayoutBase.cs @@ -0,0 +1,117 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Components; + +namespace CodeBeam.UltimateAuth.Client.Blazor; + +/// +/// Base class for Blazor layouts that participate in an UltimateAuth Hub flow. +/// +/// +/// +/// The layout resolves the Hub session identifier from the current navigation URI +/// and exposes the corresponding to derived layouts. +/// +/// +/// Hub state is obtained through . An absent or invalid +/// Hub session identifier results in no current Hub state. +/// +/// +/// This type provides Hub flow state to the UI and does not itself authorize, +/// complete, or otherwise make security decisions for the authentication flow. +/// +/// +public abstract class UAuthHubLayoutBase : LayoutComponentBase +{ + /// + /// Gets the navigation service used to inspect the current URI. + /// + [Inject] protected NavigationManager Navigation { get; set; } = default!; + + /// + /// Gets the Hub flow reader used to retrieve the state of the current Hub session. + /// + [Inject] protected IHubFlowReader HubFlowReader { get; set; } = default!; + + /// + /// Gets the state associated with the current Hub session, when one can be resolved. + /// + /// + /// The value is when the current URI does not contain a Hub + /// session identifier, the identifier is invalid, or no state has been loaded. + /// + protected HubFlowState? HubState { get; private set; } + + /// + /// Gets a value indicating whether the resolved Hub flow exists. + /// + protected bool HasHub => HubState?.Exists == true; + + /// + /// Gets a value indicating whether the resolved Hub flow exists and is active. + /// + protected bool IsHubActive => HasHub && HubState?.IsActive == true; + + /// + /// Gets a value indicating whether the resolved Hub flow has expired. + /// + protected bool IsExpired => HubState?.IsExpired == true; + + /// + /// Gets the error associated with the resolved Hub flow, if any. + /// + protected HubErrorCode? Error => HubState?.Error; + + private string? _lastHubKey; + + /// + protected override async Task OnParametersSetAsync() + { + await base.OnParametersSetAsync(); + + var hubKey = ResolveHubKey(); + + if (string.IsNullOrWhiteSpace(hubKey)) + { + HubState = null; + return; + } + + if (_lastHubKey == hubKey && HubState is not null) + return; + + _lastHubKey = hubKey; + + if (HubSessionId.TryParse(hubKey, out var hubId)) + { + HubState = await HubFlowReader.GetStateAsync(hubId); + } + else + { + HubState = null; + } + } + + /// + /// Resolves the Hub session identifier associated with the current navigation URI. + /// + /// + /// The raw Hub session identifier when present; otherwise, . + /// + /// + /// The default implementation reads from + /// the current query string. Derived layouts may override this method to provide + /// the Hub session identifier from another source. + /// + protected virtual string? ResolveHubKey() + { + var uri = Navigation.ToAbsoluteUri(Navigation.Uri); + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); + + if (query.TryGetValue(UAuthConstants.Query.Hub, out var hubValue)) + return hubValue.ToString(); + + return null; + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubLayoutComponentBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubLayoutComponentBase.cs deleted file mode 100644 index 48b248c3..00000000 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubLayoutComponentBase.cs +++ /dev/null @@ -1,59 +0,0 @@ -ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; -using CodeBeam.UltimateAuth.Core.Defaults; -using CodeBeam.UltimateAuth.Core.Domain; -using Microsoft.AspNetCore.Components; - -namespace CodeBeam.UltimateAuth.Client.Blazor; - -public abstract class UAuthHubLayoutComponentBase : LayoutComponentBase -{ - [Inject] protected NavigationManager Navigation { get; set; } = default!; - [Inject] protected IHubFlowReader HubFlowReader { get; set; } = default!; - - protected HubFlowState? HubState { get; private set; } - - protected bool HasHub => HubState?.Exists == true; - protected bool IsHubAuthorized => HasHub && HubState?.IsActive == true; - protected bool IsExpired => HubState?.IsExpired == true; - protected HubErrorCode? Error => HubState?.Error; - - private string? _lastHubKey; - - protected override async Task OnParametersSetAsync() - { - await base.OnParametersSetAsync(); - - var hubKey = ResolveHubKey(); - - if (string.IsNullOrWhiteSpace(hubKey)) - { - HubState = null; - return; - } - - if (_lastHubKey == hubKey && HubState is not null) - return; - - _lastHubKey = hubKey; - - if (HubSessionId.TryParse(hubKey, out var hubId)) - { - HubState = await HubFlowReader.GetStateAsync(hubId); - } - else - { - HubState = null; - } - } - - protected virtual string? ResolveHubKey() - { - var uri = Navigation.ToAbsoluteUri(Navigation.Uri); - var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); - - if (query.TryGetValue(UAuthConstants.Query.Hub, out var hubValue)) - return hubValue.ToString(); - - return null; - } -} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubPageBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubPageBase.cs index 2fc629d1..f4063ab0 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubPageBase.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubPageBase.cs @@ -5,26 +5,78 @@ namespace CodeBeam.UltimateAuth.Client.Blazor; -public abstract class UAuthHubPageBase : UAuthReactiveComponentBase +/// +/// Base class for Blazor pages that participate in an UltimateAuth Hub flow. +/// +/// +/// +/// The page receives the Hub session identifier from the current query string +/// and exposes the corresponding to derived pages. +/// +/// +/// Hub state is obtained through and is automatically +/// loaded when component parameters are processed. Derived pages can explicitly +/// refresh the state by calling . +/// +/// +/// This type provides Hub flow state to the UI and does not itself authorize, +/// complete, or otherwise make security decisions for the authentication flow. +/// +/// +public abstract class UAuthHubPageBase : UAuthComponentBase { + /// + /// Gets the Hub flow reader used to retrieve the state of the current Hub session. + /// [Inject] protected IHubFlowReader HubFlowReader { get; set; } = default!; - [Inject] protected NavigationManager Nav { get; set; } = default!; + /// + /// Gets or sets the raw Hub session identifier supplied by the current query string. + /// + /// + /// The value is supplied from . + /// It is validated as a before Hub state is read. + /// [Parameter] [SupplyParameterFromQuery(Name = UAuthConstants.Query.Hub)] public string? HubKey { get; set; } + /// + /// Gets the state associated with the current Hub session, when one can be resolved. + /// + /// + /// The value is when no Hub session identifier is supplied + /// or when the supplied identifier is invalid. + /// protected HubFlowState? HubState { get; private set; } - protected bool IsHubAuthorized => HubState is { Exists: true, IsActive: true }; + /// + /// Gets a value indicating whether the resolved Hub flow exists and is active. + /// + protected bool IsHubActive => HubState is { Exists: true, IsActive: true }; + /// protected override async Task OnParametersSetAsync() { await base.OnParametersSetAsync(); - await ReloadState(); + await ReloadStateAsync(); } - public async Task ReloadState() + /// + /// Reloads the Hub flow state associated with the current . + /// + /// + /// + /// When is missing, empty, or invalid, is cleared. + /// + /// + /// A valid Hub session identifier is resolved through . + /// + /// + /// + /// A task that represents the asynchronous reload operation. + /// + public async Task ReloadStateAsync() { if (string.IsNullOrWhiteSpace(HubKey)) { diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthPageBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthPageBase.cs new file mode 100644 index 00000000..a4af1c65 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthPageBase.cs @@ -0,0 +1,189 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using Microsoft.AspNetCore.WebUtilities; +using System.Text; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Client.Blazor; + +/// +/// Base class for UltimateAuth Blazor pages that participate in authentication flows and consume UltimateAuth flow parameters +/// from the current URL. +/// +public abstract class UAuthPageBase : UAuthComponentBase +{ + /// + /// Gets the UltimateAuth flow payload parsed from the current URL, when one is available and valid. + /// + protected AuthFlowPayload? UAuthPayload { get; private set; } + + /// + /// Gets the return URL supplied for the current authentication flow. + /// + /// + /// The value represents input from the current URL and should not be treated as a trusted navigation target without validation. + /// + protected string? ReturnUrl { get; private set; } + + /// + /// Gets whether focus was requested for the current page. + /// + protected bool ShouldFocus { get; private set; } + + /// + /// Gets the identifier supplied for the current authentication flow. + /// + protected string? Identifier { get; private set; } + + /// + /// Gets whether authentication flow query parameters should be removed from the browser URL after they have been parsed. + /// Default is true. + /// + protected virtual bool ClearUAuthQueryAfterParse => true; + + private bool _needsClear; + private string? _lastParsedUri; + private bool _payloadConsumed; + + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + + var currentUri = Navigation.Uri; + + if (string.Equals(_lastParsedUri, currentUri, StringComparison.Ordinal)) + return; + + _lastParsedUri = currentUri; + + _payloadConsumed = false; + + var uri = Navigation.ToAbsoluteUri(currentUri); + var query = QueryHelpers.ParseQuery(uri.Query); + + ShouldFocus = query.TryGetValue(UAuthConstants.Query.Focus, out var focus) && focus == "1"; + ReturnUrl = query.TryGetValue(UAuthConstants.Query.ReturnUrl, out var ru) ? ru.ToString() : null; + Identifier = query.TryGetValue(UAuthConstants.Query.Identifier, out var id) ? id.ToString() : null; + + UAuthPayload = null; + + if (query.TryGetValue(UAuthConstants.Query.Payload, out var raw) && !string.IsNullOrWhiteSpace(raw)) + { + try + { + var bytes = WebEncoders.Base64UrlDecode(raw!); + var json = Encoding.UTF8.GetString(bytes); + UAuthPayload = JsonSerializer.Deserialize(json); + } + catch + { + UAuthPayload = null; + } + } + + _needsClear = ClearUAuthQueryAfterParse && HasUAuthPageQuery(query); + } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (TryConsumePayload(out var payload)) + await OnUAuthPayloadAsync(payload!); + + if (ConsumeFocus()) + await OnFocusRequestedAsync(); + + if (_needsClear) + { + _needsClear = false; + var cleanUri = BuildUriWithoutConsumedUAuthQuery(); + + if (!string.Equals(cleanUri, Navigation.Uri, StringComparison.Ordinal)) + Navigation.NavigateTo(cleanUri, replace: true); + } + } + + /// + /// Consumes a pending focus request. + /// + /// + /// when a focus request was pending, otherwise . + /// + protected bool ConsumeFocus() + { + if (!ShouldFocus) + return false; + + ShouldFocus = false; + return true; + } + + /// + /// Attempts to consume the current authentication flow payload. A payload can be consumed only once for a parsed URL. + /// + protected bool TryConsumePayload(out AuthFlowPayload? payload) + { + if (_payloadConsumed || UAuthPayload is null) + { + payload = null; + return false; + } + + _payloadConsumed = true; + payload = UAuthPayload; + return true; + } + + /// + /// Called when a new UltimateAuth flow payload is available for the current page. + /// This method is called only once per parsed URL, and only when a valid payload is present. + /// + /// + /// + protected virtual Task OnUAuthPayloadAsync(AuthFlowPayload payload) => Task.CompletedTask; + + /// + /// Called when a focus request is present for the current page. + /// This method is called only once per parsed URL, and only when a focus request is present. + /// + /// + protected virtual Task OnFocusRequestedAsync() => Task.CompletedTask; + + private string BuildUriWithoutConsumedUAuthQuery() + { + var uri = Navigation.ToAbsoluteUri(Navigation.Uri); + var query = QueryHelpers.ParseQuery(uri.Query); + + var remaining = query + .Where(x => !IsConsumedUAuthQueryParameter(x.Key)) + .SelectMany( + x => x.Value, + (x, value) => new KeyValuePair( + x.Key, + value)); + + return QueryHelpers.AddQueryString( + uri.GetLeftPart(UriPartial.Path), + remaining); + } + + private static bool IsConsumedUAuthQueryParameter(string key) + { + return key is + UAuthConstants.Query.Payload or + UAuthConstants.Query.Focus or + UAuthConstants.Query.ReturnUrl or + UAuthConstants.Query.Identifier; + } + + private static bool HasUAuthPageQuery(IDictionary query) + { + return query.ContainsKey(UAuthConstants.Query.Payload) + || query.ContainsKey(UAuthConstants.Query.Focus) + || query.ContainsKey(UAuthConstants.Query.ReturnUrl) + || query.ContainsKey(UAuthConstants.Query.Identifier); + } +} \ No newline at end of file diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor.cs index ef31c4b2..46ec4b79 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor.cs @@ -3,46 +3,121 @@ namespace CodeBeam.UltimateAuth.Client.Blazor; +/// +/// Provides the root Blazor integration component for UltimateAuth. +/// +/// +/// +/// initializes the UltimateAuth client runtime, +/// exposes the current as a cascading value, +/// coordinates the authenticated session lifecycle, and optionally provides +/// the application's Blazor router. +/// +/// +/// Applications using UltimateAuth Blazor components should normally place +/// their application content within this component. +/// +/// +/// Client-side authentication state is intended for UI behavior only and +/// does not constitute a security boundary. Authorization of protected +/// resources must always be enforced by the server. +/// +/// public partial class UAuthApp { private bool _initialized; private bool _coordinatorStarted; + /// + /// Gets or sets the application content rendered within the UltimateAuth context. + /// [Parameter] public RenderFragment? ChildContent { get; set; } + /// + /// Gets or sets the content rendered by the built-in router when the current user is not authorized to access a route. + /// + /// + /// This parameter is used only when is enabled. + /// [Parameter] public RenderFragment? NotAuthorized { get; set; } + /// + /// Gets or sets whether should provide the application's Blazor router. + /// + /// + /// Set this to when the application provides its own router. + /// [Parameter] public bool UseBuiltInRouter { get; set; } + /// + /// Gets or sets whether UltimateAuth client routes are included in the assemblies searched by the built-in router. + /// + /// + /// The default value is . + /// [Parameter] public bool UseUAuthClientRoutes { get; set; } = true; + /// + /// Gets or sets the assembly containing the application's routable components. + /// + /// + /// This value is used by the built-in router. + /// [Parameter] public Assembly? AppAssembly { get; set; } + /// + /// Gets or sets additional assemblies that should be searched for routable components. + /// + /// + /// When is enabled, the UltimateAuth + /// Blazor client assemblies are added to this set automatically. + /// [Parameter] public IEnumerable? AdditionalAssemblies { get; set; } + /// + /// Gets or sets the default layout used by the built-in + /// . + /// [Parameter] public Type? DefaultLayout { get; set; } + /// + /// Gets or sets the CSS selector used by Blazor's focus-on-navigation behavior. + /// + /// + /// The default value is h1. + /// [Parameter] public string? FocusSelector { get; set; } = "h1"; + /// + /// Gets or sets how UltimateAuth state changes affect component rendering. + /// + /// + /// The default value is . + /// [Parameter] public UAuthRenderMode RenderMode { get; set; } = UAuthRenderMode.Manual; + /// + /// Gets or sets the callback invoked when the session coordinator determines that reauthentication is required. + /// [Parameter] public EventCallback OnReauthRequired { get; set; } + /// protected override async Task OnInitializedAsync() { Coordinator.ReauthRequired += HandleReauthRequired; } + /// protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) @@ -81,6 +156,7 @@ private void OnStateChanged(UAuthStateChangeReason reason) _ = InvokeAsync(async () => { await Coordinator.StartAsync(); + _coordinatorStarted = true; }); } @@ -116,6 +192,10 @@ private IEnumerable GetAdditionalAssemblies() return Enumerable.Empty(); } + /// + /// Disposes the component and stops the session coordinator if it was started. + /// + /// public async ValueTask DisposeAsync() { StateManager.State.Changed -= OnStateChanged; diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor index 93eb2736..6a57f226 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor @@ -27,7 +27,7 @@ @if (SubmitMode == UAuthSubmitMode.DirectCommit) { - + } @ChildContent diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor.cs index 8696afd6..7ff71a56 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor.cs @@ -10,6 +10,9 @@ namespace CodeBeam.UltimateAuth.Client.Blazor; +/// +/// Represents a Blazor component that provides a login form for UltimateAuth authentication. +/// public partial class UAuthLoginForm { [Inject] @@ -98,6 +101,7 @@ public partial class UAuthLoginForm private HubFlowState? _flow; private DeviceId? _deviceId; + /// protected override async Task OnParametersSetAsync() { await base.OnParametersSetAsync(); @@ -113,6 +117,7 @@ protected override async Task OnParametersSetAsync() } } + /// protected override async Task OnAfterRenderAsync(bool firstRender) { if (!firstRender) @@ -122,6 +127,10 @@ protected override async Task OnAfterRenderAsync(bool firstRender) StateHasChanged(); } + /// + /// Asynchronously reloads the credentials associated with the current hub session, if applicable. + /// + /// protected async Task ReloadCredentialsAsync() { if (LoginType != UAuthLoginType.Pkce) @@ -133,6 +142,10 @@ protected async Task ReloadCredentialsAsync() _credentials = await HubCredentialResolver.ResolveAsync(EffectiveHubSessionId.Value); } + /// + /// Asynchronously reloads the state associated with the current hub session, if applicable. + /// + /// protected async Task ReloadStateAsync() { if (LoginType != UAuthLoginType.Pkce || EffectiveHubSessionId is null || HubFlowReader is null) @@ -286,7 +299,9 @@ private string ResolvedEndpoint if (_credentials != null && EffectiveHubSessionId is not null) { - query.Add($"hub={EffectiveHubSessionId}"); + query.Add( + $"{UAuthConstants.Query.Hub}=" + + $"{Uri.EscapeDataString(EffectiveHubSessionId.Value.Value)}"); } if (!string.IsNullOrWhiteSpace(returnUrl)) diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor index 49e15f69..1bcc37df 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor @@ -1,4 +1,4 @@ ο»Ώ@namespace CodeBeam.UltimateAuth.Client.Blazor -@inherits UAuthReactiveComponentBase +@inherits UAuthComponentBase @ChildContent diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor.cs index c3c4e2dd..9240307a 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor.cs @@ -2,8 +2,15 @@ namespace CodeBeam.UltimateAuth.Client.Blazor; -public partial class UAuthScope : UAuthReactiveComponentBase +/// +/// A Blazor component that defines a scope for UltimateAuth authentication and authorization. +/// It can be used to group child components that require specific authentication or authorization context. +/// +public partial class UAuthScope : UAuthComponentBase { + /// + /// Gets or sets the child content to be rendered within this scope. + /// [Parameter] public RenderFragment? ChildContent { get; set; } } diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor index fca197bb..1f9227b4 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor @@ -1,9 +1,7 @@ ο»Ώ@namespace CodeBeam.UltimateAuth.Client.Blazor -@inherits UAuthReactiveComponentBase -@using CodeBeam.UltimateAuth.Core.Domain +@inherits UAuthComponentBase @using Microsoft.AspNetCore.Authorization -@using Microsoft.AspNetCore.Components.Authorization @inject IAuthorizationService AuthorizationService @if (_inactive) diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs index e488b329..bdce111b 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs @@ -1,10 +1,14 @@ -ο»Ώusing CodeBeam.UltimateAuth.Core.Domain; +ο»Ώusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components; namespace CodeBeam.UltimateAuth.Client.Blazor; -public partial class UAuthStateView : UAuthReactiveComponentBase +/// +/// A Blazor component that conditionally renders content based on the current UltimateAuth authentication state and authorization requirements. +/// +public partial class UAuthStateView : UAuthComponentBase { private IReadOnlyList _rolesParsed = Array.Empty(); private IReadOnlyList _permissionsParsed = Array.Empty(); @@ -15,40 +19,87 @@ public partial class UAuthStateView : UAuthReactiveComponentBase private string? _rolesRaw; private string? _permissionsRaw; + /// + /// Gets or sets the content to render when the user is authorized. The content receives the current as a parameter. + /// [Parameter] public RenderFragment? Authorized { get; set; } + /// + /// Gets or sets the content to render when the user is not authorized. This content is displayed when the user does not meet the specified authorization requirements. + /// [Parameter] public RenderFragment? NotAuthorized { get; set; } + /// + /// Gets or sets the content to render when the user is inactive. This content is displayed when the user's session state is not active, and the parameter is set to true. + /// [Parameter] public RenderFragment? Inactive { get; set; } + /// + /// Gets or sets the content to render while the authorization evaluation is in progress. This content is displayed when the component is determining whether the user meets the specified authorization requirements. + /// [Parameter] public RenderFragment? Authorizing { get; set; } + /// + /// Gets or sets the content to render regardless of the user's authorization state. This content is always displayed, and it receives the current as a parameter. + /// [Parameter] public RenderFragment? ChildContent { get; set; } + /// + /// Gets or sets a comma-separated list of roles that the user must have to be considered authorized. The roles are evaluated based on the specified . + /// [Parameter] public string? Roles { get; set; } + /// + /// Gets or sets a comma-separated list of permissions that the user must have to be considered authorized. The permissions are evaluated based on the specified . + /// [Parameter] public string? Permissions { get; set; } + /// + /// Gets or sets the name of a policy that the user must satisfy to be considered authorized. The policy is evaluated based on the specified . + /// [Parameter] public string? Policy { get; set; } /// - /// Gets or sets a value indicating whether all set conditions must be matched for the operation to succeed. - /// Null parameters don't count as condition. + /// Determines how authorization conditions are evaluated. + /// + /// + /// : + /// Any configured condition may succeed. + /// + /// + /// + /// : + /// All configured conditions and values must succeed. + /// + /// + /// + /// : + /// At least one value from each configured category must succeed. + /// For example: + /// one matching role AND one matching permission. + /// + /// + /// Null or empty parameters are ignored. /// [Parameter] - public bool MatchAll { get; set; } = true; + public AuthorizationMatchMode MatchMode { get; set; } = AuthorizationMatchMode.Category; + /// + /// Gets or sets a value indicating whether the user's session state must be active for the user to be considered authorized. + /// If set to true, the component will evaluate the user's session state and render the content if the session is not active. + /// [Parameter] public bool RequireActive { get; set; } = true; + /// protected override async Task OnParametersSetAsync() { await base.OnParametersSetAsync(); @@ -78,6 +129,11 @@ protected override async Task OnParametersSetAsync() _authorizing = false; } + /// + /// Handles changes in the authentication state. + /// This method is called when the authentication state changes, and it evaluates the current session state and authorization requirements. + /// + /// protected override async void HandleAuthStateChanged(UAuthStateChangeReason reason) { EvaluateSessionState(); @@ -92,34 +148,76 @@ private async Task EvaluateAuthorizationAsync() if (!AuthState.IsAuthenticated) return false; - var roles = _rolesParsed; - var permissions = _permissionsParsed; + var hasRoles = _rolesParsed.Count > 0; + var hasPermissions = _permissionsParsed.Count > 0; + var hasPolicy = !string.IsNullOrWhiteSpace(Policy); + + // No explicit authorization requirements: + // authentication itself is sufficient. + if (!hasRoles && !hasPermissions && !hasPolicy) + return true; + + var roleResults = _rolesParsed + .Select(AuthState.IsInRole) + .ToList(); + + var permissionResults = _permissionsParsed + .Select(AuthState.HasPermission) + .ToList(); - var results = new List(); + bool? policyResult = null; - if (roles.Count > 0) + if (!string.IsNullOrWhiteSpace(Policy)) { - results.Add(MatchAll - ? roles.All(AuthState.IsInRole) - : roles.Any(AuthState.IsInRole)); + policyResult = await EvaluatePolicyAsync(); } - if (permissions.Count > 0) + return MatchMode switch { - results.Add(MatchAll - ? permissions.All(AuthState.HasPermission) - : permissions.Any(AuthState.HasPermission)); - } + AuthorizationMatchMode.Any + => EvaluateAny(roleResults, permissionResults, policyResult), - if (!string.IsNullOrWhiteSpace(Policy)) - results.Add(await EvaluatePolicyAsync()); + AuthorizationMatchMode.All + => EvaluateAll(roleResults, permissionResults, policyResult), - if (results.Count == 0) - return true; + AuthorizationMatchMode.Category + => EvaluateCategory(roleResults, permissionResults, policyResult), + + _ => false + }; + } + + private static bool EvaluateAny(IReadOnlyList roles, IReadOnlyList permissions, bool? policy) + { + return roles.Any(x => x) || permissions.Any(x => x) || policy == true; + } + + private static bool EvaluateAll(IReadOnlyList roles, IReadOnlyList permissions, bool? policy) + { + if (roles.Count > 0 && roles.Any(x => !x)) + return false; + + if (permissions.Count > 0 && permissions.Any(x => !x)) + return false; + + if (policy.HasValue && !policy.Value) + return false; + + return true; + } + + private static bool EvaluateCategory(IReadOnlyList roles, IReadOnlyList permissions, bool? policy) + { + if (roles.Count > 0 && !roles.Any(x => x)) + return false; + + if (permissions.Count > 0 && !permissions.Any(x => x)) + return false; + + if (policy.HasValue && !policy.Value) + return false; - return MatchAll - ? results.All(x => x) - : results.Any(x => x); + return true; } private void EvaluateSessionState() @@ -171,6 +269,6 @@ private async Task EvaluatePolicyAsync() private string BuildAuthKey() { - return $"{Roles}|{Permissions}|{Policy}|{MatchAll}"; + return $"{Roles}|{Permissions}|{Policy}|{MatchMode}{RequireActive}"; } } diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Device/BrowserDeviceIdStorage.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Device/BrowserDeviceIdStorage.cs index b64b221b..1ef3de09 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Device/BrowserDeviceIdStorage.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Device/BrowserDeviceIdStorage.cs @@ -4,16 +4,25 @@ namespace CodeBeam.UltimateAuth.Client.Blazor.Device; +/// +/// Represents a device ID storage implementation that uses browser client storage to persist the device ID. +/// public sealed class BrowserDeviceIdStorage : IDeviceIdStorage { private const string Key = "udid"; private readonly IClientStorage _storage; + /// public BrowserDeviceIdStorage(IClientStorage storage) { _storage = storage; } + /// + /// Loads the device ID from the browser client storage. + /// + /// + /// public async ValueTask LoadAsync(CancellationToken ct = default) { try @@ -29,6 +38,12 @@ public BrowserDeviceIdStorage(IClientStorage storage) } } + /// + /// Saves the device ID to the browser client storage. + /// + /// + /// + /// public ValueTask SaveAsync(string deviceId, CancellationToken ct = default) { return _storage.SetAsync(StorageScope.Local, Key, deviceId); diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/AssemblyExtensions.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/AssemblyExtensions.cs index 3c0cb7aa..2411bfc2 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/AssemblyExtensions.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/AssemblyExtensions.cs @@ -2,8 +2,17 @@ namespace CodeBeam.UltimateAuth.Client.Blazor; +/// +/// Provides extension methods for working with assemblies in the context of UltimateAuth Blazor client applications. +/// public static class UAuthAssemblies { + /// + /// Appends the assembly containing the UAuthBlazorClientMarker class to the provided collection of assemblies, + /// ensuring that it is included for UltimateAuth Blazor client applications. + /// + /// + /// public static Assembly[] WithUltimateAuth(this IEnumerable? assemblies) { var authAssembly = typeof(UAuthBlazorClientMarker).Assembly; @@ -14,6 +23,11 @@ public static Assembly[] WithUltimateAuth(this IEnumerable? assemblies return assemblies.Append(authAssembly).DistinctBy(a => a.FullName).ToArray(); } + /// + /// Returns an array containing the assembly of the UAuthBlazorClientMarker class, + /// which is used to identify the UltimateAuth Blazor client application assembly. + /// + /// public static Assembly[] BlazorClient() { return new[] { typeof(UAuthBlazorClientMarker).Assembly }; diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserClientStorage.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserClientStorage.cs index 017c43a5..46436d41 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserClientStorage.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserClientStorage.cs @@ -4,27 +4,64 @@ namespace CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; +/// +/// Represents a client storage implementation that uses the browser's localStorage and sessionStorage via JavaScript interop. +/// public sealed class BrowserClientStorage : IClientStorage { private readonly IJSRuntime _js; + /// + /// Initializes a new instance of the class with the specified JavaScript runtime. + /// + /// public BrowserClientStorage(IJSRuntime js) { _js = js; } + /// + /// Sets a value in the specified storage scope (localStorage or sessionStorage) with the given key. + /// + /// + /// + /// + /// public ValueTask SetAsync(StorageScope scope, string key, string value) => _js.InvokeVoidAsync("uauth.storage.set", Scope(scope), key, value); + /// + /// Gets a value from the specified storage scope (localStorage or sessionStorage) with the given key. + /// + /// + /// + /// public ValueTask GetAsync(StorageScope scope, string key) => _js.InvokeAsync("uauth.storage.get", Scope(scope), key); + /// + /// Removes a value from the specified storage scope (localStorage or sessionStorage) with the given key. + /// + /// + /// + /// public ValueTask RemoveAsync(StorageScope scope, string key) => _js.InvokeVoidAsync("uauth.storage.remove", Scope(scope), key); - + + /// + /// Checks if a value exists in the specified storage scope (localStorage or sessionStorage) with the given key. + /// + /// + /// + /// public async ValueTask ExistsAsync(StorageScope scope, string key) => await _js.InvokeAsync("uauth.storage.exists", Scope(scope), key); + /// + /// Gets the string representation of the storage scope for use in JavaScript interop. + /// + /// + /// private static string Scope(StorageScope scope) => scope == StorageScope.Local ? "local" : "session"; } diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs index 7e1d5999..a4b93b3e 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs @@ -2,10 +2,20 @@ namespace CodeBeam.UltimateAuth.Client.Infrastructure; +/// +/// Discovers the login page route by scanning for a component decorated with the [UAuthLoginPage] attribute. If no such component is found, it defaults to "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/login". If multiple components are found, an exception is thrown. +/// The resolved route is cached for subsequent calls. +/// public static class UAuthLoginPageDiscovery { private static string? _cached; + /// + /// Resolves the login page route by scanning for a component decorated with the [UAuthLoginPage] attribute. If no such component is found, it defaults to "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/login". + /// If multiple components are found, an exception is thrown. + /// + /// + /// public static string Resolve() { if (_cached != null) diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs index 63662572..38c7fe96 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs @@ -91,6 +91,8 @@ public async Task SendJsonAsync(string endpoint, object? p public async Task TryAndCommitAsync(string tryEndpoint, string commitEndpoint, object request, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + await _bootstrapper.EnsureStartedAsync(); var response = await _js.InvokeAsync( diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Runtime/UAuthBlazorClientMarker.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Runtime/UAuthBlazorClientMarker.cs index d7174ce1..affb92b8 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Runtime/UAuthBlazorClientMarker.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Runtime/UAuthBlazorClientMarker.cs @@ -1,5 +1,9 @@ ο»Ώnamespace CodeBeam.UltimateAuth.Client.Blazor; +/// +/// Marker class for the UltimateAuth Blazor client library. +/// This class is used to identify the assembly and provide a reference point for dependency injection and other framework features. +/// public class UAuthBlazorClientMarker { } diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js index 28aff8b9..61a3dfaf 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js @@ -1 +1 @@ -ο»Ώwindow.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(n){if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");let t=n.querySelector("input[name='__uauth_device']");t||(t=document.createElement("input"),t.type="hidden",t.name="__uauth_device",n.appendChild(t));t.value=window.uauth.deviceId;n.submit()}};window.uauth.tryAndCommit=async function(n){const{tryUrl:f,commitUrl:e,data:i,clientProfile:r}=n,o=await window.uauth.postJson({url:f,payload:i,clientProfile:r});let t=o?.body;t||(t={});const u={success:t.success??!1,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1};if(u.success){const n=document.createElement("form");n.method="POST";n.action=e;for(const t in i){const r=document.createElement("input");r.type="hidden";r.name=t;r.value=i[t]??"";n.appendChild(r)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=r??"";n.appendChild(t);const u=document.createElement("input");u.type="hidden";u.name="__uauth_device";u.value=window.uauth.deviceId;n.appendChild(u);document.body.appendChild(n);n.submit()}return u};window.uauth.post=async function(n){const{url:f,mode:s,data:t,clientProfile:e}=n;if(s==="navigate"){const n=document.createElement("form");n.method="POST";n.action=f;const i=document.createElement("input");i.type="hidden";i.name="__uauth_client_profile";i.value=e??"";n.appendChild(i);const r=document.createElement("input");if(r.type="hidden",r.name="__uauth_device",r.value=window.uauth.deviceId,n.appendChild(r),t)for(const i in t){const r=document.createElement("input");r.type="hidden";r.name=i;r.value=t[i];n.appendChild(r)}return document.body.appendChild(n),n.submit(),null}let r=null;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const o={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":e,"X-Requested-With":"UAuth"};if(t){r=new URLSearchParams;for(const n in t)r.append(n,t[n]);o["Content-Type"]="application/x-www-form-urlencoded"}const i=await fetch(f,{method:"POST",credentials:"include",headers:o,body:r});let u=null;try{u=await i.json()}catch{u=null}return{ok:i.ok,status:i.status,refreshOutcome:i.headers.get("X-UAuth-Refresh"),body:u}};window.uauth.postJson=async function(n){const{url:u,payload:r,clientProfile:f}=n;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const e={"Content-Type":"application/json","X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":f??"","X-Requested-With":"UAuth"},t=await fetch(u,{method:"POST",credentials:"include",headers:e,body:r?JSON.stringify(r):null});let i=null;try{i=await t.json()}catch{i=null}return{ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:i}};window.uauth.setDeviceId=function(n){window.uauth.deviceId=n};window.uauth.getDeviceInfo=function(){return{userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language}} \ No newline at end of file +ο»Ώwindow.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=function(n){if(!n)return;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");let t=n.querySelector("input[name='__uauth_device']");t||(t=document.createElement("input"),t.type="hidden",t.name="__uauth_device",n.appendChild(t));t.value=window.uauth.deviceId;n.submit()};window.uauth.tryAndCommit=async function(n){const{tryUrl:f,commitUrl:e,data:i,clientProfile:r}=n,o=await window.uauth.postJson({url:f,payload:i,clientProfile:r});let t=o?.body;t||(t={});const u={success:t.success??!1,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1};if(u.success){const n=document.createElement("form");n.method="POST";n.action=e;for(const t in i){const r=document.createElement("input");r.type="hidden";r.name=t;r.value=i[t]??"";n.appendChild(r)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=r??"";n.appendChild(t);const u=document.createElement("input");u.type="hidden";u.name="__uauth_device";u.value=window.uauth.deviceId;n.appendChild(u);document.body.appendChild(n);n.submit()}return u};window.uauth.post=async function(n){const{url:f,mode:s,data:t,clientProfile:e}=n;if(s==="navigate"){const n=document.createElement("form");n.method="POST";n.action=f;const i=document.createElement("input");i.type="hidden";i.name="__uauth_client_profile";i.value=e??"";n.appendChild(i);const r=document.createElement("input");if(r.type="hidden",r.name="__uauth_device",r.value=window.uauth.deviceId,n.appendChild(r),t)for(const i in t){const r=document.createElement("input");r.type="hidden";r.name=i;r.value=t[i];n.appendChild(r)}return document.body.appendChild(n),n.submit(),null}let r=null;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const o={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":e,"X-Requested-With":"UAuth"};if(t){r=new URLSearchParams;for(const n in t)r.append(n,t[n]);o["Content-Type"]="application/x-www-form-urlencoded"}const i=await fetch(f,{method:"POST",credentials:"include",headers:o,body:r});let u=null;try{u=await i.json()}catch{u=null}return{ok:i.ok,status:i.status,refreshOutcome:i.headers.get("X-UAuth-Refresh"),body:u}};window.uauth.postJson=async function(n){const{url:u,payload:r,clientProfile:f}=n;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const e={"Content-Type":"application/json","X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":f??"","X-Requested-With":"UAuth"},t=await fetch(u,{method:"POST",credentials:"include",headers:e,body:r?JSON.stringify(r):null});let i=null;try{i=await t.json()}catch{i=null}return{ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:i}};window.uauth.setDeviceId=function(n){window.uauth.deviceId=n};window.uauth.getDeviceInfo=function(){return{userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language}} \ No newline at end of file diff --git a/src/client/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj b/src/client/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj index f6abad23..cdd30716 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj +++ b/src/client/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientDeviceProvider.cs b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientDeviceProvider.cs index d307fbdc..a39a6075 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientDeviceProvider.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientDeviceProvider.cs @@ -2,7 +2,14 @@ namespace CodeBeam.UltimateAuth.Client.Abstractions; +/// +/// Provides a mechanism to retrieve the device context for the client application. +/// public interface IClientDeviceProvider { + /// + /// Retrieves the device context for the client application asynchronously. + /// + /// Task GetAsync(); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientStorage.cs b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientStorage.cs index 9f605d26..2cbb2793 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientStorage.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientStorage.cs @@ -2,10 +2,28 @@ namespace CodeBeam.UltimateAuth.Client.Infrastructure; +/// +/// Represents a storage mechanism for client-side data, allowing for setting, retrieving, removing, and checking the existence of key-value pairs within specified storage scopes. +/// public interface IClientStorage { + /// + /// Sets a value in the specified storage scope with the given key. + /// ValueTask SetAsync(StorageScope scope, string key, string value); + + /// + /// Retrieves a value from the specified storage scope using the given key. Returns null if the key does not exist. + /// ValueTask GetAsync(StorageScope scope, string key); + + /// + /// Removes a value from the specified storage scope using the given key. + /// ValueTask RemoveAsync(StorageScope scope, string key); + + /// + /// Checks if a value exists in the specified storage scope for the given key. + /// ValueTask ExistsAsync(StorageScope scope, string key); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IReturnUrlProvider.cs b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IReturnUrlProvider.cs index f5eddd8e..0567ef25 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IReturnUrlProvider.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IReturnUrlProvider.cs @@ -1,6 +1,12 @@ ο»Ώnamespace CodeBeam.UltimateAuth.Client.Abstractions; +/// +/// Represents a provider that can retrieve the current return URL, typically used in authentication flows to redirect users back to their original destination after login or other actions. +/// public interface IReturnUrlProvider { + /// + /// Gets the current return URL, which is the URL to which the user should be redirected after completing an authentication flow or other relevant action. + /// string GetCurrentUrl(); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs index ce1781aa..cec583af 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs @@ -1,5 +1,9 @@ ο»Ώnamespace CodeBeam.UltimateAuth.Client.Abstractions; +/// +/// Represents a coordinator for managing user sessions, providing methods to start and stop session coordination, +/// and an event to notify when reauthentication is required. +/// public interface ISessionCoordinator : IAsyncDisposable { /// @@ -13,5 +17,8 @@ public interface ISessionCoordinator : IAsyncDisposable /// Task StopAsync(); + /// + /// Event triggered when reauthentication is required. + /// event Action? ReauthRequired; } diff --git a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs index cbc32fbe..ae41b93d 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs @@ -10,17 +10,28 @@ namespace CodeBeam.UltimateAuth.Client; /// /// Represents the client-side authentication snapshot for UltimateAuth. -/// +/// /// This is a lightweight, memory-only view of the current authentication state. /// It is not a security boundary and must always be validated server-side. +/// /// public sealed class UAuthState { private UAuthState() { } + /// + /// Gets the current authenticated identity snapshot, or null if the user is not authenticated. + /// public AuthIdentitySnapshot? Identity { get; private set; } + + /// + /// Gets the current claims snapshot for the authenticated user, or an empty snapshot if the user is not authenticated. + /// public ClaimsSnapshot Claims { get; private set; } = ClaimsSnapshot.Empty; + /// + /// Gets the timestamp of the last successful validation of the authentication state, or null if it has never been validated. + /// public DateTimeOffset? LastValidatedAt { get; private set; } /// @@ -28,13 +39,25 @@ private UAuthState() { } /// public bool IsStale { get; private set; } - + /// + /// Occurs when the authentication state has changed, such as after login, logout, or profile updates. + /// public event Action? Changed; internal Action? RequestRender; + /// + /// Gets a value indicating whether the user is currently authenticated (i.e., has a valid identity). + /// public bool IsAuthenticated => Identity is not null; + + /// + /// Gets a value indicating whether the authentication state needs to be validated (i.e., the user is authenticated but the snapshot is stale). + /// public bool NeedsValidation => IsAuthenticated && IsStale; + /// + /// Creates a new anonymous (unauthenticated) instance of . + /// public static UAuthState Anonymous() => new(); internal void ApplySnapshot(AuthStateSnapshot snapshot, DateTimeOffset validatedAt) @@ -96,6 +119,9 @@ internal void MarkStale() Changed?.Invoke(UAuthStateChangeReason.MarkedStale); } + /// + /// Marks the authentication state as stale and requests a re-render of the UI. + /// public void Touch(bool updateState = true) { if (updateState) @@ -116,9 +142,20 @@ internal void Clear() Changed?.Invoke(UAuthStateChangeReason.Cleared); } + /// + /// Determines whether the current authenticated user is in the specified role. + /// + /// + /// public bool IsInRole(string role) => IsAuthenticated && Claims.IsInRole(role); private CompiledPermissionSet? _compiledPermissions; + + /// + /// Determines whether the current authenticated user has the specified permission. + /// + /// + /// public bool HasPermission(string permission) { if (!IsAuthenticated) @@ -130,6 +167,11 @@ public bool HasPermission(string permission) return _compiledPermissions?.IsAllowed(permission) == true; } + /// + /// Determines whether the current authenticated user has any of the specified permissions. + /// + /// + /// public bool HasAnyPermission(params string[] permissions) { foreach (var perm in permissions) @@ -141,8 +183,16 @@ public bool HasAnyPermission(params string[] permissions) return false; } + /// + /// Determines whether the current authenticated user has the specified claim type and value. + /// public bool HasClaim(string type, string value) => IsAuthenticated && Claims.HasValue(type, value); + /// + /// Gets the value of the specified claim type for the current authenticated user, or null if the claim does not exist or the user is not authenticated. + /// + /// + /// public string? GetClaim(string type) => IsAuthenticated ? Claims.Get(type) : null; /// diff --git a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs index 881df3f2..64af51cb 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs @@ -1,11 +1,37 @@ ο»Ώnamespace CodeBeam.UltimateAuth.Client; +/// +/// Describes why the UltimateAuth client authentication state changed. +/// public enum UAuthStateChangeReason { + /// + /// The state was updated with an authenticated identity snapshot. + /// Authenticated, + + /// + /// The current authentication state was successfully validated. + /// Validated, + + /// + /// The current authentication state was marked as requiring validation. + /// MarkedStale, + + /// + /// The authentication state was cleared. + /// Cleared, + + /// + /// The state was explicitly touched to request an update or render. + /// Touched, + + /// + /// The current authenticated state was updated without replacing the full snapshot. + /// Patched } diff --git a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs index 6367d6a1..80a995e3 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs @@ -1,15 +1,57 @@ ο»Ώnamespace CodeBeam.UltimateAuth.Client; +/// +/// Identifies events that may affect the UltimateAuth client authentication state. +/// public enum UAuthStateEvent { + /// + /// Indicates that authentication state validation was requested. + /// ValidationCalled, + + /// + /// Indicates that one or more identifiers associated with the user changed. + /// IdentifiersChanged, + + /// + /// Indicates that the user's status changed. + /// UserStatusChanged, + + /// + /// Indicates that the user's profile information changed. + /// ProfileChanged, + + /// + /// Indicates that credentials associated with a user changed. + /// CredentialsChanged, + + /// + /// Indicates that the current user's own credentials changed. + /// CredentialsChangedSelf, + + /// + /// Indicates that authorization information associated with the user changed. + /// AuthorizationChanged, + + /// + /// Indicates that a session associated with the user was revoked. + /// SessionRevoked, + + /// + /// Indicates that the user was deleted. + /// UserDeleted, + + /// + /// Indicates that a logout operation affecting the authentication state occurred. + /// LogoutVariant } diff --git a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs index 173f6526..00ed1d9f 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs @@ -1,8 +1,23 @@ ο»Ώnamespace CodeBeam.UltimateAuth.Client; +/// +/// Specifies how the UltimateAuth client authentication state handles a state event. +/// public enum UAuthStateEventHandlingMode { + /// + /// Applies the event as a local update to the current authentication state + /// without performing full state validation. + /// Patch, + + /// + /// Revalidates the authentication state in response to the event. + /// Validate, + + /// + /// Performs no authentication state update in response to the event. + /// None } diff --git a/src/client/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj b/src/client/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj index fa4eaed0..f7f329c1 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj +++ b/src/client/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj @@ -2,7 +2,6 @@ net8.0;net9.0;net10.0 - $(NoWarn);1591 CodeBeam.UltimateAuth.Client diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs index 3b079766..d3b69728 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs @@ -1,7 +1,17 @@ ο»Ώnamespace CodeBeam.UltimateAuth.Client.Contracts; +/// +/// Specifies why an UltimateAuth session coordinator stopped its active coordination cycle. +/// public enum CoordinatorTerminationReason { + /// + /// Indicates that no specific termination reason was reported. + /// None = 0, - ReauthRequired = 1 + + /// + /// Indicates that the current authentication context requires the user to reauthenticate. + /// + ReauthRequired = 10 } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs index f04f078b..82206fe2 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs @@ -2,9 +2,23 @@ namespace CodeBeam.UltimateAuth.Client.Contracts; +/// +/// Represents the result of an UltimateAuth session or token refresh operation. +/// public sealed record RefreshResult { + /// + /// Gets a value indicating whether the refresh operation completed successfully. + /// public bool IsSuccess { get; init; } + + /// + /// Gets the status code associated with the refresh operation. + /// public int Status { get; init; } + + /// + /// Gets the semantic outcome of the refresh operation. + /// public RefreshOutcome Outcome { get; init; } } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs index 322f397c..3a7120cb 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs @@ -1,7 +1,17 @@ ο»Ώnamespace CodeBeam.UltimateAuth.Client.Contracts; +/// +/// Specifies the browser storage scope used for UltimateAuth client data. +/// public enum StorageScope { - Session, - Local + /// + /// Stores data in storage scoped to the current browser session. + /// + Session = 0, + + /// + /// Stores data in persistent browser-local storage. + /// + Local = 10 } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/TenantTransport.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/TenantTransport.cs index 6a13be2f..d470db9d 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Contracts/TenantTransport.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/TenantTransport.cs @@ -1,8 +1,22 @@ ο»Ώnamespace CodeBeam.UltimateAuth.Client.Contracts; +/// +/// Specifies how tenant context is transported with UltimateAuth client requests. +/// public enum TenantTransport { - None, - Header, - Route + /// + /// Does not explicitly include tenant context in the request transport. + /// + None = 0, + + /// + /// Includes tenant context in an HTTP request header. + /// + Header = 10, + + /// + /// Includes tenant context as part of the request route. + /// + Route = 20 } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthRenderMode.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthRenderMode.cs index f7f1cbba..ae3dd867 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthRenderMode.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthRenderMode.cs @@ -1,7 +1,18 @@ ο»Ώnamespace CodeBeam.UltimateAuth.Client; +/// +/// Specifies how UltimateAuth authentication state changes affect UI rendering. +/// public enum UAuthRenderMode { + /// + /// Does not automatically request a UI re-render in response to authentication + /// state change notifications. + /// Manual = 0, - Reactive = 1 + + /// + /// Automatically requests a UI re-render in response to authentication state change notifications. + /// + Reactive = 10 } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthSubmitMode.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthSubmitMode.cs index 4875462b..bf0f175a 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthSubmitMode.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthSubmitMode.cs @@ -1,8 +1,22 @@ ο»Ώnamespace CodeBeam.UltimateAuth.Client; +/// +/// Specifies how an UltimateAuth authentication submission is executed. +/// public enum UAuthSubmitMode { + /// + /// Commits the authentication operation directly without first returning a structured try result to the caller. + /// DirectCommit = 0, + + /// + /// Attempts the authentication operation and returns its result without committing a successful authentication. + /// TryOnly = 10, + + /// + /// Attempts the authentication operation and, when successful, commits the authentication flow. + /// TryAndCommit = 20, } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs index 1fd9fc47..0e87ba67 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs @@ -3,17 +3,36 @@ namespace CodeBeam.UltimateAuth.Client.Contracts; +/// +/// Represents the transport-level result of an UltimateAuth client request. +/// +/// +/// This type describes the response received by the client transport layer. +/// Application-level authentication results may be represented separately by more specific UltimateAuth result types. +/// public sealed class UAuthTransportResult { + /// + /// Gets a value indicating whether the transport response represents a successful operation. + /// [JsonPropertyName("ok")] public bool Ok { get; init; } + /// + /// Gets the HTTP status code returned by the request. + /// [JsonPropertyName("status")] public int Status { get; init; } + /// + /// Gets the refresh outcome reported by the transport response, when available. + /// [JsonPropertyName("refreshOutcome")] public string? RefreshOutcome { get; init; } + /// + /// Gets the response body as JSON, when a body is available. + /// [JsonPropertyName("body")] public JsonElement? Body { get; init; } } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs b/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs index 033d82f0..bf73fae4 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs @@ -2,7 +2,13 @@ namespace CodeBeam.UltimateAuth.Client.Device; +/// +/// Represents a generator for device identifiers. +/// public interface IDeviceIdGenerator { + /// + /// Generates a new device identifier. + /// DeviceId Generate(); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs b/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs index a9be9fdd..1e60ab73 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs @@ -2,7 +2,13 @@ namespace CodeBeam.UltimateAuth.Client; +/// +/// Provides a mechanism to retrieve or create a unique device identifier for the client application. +/// public interface IDeviceIdProvider { + /// + /// Retrieves the existing device identifier or creates a new one if it doesn't exist. + /// ValueTask GetOrCreateAsync(CancellationToken ct = default); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs b/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs index c91457d3..e6bbd169 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs @@ -1,7 +1,22 @@ ο»Ώnamespace CodeBeam.UltimateAuth.Client.Device; +/// +/// Represents a storage mechanism for device identifiers. +/// public interface IDeviceIdStorage { + /// + /// Loads the device identifier asynchronously. + /// + /// + /// ValueTask LoadAsync(CancellationToken ct = default); + + /// + /// Saves the device identifier asynchronously. + /// + /// + /// + /// ValueTask SaveAsync(string deviceId, CancellationToken ct = default); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs b/src/client/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs index eb21d03a..03008f9c 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs @@ -2,36 +2,117 @@ namespace CodeBeam.UltimateAuth.Client.Diagnostics; +/// +/// Represents diagnostic information for the UAuth client, tracking its lifecycle events and refresh attempts. +/// public sealed class UAuthClientDiagnostics { private int _terminatedCount; + /// + /// Occurs when any diagnostic information changes, allowing subscribers to react to updates in the client's state. + /// public event Action? Changed; + /// + /// Gets the timestamp when the client was started, or null if it has not been started yet. + /// public DateTimeOffset? StartedAt { get; private set; } + + /// + /// Gets the timestamp when the client was stopped, or null if it has not been stopped yet. + /// public DateTimeOffset? StoppedAt { get; private set; } + + /// + /// Gets the timestamp when the client was terminated, or null if it has not been terminated yet. + /// public DateTimeOffset? TerminatedAt { get; private set; } + + /// + /// Gets a value indicating whether the client is currently running, which is true if it has been started and has not been stopped or terminated. + /// public bool IsRunning => StartedAt is not null && !IsStopped && !IsTerminated; + + /// + /// Gets a value indicating whether the client has been stopped, which is true if it has a non-null StoppedAt timestamp. + /// public bool IsStopped => StoppedAt is not null; + + /// + /// Gets a value indicating whether the client has been terminated, which is true if it has a non-null TerminatedAt timestamp. + /// public bool IsTerminated { get; private set; } + + /// + /// Gets the reason for the client's termination, or null if it has not been terminated. + /// This provides context for why the client was terminated, such as due to an error or a manual stop request. + /// public CoordinatorTerminationReason? TerminationReason { get; private set; } + + /// + /// Gets the total number of times the client has been terminated, which is incremented each time the MarkTerminated method is called. + /// public int TerminatedCount => _terminatedCount; + + /// + /// Gets the total number of times the client has been started and stopped, which are incremented each time the MarkStarted and MarkStopped methods are called, respectively. + /// public int StartCount { get; private set; } + + /// + /// Gets the total number of times the client has been stopped, which is incremented each time the MarkStopped method is called. + /// public int StopCount { get; private set; } + + /// + /// Gets the total number of refresh attempts made by the client, which is incremented each time either the MarkManualRefresh or MarkAutomaticRefresh methods are called. + /// public int RefreshAttemptCount { get; private set; } + + /// + /// Gets the total number of manual refresh attempts made by the client, which is incremented each time the MarkManualRefresh method is called. + /// public int ManualRefreshCount { get; private set; } + + /// + /// Gets the total number of automatic refresh attempts made by the client, which is incremented each time the MarkAutomaticRefresh method is called. + /// public int AutomaticRefreshCount { get; private set; } - + + + /// + /// Gets the total number of refresh attempts that resulted in a "touched" state, which is incremented each time the MarkRefreshTouched method is called. + /// public int RefreshTouchedCount { get; private set; } + + /// + /// Gets the total number of refresh attempts that resulted in a "rotated" state, which is incremented each time the MarkRefreshRotated method is called. + /// public int RefreshRotatedCount { get; private set; } + + /// + /// Gets the total number of refresh attempts that resulted in a "no operation" state, which is incremented each time the MarkRefreshNoOp method is called. + /// public int RefreshNoOpCount { get; private set; } + + /// + /// Gets the total number of refresh attempts that required reauthentication, which is incremented each time the MarkRefreshReauthRequired method is called. + /// public int RefreshReauthRequiredCount { get; private set; } + + /// + /// Gets the total number of successful refresh attempts, which is incremented each time the MarkRefreshSuccess method is called. + /// public int RefreshSuccessCount { get; private set; } + /// + /// Gets the total duration for which the client has been running, calculated as the difference between the StartedAt timestamp and either the StoppedAt or TerminatedAt timestamp, or the current time if the client is still running. Returns null if the client has not been started yet. + /// public TimeSpan? RunningDuration => StartedAt is null ? null diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs index 2098052a..54798cf1 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs @@ -1,6 +1,12 @@ ο»Ώnamespace CodeBeam.UltimateAuth.Client.Infrastructure; +/// +/// Represents a bridge for browser-specific operations in the UltimateAuth client. +/// public interface IBrowserUAuthBridge { + /// + /// Sets the device ID in the browser's local storage or cookies. + /// ValueTask SetDeviceIdAsync(string deviceId); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthClientBootstrapper.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthClientBootstrapper.cs index 13b5b154..79c72386 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthClientBootstrapper.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthClientBootstrapper.cs @@ -1,6 +1,12 @@ ο»Ώnamespace CodeBeam.UltimateAuth.Client.Infrastructure; +/// +/// Represents a bootstrapper for the UltimateAuth client, responsible for ensuring that the client is properly initialized and started before use. +/// public interface IUAuthClientBootstrapper { + /// + /// Ensures that the UltimateAuth client is started and ready for use. This method should be called before any operations that require the client to be initialized. + /// Task EnsureStartedAsync(CancellationToken ct = default); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs index 98618306..b1c7d47b 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs @@ -3,13 +3,49 @@ namespace CodeBeam.UltimateAuth.Client.Infrastructure; +/// +/// Defines a client for sending requests to the UltimateAuth server, handling navigation, form submissions, JSON payloads, and transactional operations. +/// public interface IUAuthRequestClient { + /// + /// Navigates to the specified endpoint, optionally submitting form data, and handles the response. + /// + /// + /// + /// + /// Task NavigateAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default); + + /// + /// Sends a form submission to the specified endpoint and returns the result of the operation. + /// + /// + /// + /// + /// Task SendFormAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default); + + /// + /// Sends a JSON payload to the specified endpoint and returns the result of the operation. + /// + /// + /// + /// + /// Task SendJsonAsync(string endpoint, object? payload = null, CancellationToken ct = default); + + /// + /// Attempts to perform a transactional operation by first trying the specified endpoint and, if successful, committing the operation to another endpoint. Returns the result of the try operation. + /// + /// + /// + /// + /// + /// + /// Task TryAndCommitAsync(string tryEndpoint, string commitEndpoint, object request, CancellationToken ct = default); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs index d42e1275..ab7db38c 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs @@ -1,5 +1,8 @@ ο»Ώnamespace CodeBeam.UltimateAuth.Client; +/// +/// Indicates that the decorated class is a login page component for the UltimateAuth client. +/// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] public sealed class UAuthLoginPageAttribute : Attribute { diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs index 717c9378..41ff2612 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs @@ -3,8 +3,19 @@ namespace CodeBeam.UltimateAuth.Client.Infrastructure; +/// +/// Provides utility methods for constructing URLs for UltimateAuth endpoints, taking into account multi-tenancy and transport options. +/// public static class UAuthUrlBuilder { + /// + /// Builds a complete URL for an UltimateAuth endpoint based on the provided authority, relative path, and multi-tenant options. + /// + /// + /// + /// + /// + /// public static string Build(string authority, string relativePath, UAuthClientMultiTenantOptions tenant) { var baseAuthority = authority.TrimEnd('/'); diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs index 0b709c79..6a8ab4e2 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs @@ -1,5 +1,8 @@ ο»Ώnamespace CodeBeam.UltimateAuth.Client.Options; +/// +/// Options for configuring the endpoints of the UAuth client. +/// public sealed class UAuthClientEndpointOptions { /// @@ -7,14 +10,53 @@ public sealed class UAuthClientEndpointOptions /// public string BasePath { get; set; } = "/auth"; + /// + /// Path for the login endpoint (e.g. /login) + /// public string Login { get; set; } = "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/login"; + + /// + /// Path for the try login endpoint (e.g. /try-login) + /// public string TryLogin { get; set; } = "/try-login"; + + /// + /// Path for the logout endpoint (e.g. /logout) + /// public string Logout { get; set; } = "/logout"; + + /// + /// Path for the refresh endpoint (e.g. /refresh) + /// public string Refresh { get; set; } = "/refresh"; + + /// + /// Path for the reauth endpoint (e.g. /reauth) + /// public string Reauth { get; set; } = "/reauth"; + + /// + /// Path for the validate endpoint (e.g. /validate) + /// public string Validate { get; set; } = "/validate"; + + /// + /// Path for the PKCE authorize endpoint (e.g. /pkce/authorize) + /// public string PkceAuthorize { get; set; } = "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/pkce/authorize"; + + /// + /// Path for the PKCE try complete endpoint (e.g. /pkce/try-complete) + /// public string PkceTryComplete { get; set; } = "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/pkce/try-complete"; + + /// + /// Path for the PKCE complete endpoint (e.g. /pkce/complete) + /// public string PkceComplete { get; set; } = "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/pkce/complete"; + + /// + /// Path for the UAuthHub login endpoint (e.g. /uauthhub/entry) + /// public string HubLoginPath { get; set; } = "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/uauthhub/entry"; } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientLoginFlowOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientLoginFlowOptions.cs index 9cb1d376..db4dc0f2 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientLoginFlowOptions.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientLoginFlowOptions.cs @@ -1,5 +1,8 @@ ο»Ώnamespace CodeBeam.UltimateAuth.Client.Options; +/// +/// Options for configuring the login flow behavior of the UAuth client. +/// public sealed class UAuthClientLoginFlowOptions { /// diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientMultiTenantOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientMultiTenantOptions.cs index c5d9caf6..ee273691 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientMultiTenantOptions.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientMultiTenantOptions.cs @@ -2,6 +2,9 @@ namespace CodeBeam.UltimateAuth.Client.Options; +/// +/// Options for multi-tenant support in the UAuth client. +/// public sealed class UAuthClientMultiTenantOptions { /// diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs index 16fa248c..12c4ced0 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs @@ -2,6 +2,9 @@ namespace CodeBeam.UltimateAuth.Client.Options; +/// +/// Options for configuring the PKCE login flow in the UAuth client. +/// public sealed class UAuthClientPkceLoginFlowOptions { /// @@ -9,6 +12,9 @@ public sealed class UAuthClientPkceLoginFlowOptions /// public bool Enabled { get; set; } = true; + /// + /// The URL to redirect to after successful login. + /// public string? ReturnUrl { get; set; } /// diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientReauthOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientReauthOptions.cs index 3cb6796f..ca523092 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientReauthOptions.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientReauthOptions.cs @@ -3,8 +3,18 @@ namespace CodeBeam.UltimateAuth.Client.Options; // TODO: Add ClearCookieOnReauth +/// +/// Options for reauthentication behavior in the UAuth client. +/// public sealed class UAuthClientReauthOptions { + /// + /// Specifies the behavior to follow when reauthentication is required. + /// public ReauthBehavior Behavior { get; set; } = ReauthBehavior.Redirect; + + /// + /// Specifies the path to redirect to when reauthentication is required and the behavior is set to Redirect. + /// public string? RedirectPath { get; set; } } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthStateEventOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthStateEventOptions.cs index 51bf9632..9b795104 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthStateEventOptions.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthStateEventOptions.cs @@ -1,6 +1,12 @@ ο»Ώnamespace CodeBeam.UltimateAuth.Client.Options; +/// +/// Options for handling UAuth state events in the client. +/// public class UAuthStateEventOptions { + /// + /// Gets or sets the handling mode for UAuth state events. + /// public UAuthStateEventHandlingMode HandlingMode { get; set; } = UAuthStateEventHandlingMode.Patch; } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientEndpointOptionsValidator.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientEndpointOptionsValidator.cs index d00b4f2e..2a18d4b8 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientEndpointOptionsValidator.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientEndpointOptionsValidator.cs @@ -2,8 +2,17 @@ namespace CodeBeam.UltimateAuth.Client.Options; +/// +/// Validates the to ensure that all required endpoint paths are specified and not empty. +/// public sealed class UAuthClientEndpointOptionsValidator : IValidateOptions { + /// + /// Validates the specified instance. + /// + /// + /// + /// public ValidateOptionsResult Validate(string? name, UAuthClientOptions options) { var e = options.Endpoints; diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientOptionsValidator.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientOptionsValidator.cs index 98e2ec3c..f0352dd9 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientOptionsValidator.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientOptionsValidator.cs @@ -3,8 +3,17 @@ namespace CodeBeam.UltimateAuth.Client.Options; +/// +/// Validates the to ensure that the configuration is consistent and valid. +/// public sealed class UAuthClientOptionsValidator : IValidateOptions { + /// + /// Validates the provided instance. + /// + /// + /// + /// public ValidateOptionsResult Validate(string? name, UAuthClientOptions options) { if (options.ClientProfile == UAuthClientProfile.NotSpecified && options.AutoDetectClientProfile == false) diff --git a/src/client/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs b/src/client/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs index d240224a..2fabb239 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs @@ -1,6 +1,12 @@ ο»Ώnamespace CodeBeam.UltimateAuth.Client.Runtime; +/// +/// Provides information about the product using the UltimateAuth client. +/// public interface IUAuthClientProductInfoProvider { + /// + /// Gets the product information for the UltimateAuth client. + /// UAuthClientProductInfo Get(); } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs b/src/client/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs index 771c92c5..ffc919af 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs @@ -3,20 +3,61 @@ namespace CodeBeam.UltimateAuth.Client.Runtime; +/// +/// Represents product information for the UltimateAuth client, including versioning, client profile, and runtime details. +/// public sealed class UAuthClientProductInfo { + /// + /// Gets the name of the product. This is a read-only property initialized to "UltimateAuth Client". + /// public string ProductName { get; init; } = "UltimateAuth Client"; + + /// + /// Gets the version of the product. This is a required property that must be initialized with a valid version string. + /// public string Version { get; init; } = default!; + + /// + /// Gets the informational version of the product. This is an optional property that can be initialized with a version string for informational purposes. + /// public string? InformationalVersion { get; init; } + + /// + /// Gets the client profile associated with the UltimateAuth client. This is a required property that must be initialized with a valid UAuthClientProfile value. + /// public UAuthClientProfile ClientProfile { get; init; } = default!; + + /// + /// Gets the timestamp indicating when the UltimateAuth client started. This is a required property that must be initialized with a valid DateTimeOffset value. + /// public DateTimeOffset StartedAt { get; init; } + + /// + /// Gets the runtime identifier for the UltimateAuth client. This is a read-only property initialized with a new GUID in string format, which uniquely identifies the runtime instance. + /// public string RuntimeId { get; init; } = Guid.NewGuid().ToString("n"); + + /// + /// Gets a value indicating whether auto-refresh is enabled for the UltimateAuth client. This is a required property that must be initialized with a boolean value. + /// public bool AutoRefreshEnabled { get; init; } + + /// + /// Gets the refresh interval for the UltimateAuth client. This is an optional property that can be initialized with a TimeSpan value indicating how often the client should refresh its state. If not set, the client may use a default refresh interval. + /// public TimeSpan? RefreshInterval { get; init; } + + /// + /// Gets the reauthentication behavior for the UltimateAuth client. This is a required property that must be initialized with a valid ReauthBehavior value, which determines how the client handles reauthentication scenarios. + /// public ReauthBehavior ReauthBehavior { get; init; } + /// + /// Gets the framework description for the UltimateAuth client. This is a required property that must be initialized with a string value describing the framework in which the client is running (e.g., ".NET 6.0", ".NET 7.0"). + /// public string FrameworkDescription { get; init; } = default!; } diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs index cf9bb88e..f103469a 100644 --- a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -7,6 +7,7 @@ using CodeBeam.UltimateAuth.Client.Infrastructure; using CodeBeam.UltimateAuth.Client.Options; using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Core.Infrastructure; using CodeBeam.UltimateAuth.Users.Contracts; @@ -279,7 +280,7 @@ public async Task TryCompletePkceLoginAsync(PkceCompleteRequ if (!string.IsNullOrWhiteSpace(request.ReturnUrl)) { - payload["return_url"] = request.ReturnUrl; + payload[UAuthConstants.Form.ReturnUrl] = request.ReturnUrl; } switch (mode) @@ -330,7 +331,7 @@ public async Task CompletePkceLoginAsync(PkceCompleteRequest request) { ["authorization_code"] = request.AuthorizationCode, ["code_verifier"] = request.CodeVerifier, - ["return_url"] = request.ReturnUrl ?? string.Empty, + [UAuthConstants.Form.ReturnUrl] = request.ReturnUrl ?? string.Empty, ["Identifier"] = request.Identifier ?? string.Empty, ["Secret"] = request.Secret ?? string.Empty, @@ -419,7 +420,7 @@ private IDictionary BuildPayload(LoginRequest request, string? r if (!string.IsNullOrWhiteSpace(resolvedReturnUrl)) { - payload["return_url"] = resolvedReturnUrl; + payload[UAuthConstants.Form.ReturnUrl] = resolvedReturnUrl; } return payload; @@ -436,7 +437,7 @@ private Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifi { ["authorization_code"] = authorizationCode, ["code_verifier"] = codeVerifier, - ["return_url"] = returnUrl, + [UAuthConstants.Form.ReturnUrl] = returnUrl, ["client_profile"] = _options.ClientProfile.ToString(), ["device"] = deviceEncoded }; diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs index 00bbef97..83c6f847 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs @@ -32,6 +32,24 @@ public async Task ExistsAsync(CredentialKey key, CancellationToken ct = de public async Task AddAsync(PasswordCredential credential, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + + if (credential.Tenant != _tenant) + throw new UAuthConflictException("tenant_mismatch"); + + var exists = await DbSet + .AsNoTracking() + .AnyAsync( + x => + x.Tenant == _tenant && + x.UserKey == credential.UserKey && + x.DeletedAt == null, + ct); + + if (exists) + throw new UAuthConflictException("password_credential_exists"); + + var entity = credential.ToProjection(); DbSet.Add(entity); @@ -73,6 +91,8 @@ public async Task SaveAsync(PasswordCredential credential, long expectedVersion, public async Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var entity = await DbSet .SingleOrDefaultAsync(x => x.Id == key.Id && @@ -95,6 +115,8 @@ public async Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long public async Task DeleteAsync(CredentialKey key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var entity = await DbSet .SingleOrDefaultAsync(x => x.Id == key.Id && @@ -123,6 +145,8 @@ public async Task DeleteAsync(CredentialKey key, long expectedVersion, DeleteMod public async Task> GetByUserAsync(UserKey userKey, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); + var entities = await DbSet .AsNoTracking() .Where(x => diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs index 4d524d37..4cbd31b2 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs @@ -19,6 +19,8 @@ public InMemoryPasswordCredentialStore(TenantExecutionContext tenant) : base(ten protected override void BeforeAdd(PasswordCredential entity) { + base.BeforeAdd(entity); + var exists = TenantValues() .Any(x => x.Tenant == entity.Tenant && diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/AssemblyVisibility.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/AssemblyVisibility.cs index 156a21be..35a00498 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/AssemblyVisibility.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/AssemblyVisibility.cs @@ -1,3 +1,5 @@ ο»Ώusing System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Users.Reference")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs index fd88eedc..d5e583c6 100644 --- a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs @@ -74,6 +74,7 @@ public PasswordCredential Snapshot() Metadata = Metadata, CreatedAt = CreatedAt, UpdatedAt = UpdatedAt, + DeletedAt = DeletedAt, Version = Version }; } diff --git a/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryTenantVersionedStore.cs b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryTenantVersionedStore.cs index f43531e4..eb7edb5c 100644 --- a/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryTenantVersionedStore.cs +++ b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryTenantVersionedStore.cs @@ -43,6 +43,18 @@ protected IReadOnlyList TenantValues() .AsReadOnly(); } + protected override void ValidateAdd(TEntity entity) + { + EnsureTenant(entity); + base.ValidateAdd(entity); + } + + protected override void ValidateSave(TEntity entity, long expectedVersion) + { + EnsureTenant(entity); + base.ValidateSave(entity, expectedVersion); + } + private void EnsureTenant(TEntity entity) { if (!_tenant.IsGlobal && entity.Tenant != _tenant.Tenant) diff --git a/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryVersionedStore.cs b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryVersionedStore.cs index 8d705d32..fc2df442 100644 --- a/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryVersionedStore.cs +++ b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryVersionedStore.cs @@ -16,6 +16,8 @@ public abstract class InMemoryVersionedStore : IVersionedStore GetAsync(TKey key, CancellationToken ct = default) { @@ -38,6 +40,11 @@ public virtual Task AddAsync(TEntity entity, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); + ValidateAdd(entity); + + if (entity.Version != 0) + throw new InvalidOperationException($"New {typeof(TEntity).Name} must have version 0."); + var key = GetKey(entity); var snapshot = Snapshot(entity); @@ -53,6 +60,8 @@ public virtual Task SaveAsync(TEntity entity, long expectedVersion, Cancellation { ct.ThrowIfCancellationRequested(); + ValidateSave(entity, expectedVersion); + var key = GetKey(entity); if (!_store.TryGetValue(key, out var current)) diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs index 5fd8819f..79c3c5d3 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs @@ -53,16 +53,27 @@ public static SessionChainProjection ToProjection(this UAuthSessionChain chain) public static void UpdateProjection(this UAuthSessionChain source, SessionChainProjection target) { - DeviceId.TryCreate(source.Device.DeviceId?.Value, out var deviceId); + if (source.Device.DeviceId is not DeviceId deviceId) + throw new ArgumentException("Device id required."); target.ActiveSessionId = source.ActiveSessionId; target.RevokedAt = source.RevokedAt; + target.DeviceId = deviceId; target.Device = source.Device; + target.ClaimsSnapshot = source.ClaimsSnapshot; - target.SecurityVersionAtCreation = source.SecurityVersionAtCreation; + + target.SecurityVersionAtCreation = + source.SecurityVersionAtCreation; + target.LastSeenAt = source.LastSeenAt; target.AbsoluteExpiresAt = source.AbsoluteExpiresAt; - // Version store-owned + + target.RotationCount = source.RotationCount; + target.TouchCount = source.TouchCount; + + // Version intentionally omitted: + // optimistic concurrency/version is store-owned. } } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs index 99824242..bcb56759 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs @@ -132,21 +132,31 @@ public async Task SaveSessionAsync(UAuthSession session, long expectedVersion, C projection.Version++; } - public Task CreateSessionAsync(UAuthSession session, CancellationToken ct = default) + public async Task CreateSessionAsync(UAuthSession session, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); + if (session.Tenant != _tenant) + throw new InvalidOperationException("Tenant mismatch."); + if (!_inExecution) throw new InvalidOperationException("Must be called inside ExecuteAsync"); - var projection = session.ToProjection(); - if (session.Version != 0) throw new InvalidOperationException("New session must have version 0."); - DbSetSession.Add(projection); + var exists = await _db.Set() + .AnyAsync( + x => x.Tenant == _tenant && + x.SessionId == session.SessionId, + ct); - return Task.CompletedTask; + if (exists) + throw new UAuthConcurrencyException( + "session_already_exists"); + + var projection = session.ToProjection(); + DbSetSession.Add(projection); } public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) @@ -165,6 +175,19 @@ public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffs domain.UpdateProjection(projection); projection.Version++; + var chain = await DbSetChain + .SingleOrDefaultAsync( + x => x.Tenant == _tenant && + x.ChainId == projection.ChainId, + ct); + + if (chain?.ActiveSessionId == sessionId) + { + chain.ActiveSessionId = null; + chain.LastSeenAt = at; + chain.Version++; + } + return true; } @@ -314,22 +337,38 @@ public async Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion, projection.Version++; } - public Task CreateChainAsync(UAuthSessionChain chain, CancellationToken ct = default) + public async Task CreateChainAsync(UAuthSessionChain chain, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); + if (chain.Tenant != _tenant) + throw new InvalidOperationException("Tenant mismatch."); + if (!_inExecution) throw new InvalidOperationException("Must be called inside ExecuteAsync"); if (chain.Version != 0) throw new InvalidOperationException("New chain must have version 0."); - var projection = chain.ToProjection(); + var exists = + DbSetChain.Local.Any(x => + x.Tenant == _tenant && + x.ChainId == chain.ChainId); - DbSetChain.Add(projection); - _db.Entry(projection).State = EntityState.Added; + if (!exists) + { + exists = await DbSetChain + .AsNoTracking() + .AnyAsync( + x => x.Tenant == _tenant && + x.ChainId == chain.ChainId, + ct); + } - return Task.CompletedTask; + if (exists) + throw new UAuthConcurrencyException("chain_already_exists"); + + DbSetChain.Add(chain.ToProjection()); } public async Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) @@ -475,6 +514,9 @@ public async Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, Can { ct.ThrowIfCancellationRequested(); + if (root.Tenant != _tenant) + throw new InvalidOperationException("Tenant mismatch."); + if (!_inExecution) throw new InvalidOperationException("Must be called inside ExecuteAsync"); @@ -494,21 +536,40 @@ public async Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, Can projection.Version++; } - public Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = default) + public async Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); + if (root.Tenant != _tenant) + throw new InvalidOperationException("Tenant mismatch."); + if (!_inExecution) throw new InvalidOperationException("Must be called inside ExecuteAsync"); if (root.Version != 0) throw new InvalidOperationException("New root must have version 0."); + var exists = DbSetRoot.Local.Any(x => + x.Tenant == _tenant && + x.UserKey == root.UserKey); + + if (!exists) + { + exists = await DbSetRoot + .AsNoTracking() + .AnyAsync( + x => + x.Tenant == _tenant && + x.UserKey == root.UserKey, + ct); + } + + if (exists) + throw new UAuthConcurrencyException("root_already_exists"); + var projection = root.ToProjection(); DbSetRoot.Add(projection); - - return Task.CompletedTask; } public async Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default) @@ -604,6 +665,21 @@ public async Task RemoveSessionAsync(AuthSessionId sessionId, CancellationToken if (projection is null) return; + var chain = await DbSetChain + .SingleOrDefaultAsync( + x => x.Tenant == _tenant && + x.ChainId == projection.ChainId, + ct); + + if (chain?.ActiveSessionId == sessionId) + { + chain.ActiveSessionId = null; + + // Remove has no DateTimeOffset parameter. + // Don't introduce DateTimeOffset.UtcNow here merely to update LastSeenAt. + chain.Version++; + } + DbSetSession.Remove(projection); } diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs index c21a91d6..c081e412 100644 --- a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs @@ -73,6 +73,9 @@ public Task CreateSessionAsync(UAuthSession session, CancellationToken ct = defa lock (_lock) { + if (session.Tenant != _tenant) + throw new InvalidOperationException("Tenant mismatch."); + if (_sessions.ContainsKey(session.SessionId)) throw new UAuthConcurrencyException("session_already_exists"); @@ -203,6 +206,9 @@ public Task CreateChainAsync(UAuthSessionChain chain, CancellationToken ct = def { ct.ThrowIfCancellationRequested(); + if (chain.Tenant != _tenant) + throw new InvalidOperationException("Tenant mismatch."); + lock (_lock) { if (_chains.ContainsKey(chain.ChainId)) @@ -285,6 +291,9 @@ public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, Cancellat { ct.ThrowIfCancellationRequested(); + if (root.Tenant != _tenant) + throw new InvalidOperationException("Tenant mismatch."); + if (!_roots.TryGetValue((_tenant, root.UserKey), out var current)) throw new UAuthNotFoundException("root_not_found"); @@ -299,6 +308,9 @@ public Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = defaul { ct.ThrowIfCancellationRequested(); + if (root.Tenant != _tenant) + throw new InvalidOperationException("Tenant mismatch."); + lock (_lock) { if (_roots.ContainsKey((_tenant, root.UserKey))) diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs index 14e4de4f..2f87aab7 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs @@ -145,6 +145,9 @@ public async Task AddAsync(UserIdentifier entity, CancellationToken ct = default { ct.ThrowIfCancellationRequested(); + if (entity.Tenant != _tenant) + throw new UAuthConflictException("tenant_mismatch"); + if (entity.Version != 0) throw new UAuthValidationException("New identifier must have version 0."); @@ -176,6 +179,9 @@ public async Task SaveAsync(UserIdentifier entity, long expectedVersion, Cancell { ct.ThrowIfCancellationRequested(); + if (entity.Tenant != _tenant) + throw new UAuthConflictException("tenant_mismatch"); + using var tx = await _db.Database.BeginTransactionAsync(ct); if (entity.IsPrimary) @@ -322,11 +328,26 @@ public async Task> QueryAsync(UserIdentifierQuery qu ? baseQuery.OrderByDescending(x => x.CreatedAt) : baseQuery.OrderBy(x => x.CreatedAt), + nameof(UserIdentifier.UpdatedAt) => + query.Descending + ? baseQuery.OrderByDescending(x => x.UpdatedAt) + : baseQuery.OrderBy(x => x.UpdatedAt), + + nameof(UserIdentifier.DeletedAt) => + query.Descending + ? baseQuery.OrderByDescending(x => x.DeletedAt) + : baseQuery.OrderBy(x => x.DeletedAt), + nameof(UserIdentifier.Value) => query.Descending ? baseQuery.OrderByDescending(x => x.Value) : baseQuery.OrderBy(x => x.Value), + nameof(UserIdentifier.NormalizedValue) => + query.Descending + ? baseQuery.OrderByDescending(x => x.NormalizedValue) + : baseQuery.OrderBy(x => x.NormalizedValue), + _ => baseQuery.OrderBy(x => x.CreatedAt) }; diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs index 9994d412..bd617ef4 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs @@ -48,9 +48,23 @@ public async Task AddAsync(UserLifecycle entity, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); + if (entity.Tenant != _tenant) + throw new UAuthConflictException("tenant_mismatch"); + if (entity.Version != 0) throw new InvalidOperationException("New lifecycle must have version 0."); + var exists = await DbSet + .AsNoTracking() + .AnyAsync( + x => + x.Tenant == _tenant && + x.UserKey == entity.UserKey, + ct); + + if (exists) + throw new UAuthConflictException("user_lifecycle_exists"); + var projection = entity.ToProjection(); DbSet.Add(projection); @@ -62,6 +76,9 @@ public async Task SaveAsync(UserLifecycle entity, long expectedVersion, Cancella { ct.ThrowIfCancellationRequested(); + if (entity.Tenant != _tenant) + throw new UAuthConflictException("tenant_mismatch"); + var existing = await DbSet .SingleOrDefaultAsync(x => x.Tenant == _tenant && @@ -133,6 +150,12 @@ public async Task> QueryAsync(UserLifecycleQuery quer nameof(UserLifecycle.CreatedAt) => query.Descending ? baseQuery.OrderByDescending(x => x.CreatedAt) : baseQuery.OrderBy(x => x.CreatedAt), + nameof(UserLifecycle.UpdatedAt) => + query.Descending ? baseQuery.OrderByDescending(x => x.UpdatedAt) : baseQuery.OrderBy(x => x.UpdatedAt), + + nameof(UserLifecycle.DeletedAt) => + query.Descending ? baseQuery.OrderByDescending(x => x.DeletedAt) : baseQuery.OrderBy(x => x.DeletedAt), + nameof(UserLifecycle.Status) => query.Descending ? baseQuery.OrderByDescending(x => x.Status) : baseQuery.OrderBy(x => x.Status), diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs index dbbe32fd..32bef99f 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs @@ -53,6 +53,9 @@ public async Task AddAsync(UserProfile entity, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); + if (entity.Tenant != _tenant) + throw new UAuthConflictException("tenant_mismatch"); + var projection = entity.ToProjection(); if (entity.Version != 0) @@ -77,6 +80,9 @@ public async Task SaveAsync(UserProfile entity, long expectedVersion, Cancellati { ct.ThrowIfCancellationRequested(); + if (entity.Tenant != _tenant) + throw new UAuthConflictException("tenant_mismatch"); + var existing = await DbSet .SingleOrDefaultAsync(x => x.Tenant == _tenant && @@ -206,9 +212,14 @@ public async Task> GetAllProfilesByUserAsync(UserKey var projections = await DbSet .AsNoTracking() - .Where(x => x.Tenant == _tenant) - .Where(x => x.UserKey == userKey) + .Where(x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.DeletedAt == null) .ToListAsync(ct); - return projections.Select(x => x.ToDomain()).ToList(); + + return projections + .Select(x => x.ToDomain()) + .ToList(); } } diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs index 27ee484a..252fe6d7 100644 --- a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -243,13 +243,16 @@ public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest reque foreach (var profile in profiles) { var key = new UserProfileKey(context.ResourceTenant, profile.UserKey, profile.ProfileKey); - await profileStore.DeleteAsync(key, profile.Version, DeleteMode.Soft, now, innerCt); + await profileStore.DeleteAsync(key, profile.Version, request.Mode, now, innerCt); } foreach (var integration in _integrations) { await integration.OnUserDeletedAsync(context.ResourceTenant, targetUserKey, request.Mode, innerCt); } + + var sessionStore = _sessionStoreFactory.Create(context.ResourceTenant); + await sessionStore.RevokeAllChainsAsync(targetUserKey, now, innerCt); }); await _accessOrchestrator.ExecuteAsync(context, command, ct); @@ -572,13 +575,6 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde throw new UAuthIdentifierValidationException("username_change_not_allowed"); } - var validationDto = identifier.ToDto(); - var validationResult = await _identifierValidator.ValidateAsync(context, validationDto, innerCt); - if (validationResult.IsValid != true) - { - throw new UAuthValidationException(string.Join(", ", validationResult.Errors)); - } - var normalized = _identifierNormalizer.Normalize(identifier.Type, request.NewValue); if (!normalized.IsValid) throw new UAuthIdentifierValidationException(normalized.ErrorCode ?? "identifier_invalid"); @@ -586,6 +582,17 @@ public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIde if (string.Equals(identifier.NormalizedValue, normalized.Normalized, StringComparison.Ordinal)) throw new UAuthIdentifierValidationException("identifier_value_unchanged"); + var validationDto = identifier.ToDto(); + validationDto.Value = request.NewValue; + validationDto.NormalizedValue = normalized.Normalized; + + var validationResult = await _identifierValidator.ValidateAsync(context, validationDto, innerCt); + + if (!validationResult.IsValid) + { + throw new UAuthValidationException(string.Join(", ", validationResult.Errors)); + } + var withinUserResult = await identifierStore.ExistsAsync( new IdentifierExistenceQuery( identifier.Type, diff --git a/src/utilities/CodeBeam.UltimateAuth.DocsBuilder/CodeBeam.UltimateAuth.DocsBuilder.csproj b/src/utilities/CodeBeam.UltimateAuth.DocsBuilder/CodeBeam.UltimateAuth.DocsBuilder.csproj index 15919d0a..cdce659b 100644 --- a/src/utilities/CodeBeam.UltimateAuth.DocsBuilder/CodeBeam.UltimateAuth.DocsBuilder.csproj +++ b/src/utilities/CodeBeam.UltimateAuth.DocsBuilder/CodeBeam.UltimateAuth.DocsBuilder.csproj @@ -9,8 +9,8 @@ - - + + diff --git a/tests/.gitkeep b/tests/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/tests/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -ο»Ώ \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/AuthenticationSecurityStateStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/AuthenticationSecurityStateStoreContractTests.cs new file mode 100644 index 00000000..e3fc6db3 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/AuthenticationSecurityStateStoreContractTests.cs @@ -0,0 +1,584 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Security; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Authentication.Contracts; + +public abstract class AuthenticationSecurityStateStoreContractTests +{ + protected abstract Task CreateDatabaseAsync(); + + protected virtual TenantKey Tenant => TenantKeys.Single; + + protected static readonly DateTimeOffset Now = + new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); + + // --------------------------------------------------------- + // Add / Get + // --------------------------------------------------------- + + [Fact] + public async Task AddAsync_WhenValid_PersistsState() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var user = UserKey.New(); + + var state = AuthenticationSecurityState.CreateAccount(Tenant, user); + + await store.AddAsync(state); + + var result = await store.GetAsync( + user, + AuthenticationSecurityScope.Account, + null); + + result.Should().NotBeNull(); + result!.Id.Should().Be(state.Id); + result.Tenant.Should().Be(Tenant); + result.UserKey.Should().Be(user); + result.Scope.Should().Be(state.Scope); + result.CredentialType.Should().Be(state.CredentialType); + result.SecurityVersion.Should().Be(state.SecurityVersion); + } + + [Fact] + public async Task AddAsync_SameUserWithAccountAndFactorStates_IsAllowed() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var user = UserKey.New(); + + var account = AuthenticationSecurityState.CreateAccount( + Tenant, + user); + + var factor = AuthenticationSecurityState.CreateFactor( + Tenant, + user, + CredentialType.Password); + + await store.AddAsync(account); + await store.AddAsync(factor); + + var accountResult = await store.GetAsync( + user, + AuthenticationSecurityScope.Account, + null); + + var factorResult = await store.GetAsync( + user, + AuthenticationSecurityScope.Factor, + CredentialType.Password); + + accountResult.Should().NotBeNull(); + factorResult.Should().NotBeNull(); + + accountResult!.Id.Should().Be(account.Id); + factorResult!.Id.Should().Be(factor.Id); + } + + [Fact] + public async Task AddAsync_WhenSameLogicalStateAlreadyExists_ThrowsConflict() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var user = UserKey.New(); + + var first = AuthenticationSecurityState.CreateAccount( + Tenant, + user); + + var second = AuthenticationSecurityState.CreateAccount( + Tenant, + user); + + await store.AddAsync(first); + + var act = () => store.AddAsync(second); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task AddAsync_WhenStateBelongsToDifferentTenant_IsRejected() + { + await using var db = await CreateDatabaseAsync(); + + var tenantA = TestIds.Tenant("tenant-a"); + var tenantB = TestIds.Tenant("tenant-b"); + + var store = db.CreateStore(tenantA); + + var state = AuthenticationSecurityState.CreateAccount( + tenantB, + UserKey.New()); + + var act = () => store.AddAsync(state); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task GetAsync_WhenStateDoesNotExist_ReturnsNull() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var result = await store.GetAsync( + UserKey.New(), + AuthenticationSecurityScope.Account, + null); + + result.Should().BeNull(); + } + + // --------------------------------------------------------- + // Logical key + // --------------------------------------------------------- + + [Fact] + public async Task AddAsync_SameUserWithDifferentScope_IsAllowed() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var user = UserKey.New(); + + var first = AuthenticationSecurityState.CreateAccount(Tenant, user); + + var second = AuthenticationSecurityState.CreateFactor(Tenant, user, CredentialType.Password); + + await store.AddAsync(first); + await store.AddAsync(second); + + var firstResult = await store.GetAsync( + user, + first.Scope, + first.CredentialType); + + var secondResult = await store.GetAsync( + user, + second.Scope, + second.CredentialType); + + firstResult.Should().NotBeNull(); + secondResult.Should().NotBeNull(); + + firstResult!.Id.Should().Be(first.Id); + secondResult!.Id.Should().Be(second.Id); + } + + [Fact] + public async Task AddAsync_SameUserAndScopeWithDifferentCredentialType_IsAllowed() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var user = UserKey.New(); + + var first = AuthenticationSecurityState.CreateFactor(Tenant, user, CredentialType.Password); + + var second = AuthenticationSecurityState.CreateFactor(Tenant, user, CredentialType.Passkey); + + await store.AddAsync(first); + await store.AddAsync(second); + + var password = await store.GetAsync( + user, + AuthenticationSecurityScope.Factor, + CredentialType.Password); + + var passkey = await store.GetAsync( + user, + AuthenticationSecurityScope.Factor, + CredentialType.Passkey); + + password.Should().NotBeNull(); + passkey.Should().NotBeNull(); + + password!.Id.Should().Be(first.Id); + passkey!.Id.Should().Be(second.Id); + } + + // --------------------------------------------------------- + // Update + // --------------------------------------------------------- + + [Fact] + public async Task UpdateAsync_WhenExpectedVersionMatches_PersistsChanges() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var user = UserKey.New(); + + var original = AuthenticationSecurityState.CreateAccount( + Tenant, + user); + + await store.AddAsync(original); + + var updated = original.RegisterFailure( + Now, + threshold: 3, + lockoutDuration: TimeSpan.FromMinutes(15)); + + await store.UpdateAsync( + updated, + expectedVersion: original.SecurityVersion); + + var result = await store.GetAsync( + user, + AuthenticationSecurityScope.Account, + null); + + result.Should().NotBeNull(); + + result!.Id.Should().Be(original.Id); + result.SecurityVersion.Should().Be(1); + result.FailedAttempts.Should().Be(1); + result.LastFailedAt.Should().Be(Now); + result.LockedUntil.Should().BeNull(); + } + + [Fact] + public async Task UpdateAsync_WhenExpectedVersionIsStale_ThrowsConflict() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var original = AuthenticationSecurityState.CreateAccount( + Tenant, + UserKey.New()); + + await store.AddAsync(original); + + var updated = original.RegisterFailure( + Now, + threshold: 3, + lockoutDuration: TimeSpan.FromMinutes(15)); + + var act = () => store.UpdateAsync( + updated, + expectedVersion: updated.SecurityVersion); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task UpdateAsync_WhenResetBegins_PersistsResetState() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var user = UserKey.New(); + + var original = AuthenticationSecurityState.CreateFactor( + Tenant, + user, + CredentialType.Password); + + await store.AddAsync(original); + + var updated = original.BeginReset( + "hashed-reset-token", + Now, + TimeSpan.FromMinutes(30)); + + await store.UpdateAsync( + updated, + original.SecurityVersion); + + var result = await store.GetAsync( + user, + AuthenticationSecurityScope.Factor, + CredentialType.Password); + + result.Should().NotBeNull(); + + result!.ResetRequestedAt.Should().Be(Now); + result.ResetExpiresAt.Should().Be(Now.AddMinutes(30)); + result.ResetConsumedAt.Should().BeNull(); + result.ResetTokenHash.Should().Be("hashed-reset-token"); + result.ResetAttempts.Should().Be(0); + result.SecurityVersion.Should().Be(1); + } + + [Fact] + public async Task UpdateAsync_WhenStateDoesNotExist_ThrowsNotFound() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var original = AuthenticationSecurityState.CreateAccount( + Tenant, + UserKey.New()); + + var updated = original.RequireReauthentication(); + + var act = () => store.UpdateAsync( + updated, + original.SecurityVersion); + + await act.Should() + .ThrowAsync(); + } + + // --------------------------------------------------------- + // Delete + // --------------------------------------------------------- + + [Fact] + public async Task DeleteAsync_WhenStateExists_RemovesState() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var user = UserKey.New(); + + var state = AuthenticationSecurityState.CreateAccount(Tenant, user); + + await store.AddAsync(state); + + await store.DeleteAsync( + user, + state.Scope, + state.CredentialType); + + var result = await store.GetAsync( + user, + state.Scope, + state.CredentialType); + + result.Should().BeNull(); + } + + [Fact] + public async Task DeleteAsync_AccountState_DoesNotDeleteFactorState() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var user = UserKey.New(); + + var account = AuthenticationSecurityState.CreateAccount( + Tenant, + user); + + var factor = AuthenticationSecurityState.CreateFactor( + Tenant, + user, + CredentialType.Password); + + await store.AddAsync(account); + await store.AddAsync(factor); + + await store.DeleteAsync( + user, + AuthenticationSecurityScope.Account, + null); + + var accountResult = await store.GetAsync( + user, + AuthenticationSecurityScope.Account, + null); + + var factorResult = await store.GetAsync( + user, + AuthenticationSecurityScope.Factor, + CredentialType.Password); + + accountResult.Should().BeNull(); + factorResult.Should().NotBeNull(); + } + + [Fact] + public async Task DeleteAsync_WhenStateDoesNotExist_IsIdempotent() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var act = () => store.DeleteAsync( + UserKey.New(), + AuthenticationSecurityScope.Account, + null); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task DeleteAsync_RemovesOnlyExactLogicalState() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var user = UserKey.New(); + + var password = AuthenticationSecurityState.CreateFactor(Tenant, user, CredentialType.Password); + var passkey = AuthenticationSecurityState.CreateFactor(Tenant, user, CredentialType.Passkey); + + await store.AddAsync(password); + await store.AddAsync(passkey); + + await store.DeleteAsync( + user, + AuthenticationSecurityScope.Factor, + CredentialType.Password); + + var passwordResult = await store.GetAsync( + user, + AuthenticationSecurityScope.Factor, + CredentialType.Password); + + var passkeyResult = await store.GetAsync( + user, + AuthenticationSecurityScope.Factor, + CredentialType.Passkey); + + passwordResult.Should().BeNull(); + passkeyResult.Should().NotBeNull(); + } + + // --------------------------------------------------------- + // Tenant isolation + // --------------------------------------------------------- + + [Fact] + public async Task GetAsync_IsTenantIsolated() + { + await using var db = await CreateDatabaseAsync(); + + var tenantA = TestIds.Tenant("tenant-a"); + var tenantB = TestIds.Tenant("tenant-b"); + + var storeA = db.CreateStore(tenantA); + var storeB = db.CreateStore(tenantB); + + var user = UserKey.New(); + + var state = AuthenticationSecurityState.CreateAccount( + tenantA, + user); + + await storeA.AddAsync(state); + + var fromA = await storeA.GetAsync( + user, + AuthenticationSecurityScope.Account, + null); + + var fromB = await storeB.GetAsync( + user, + AuthenticationSecurityScope.Account, + null); + + fromA.Should().NotBeNull(); + fromA!.Tenant.Should().Be(tenantA); + + fromB.Should().BeNull(); + } + + [Fact] + public async Task DeleteAsync_IsTenantIsolated() + { + await using var db = await CreateDatabaseAsync(); + + var tenantA = TestIds.Tenant("tenant-a"); + var tenantB = TestIds.Tenant("tenant-b"); + + var storeA = db.CreateStore(tenantA); + var storeB = db.CreateStore(tenantB); + + var user = UserKey.New(); + + var stateA = AuthenticationSecurityState.CreateAccount( + tenantA, + user); + + var stateB = AuthenticationSecurityState.CreateAccount( + tenantB, + user); + + await storeA.AddAsync(stateA); + await storeB.AddAsync(stateB); + + await storeA.DeleteAsync( + user, + AuthenticationSecurityScope.Account, + null); + + var fromA = await storeA.GetAsync( + user, + AuthenticationSecurityScope.Account, + null); + + var fromB = await storeB.GetAsync( + user, + AuthenticationSecurityScope.Account, + null); + + fromA.Should().BeNull(); + + fromB.Should().NotBeNull(); + fromB!.Id.Should().Be(stateB.Id); + fromB.Tenant.Should().Be(tenantB); + } + + // --------------------------------------------------------- + // Cancellation + // --------------------------------------------------------- + + [Fact] + public async Task Operations_WhenAlreadyCancelled_ThrowOperationCanceledException() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var state = AuthenticationSecurityState.CreateAccount(Tenant, UserKey.New()); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await FluentActions + .Invoking(() => store.GetAsync( + state.UserKey, + state.Scope, + state.CredentialType, + cts.Token)) + .Should() + .ThrowAsync(); + + await FluentActions + .Invoking(() => store.AddAsync(state, cts.Token)) + .Should() + .ThrowAsync(); + + await FluentActions + .Invoking(() => store.UpdateAsync( + state, + state.SecurityVersion, + cts.Token)) + .Should() + .ThrowAsync(); + + await FluentActions + .Invoking(() => store.DeleteAsync( + state.UserKey, + state.Scope, + state.CredentialType, + cts.Token)) + .Should() + .ThrowAsync(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/EfCoreAuthenticationSecurityStateStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/EfCoreAuthenticationSecurityStateStoreContractTests.cs new file mode 100644 index 00000000..003f5e51 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/EfCoreAuthenticationSecurityStateStoreContractTests.cs @@ -0,0 +1,78 @@ +ο»Ώusing CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Authentication.Contracts; + +public sealed class EfCoreAuthenticationSecurityStateStoreContractTests : AuthenticationSecurityStateStoreContractTests +{ + protected override async Task + CreateDatabaseAsync() + { + var database = new Database(); + + await database.InitializeAsync(); + + return database; + } + + private sealed class Database + : IAuthenticationSecurityStateStoreTestDatabase + { + private readonly SqliteConnection _connection; + + private readonly DbContextOptions + _options; + + private readonly List + _contexts = []; + + public Database() + { + _connection = + new SqliteConnection("Data Source=:memory:"); + + _options = + new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + } + + public async Task InitializeAsync() + { + await _connection.OpenAsync(); + + await using var db = + new UAuthAuthenticationDbContext(_options); + + await db.Database.EnsureCreatedAsync(); + } + + public IAuthenticationSecurityStateStore CreateStore( + TenantKey tenant) + { + var db = + new UAuthAuthenticationDbContext(_options); + + _contexts.Add(db); + + return new EfCoreAuthenticationSecurityStateStore< + UAuthAuthenticationDbContext>( + db, + new TenantExecutionContext(tenant)); + } + + public async ValueTask DisposeAsync() + { + foreach (var context in _contexts) + await context.DisposeAsync(); + + await _connection.DisposeAsync(); + } + } + + // AynΔ± CreateState / MutateState / AssertMutationPersisted + // implementation'Δ±. +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/IAuthenticationSecurityStateStoreTestDatabase.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/IAuthenticationSecurityStateStoreTestDatabase.cs new file mode 100644 index 00000000..e7f61ff8 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/IAuthenticationSecurityStateStoreTestDatabase.cs @@ -0,0 +1,9 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Authentication.Contracts; + +public interface IAuthenticationSecurityStateStoreTestDatabase : IAsyncDisposable +{ + IAuthenticationSecurityStateStore CreateStore(TenantKey tenant); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/InMemoryAuthenticationSecurityStateStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/InMemoryAuthenticationSecurityStateStoreContractTests.cs new file mode 100644 index 00000000..ab537943 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authentication/InMemoryAuthenticationSecurityStateStoreContractTests.cs @@ -0,0 +1,40 @@ +ο»Ώusing CodeBeam.UltimateAuth.Authentication.InMemory; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Authentication.Contracts; + +public sealed class InMemoryAuthenticationSecurityStateStoreContractTests : AuthenticationSecurityStateStoreContractTests +{ + protected override Task + CreateDatabaseAsync() + { + return Task.FromResult( + new Database()); + } + + private sealed class Database + : IAuthenticationSecurityStateStoreTestDatabase + { + private readonly Dictionary< + TenantKey, + IAuthenticationSecurityStateStore> _stores = []; + + public IAuthenticationSecurityStateStore CreateStore( + TenantKey tenant) + { + if (_stores.TryGetValue(tenant, out var store)) + return store; + + store = new InMemoryAuthenticationSecurityStateStore( + new TenantExecutionContext(tenant)); + + _stores.Add(tenant, store); + + return store; + } + + public ValueTask DisposeAsync() + => ValueTask.CompletedTask; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationEndpointHandlerTests.cs new file mode 100644 index 00000000..655e92f7 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationEndpointHandlerTests.cs @@ -0,0 +1,824 @@ +ο»Ώusing CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Reference; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Moq; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class AuthorizationEndpointHandlerTests +{ + // ========================================================= + // Check + // ========================================================= + + [Fact] + public async Task CheckAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var f = new Fixture(isAuthenticated: false); + var ctx = f.Http(); + + var result = await f.Sut.CheckAsync(ctx); + + AssertStatusCode( + result, + StatusCodes.Status401Unauthorized); + + f.AccessContextFactory.VerifyNoOtherCalls(); + f.Authorization.VerifyNoOtherCalls(); + } + + [Fact] + public async Task CheckAsync_WhenResourceIsMissing_ReturnsBadRequest() + { + var f = new Fixture(); + + var ctx = f.Json(new AuthorizationCheckRequest + { + Action = "orders.read", + Resource = "" + }); + + var result = await f.Sut.CheckAsync(ctx); + + AssertStatusCode( + result, + StatusCodes.Status400BadRequest); + + f.AccessContextFactory.VerifyNoOtherCalls(); + f.Authorization.VerifyNoOtherCalls(); + } + + [Fact] + public async Task CheckAsync_WhenActionIsMissing_ReturnsBadRequest() + { + var f = new Fixture(); + + var ctx = f.Json(new AuthorizationCheckRequest + { + Action = "", + Resource = "orders" + }); + + var result = await f.Sut.CheckAsync(ctx); + + AssertStatusCode( + result, + StatusCodes.Status400BadRequest); + + f.AccessContextFactory.VerifyNoOtherCalls(); + f.Authorization.VerifyNoOtherCalls(); + } + + [Fact] + public async Task CheckAsync_WhenAllowed_CreatesExpectedAccessContextAndReturnsOk() + { + var f = new Fixture(); + var accessContext = f.AccessContext("orders.read"); + + var ctx = f.Json(new AuthorizationCheckRequest + { + Action = "orders.read", + Resource = "orders", + ResourceId = "order-123" + }); + + f.SetupAccessContext( + "orders.read", + "orders", + "order-123", + accessContext); + + var authorizationResult = AuthorizationResult.Allow(); + + f.Authorization + .Setup(x => x.AuthorizeAsync( + accessContext, + ctx.RequestAborted)) + .ReturnsAsync(authorizationResult); + + var result = await f.Sut.CheckAsync(ctx); + + AssertStatusCode( + result, + StatusCodes.Status200OK); + + f.Authorization.Verify(x => x.AuthorizeAsync( + accessContext, + ctx.RequestAborted), + Times.Once); + } + + [Fact] + public async Task CheckAsync_WhenDenied_ReturnsForbidden() + { + var f = new Fixture(); + var accessContext = f.AccessContext("orders.delete"); + + var ctx = f.Json(new AuthorizationCheckRequest + { + Action = "orders.delete", + Resource = "orders", + ResourceId = "order-123" + }); + + f.SetupAccessContext( + "orders.delete", + "orders", + "order-123", + accessContext); + + f.Authorization + .Setup(x => x.AuthorizeAsync( + accessContext, + ctx.RequestAborted)) + .ReturnsAsync(AuthorizationResult.Deny("access_denied")); + + var result = await f.Sut.CheckAsync(ctx); + + result.Should().BeOfType(); + + f.Authorization.Verify(x => x.AuthorizeAsync( + accessContext, + ctx.RequestAborted), + Times.Once); + } + + [Fact] + public async Task CheckAsync_WhenReauthenticationIsRequired_Returns428() + { + var f = new Fixture(); + var accessContext = f.AccessContext("orders.delete"); + + var ctx = f.Json(new AuthorizationCheckRequest + { + Action = "orders.delete", + Resource = "orders", + ResourceId = "order-123" + }); + + f.SetupAccessContext( + "orders.delete", + "orders", + "order-123", + accessContext); + + f.Authorization + .Setup(x => x.AuthorizeAsync( + accessContext, + ctx.RequestAborted)) + .ReturnsAsync(AuthorizationResult.ReauthRequired()); + + var result = await f.Sut.CheckAsync(ctx); + + AssertStatusCode( + result, + StatusCodes.Status428PreconditionRequired); + } + + // ========================================================= + // Get My Roles + // ========================================================= + + [Fact] + public async Task GetMyRolesAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var f = new Fixture(isAuthenticated: false); + + var result = await f.Sut.GetMyRolesAsync(f.Http()); + + AssertStatusCode( + result, + StatusCodes.Status401Unauthorized); + + f.UserRoles.VerifyNoOtherCalls(); + } + + [Fact] + public async Task GetMyRolesAsync_WhenAuthenticated_UsesCurrentUser() + { + var f = new Fixture(); + var query = new RoleQuery + { + PageNumber = 2, + PageSize = 20 + }; + + var ctx = f.Json(query); + var accessContext = f.AccessContext( + UAuthActions.Authorization.Roles.GetSelf); + + f.SetupAccessContext( + UAuthActions.Authorization.Roles.GetSelf, + "authorization.roles", + f.UserKey.Value, + accessContext); + + var expected = EmptyRoles(); + + f.UserRoles + .Setup(x => x.GetRolesAsync( + accessContext, + f.UserKey, + It.Is(q => + q.PageNumber == 2 && + q.PageSize == 20), + ctx.RequestAborted)) + .ReturnsAsync(expected); + + var result = await f.Sut.GetMyRolesAsync(ctx); + + AssertStatusCode( + result, + StatusCodes.Status200OK); + + f.UserRoles.Verify(x => x.GetRolesAsync( + accessContext, + f.UserKey, + It.IsAny(), + ctx.RequestAborted), + Times.Once); + } + + // ========================================================= + // Get User Roles + // ========================================================= + + [Fact] + public async Task GetUserRolesAsync_WhenAuthenticated_UsesTargetUserAndAdminAction() + { + var f = new Fixture(); + var target = UserKey.New(); + + var ctx = f.Json(new RoleQuery()); + + var accessContext = f.AccessContext( + UAuthActions.Authorization.Roles.GetAdmin); + + f.SetupAccessContext( + UAuthActions.Authorization.Roles.GetAdmin, + "authorization.roles", + target.Value, + accessContext); + + f.UserRoles + .Setup(x => x.GetRolesAsync( + accessContext, + target, + It.IsAny(), + ctx.RequestAborted)) + .ReturnsAsync(EmptyRoles()); + + var result = await f.Sut.GetUserRolesAsync( + target, + ctx); + + AssertStatusCode( + result, + StatusCodes.Status200OK); + + f.UserRoles.Verify(x => x.GetRolesAsync( + accessContext, + target, + It.IsAny(), + ctx.RequestAborted), + Times.Once); + } + + // ========================================================= + // Assign + // ========================================================= + + [Fact] + public async Task AssignRoleAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var f = new Fixture(isAuthenticated: false); + var target = UserKey.New(); + + var result = await f.Sut.AssignRoleAsync( + target, + f.Http()); + + AssertStatusCode( + result, + StatusCodes.Status401Unauthorized); + + f.UserRoles.VerifyNoOtherCalls(); + } + + [Fact] + public async Task AssignRoleAsync_WhenAuthenticated_UsesTargetUserAndRoleName() + { + var f = new Fixture(); + var target = UserKey.New(); + + var ctx = f.Json(new AssignRoleRequest + { + UserKey = target, + RoleName = "Administrators" + }); + + var accessContext = f.AccessContext( + UAuthActions.Authorization.Roles.AssignAdmin); + + f.SetupAccessContext( + UAuthActions.Authorization.Roles.AssignAdmin, + "authorization.roles", + target.Value, + accessContext); + + f.UserRoles + .Setup(x => x.AssignAsync( + accessContext, + target, + "Administrators", + ctx.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = await f.Sut.AssignRoleAsync( + target, + ctx); + + AssertStatusCode( + result, + StatusCodes.Status200OK); + + f.UserRoles.Verify(x => x.AssignAsync( + accessContext, + target, + "Administrators", + ctx.RequestAborted), + Times.Once); + } + + // ========================================================= + // Remove + // ========================================================= + + [Fact] + public async Task RemoveRoleAsync_WhenAuthenticated_UsesTargetUserAndRoleName() + { + var f = new Fixture(); + var target = UserKey.New(); + + var ctx = f.Json(new RemoveRoleRequest + { + UserKey = target, + RoleName = "Administrators" + }); + + var accessContext = f.AccessContext( + UAuthActions.Authorization.Roles.RemoveAdmin); + + f.SetupAccessContext( + UAuthActions.Authorization.Roles.RemoveAdmin, + "authorization.roles", + target.Value, + accessContext); + + f.UserRoles + .Setup(x => x.RemoveAsync( + accessContext, + target, + "Administrators", + ctx.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = await f.Sut.RemoveRoleAsync( + target, + ctx); + + AssertStatusCode( + result, + StatusCodes.Status200OK); + + f.UserRoles.Verify(x => x.RemoveAsync( + accessContext, + target, + "Administrators", + ctx.RequestAborted), + Times.Once); + } + + // ========================================================= + // Create Role + // ========================================================= + + [Fact] + public async Task CreateRoleAsync_WhenAuthenticated_ForwardsRequest() + { + var f = new Fixture(); + + var permissions = new[] + { + Permission.From("users.read") + }; + + var ctx = f.Json(new CreateRoleRequest + { + Name = "Administrators", + Permissions = permissions + }); + + var accessContext = f.AccessContext( + UAuthActions.Authorization.Roles.CreateAdmin); + + f.SetupAccessContext( + UAuthActions.Authorization.Roles.CreateAdmin, + "authorization.roles", + null, + accessContext); + + var role = Role.Create( + RoleId.New(), + TenantKey.Single, + "Administrators", + permissions, + DateTimeOffset.UtcNow); + + f.Roles + .Setup(x => x.CreateAsync( + accessContext, + "Administrators", + It.Is>(p => + p.SequenceEqual(permissions)), + ctx.RequestAborted)) + .ReturnsAsync(role); + + var result = await f.Sut.CreateRoleAsync(ctx); + + AssertStatusCode( + result, + StatusCodes.Status200OK); + + f.Roles.Verify(x => x.CreateAsync( + accessContext, + "Administrators", + It.IsAny>(), + ctx.RequestAborted), + Times.Once); + } + + // ========================================================= + // Rename Role + // ========================================================= + + [Fact] + public async Task RenameRoleAsync_WhenAuthenticated_UsesRouteRoleId() + { + var f = new Fixture(); + var roleId = RoleId.New(); + + var ctx = f.Json(new RenameRoleRequest + { + Id = roleId, + Name = "Operators" + }); + + var accessContext = f.AccessContext( + UAuthActions.Authorization.Roles.RenameAdmin); + + f.SetupAccessContext( + UAuthActions.Authorization.Roles.RenameAdmin, + "authorization.roles", + roleId.ToString(), + accessContext); + + f.Roles + .Setup(x => x.RenameAsync( + accessContext, + roleId, + "Operators", + ctx.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = await f.Sut.RenameRoleAsync( + roleId, + ctx); + + AssertStatusCode( + result, + StatusCodes.Status200OK); + } + + // ========================================================= + // Delete Role + // ========================================================= + + [Fact] + public async Task DeleteRoleAsync_WhenAuthenticated_ForwardsDeleteMode() + { + var f = new Fixture(); + var roleId = RoleId.New(); + + var ctx = f.Json(new DeleteRoleRequest + { + Id = roleId, + Mode = DeleteMode.Hard + }); + + var accessContext = f.AccessContext( + UAuthActions.Authorization.Roles.DeleteAdmin); + + f.SetupAccessContext( + UAuthActions.Authorization.Roles.DeleteAdmin, + "authorization.roles", + roleId.ToString(), + accessContext); + + var expected = new DeleteRoleResult + { + RoleId = roleId, + Mode = DeleteMode.Hard, + RemovedAssignments = 3, + DeletedAt = DateTimeOffset.UtcNow + }; + + f.Roles + .Setup(x => x.DeleteAsync( + accessContext, + roleId, + DeleteMode.Hard, + ctx.RequestAborted)) + .ReturnsAsync(expected); + + var result = await f.Sut.DeleteRoleAsync( + roleId, + ctx); + + AssertStatusCode( + result, + StatusCodes.Status200OK); + + f.Roles.Verify(x => x.DeleteAsync( + accessContext, + roleId, + DeleteMode.Hard, + ctx.RequestAborted), + Times.Once); + } + + // ========================================================= + // Set Permissions + // ========================================================= + + [Fact] + public async Task SetRolePermissionsAsync_WhenAuthenticated_ForwardsPermissions() + { + var f = new Fixture(); + var roleId = RoleId.New(); + + var permissions = new[] + { + Permission.From("users.read") + }; + + var ctx = f.Json(new SetRolePermissionsRequest + { + RoleId = roleId, + Permissions = permissions + }); + + var accessContext = f.AccessContext( + UAuthActions.Authorization.Roles.SetPermissionsAdmin); + + f.SetupAccessContext( + UAuthActions.Authorization.Roles.SetPermissionsAdmin, + "authorization.roles", + roleId.ToString(), + accessContext); + + f.Roles + .Setup(x => x.SetPermissionsAsync( + accessContext, + roleId, + It.Is>(p => + p.SequenceEqual(permissions)), + ctx.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = await f.Sut.SetRolePermissionsAsync( + roleId, + ctx); + + AssertStatusCode( + result, + StatusCodes.Status200OK); + } + + // ========================================================= + // Query Roles + // ========================================================= + + [Fact] + public async Task QueryRolesAsync_WhenAuthenticated_ForwardsQuery() + { + var f = new Fixture(); + + var ctx = f.Json(new RoleQuery + { + Search = "admin", + IncludeDeleted = true, + PageNumber = 2, + PageSize = 10, + SortBy = nameof(Role.Name), + Descending = true + }); + + var accessContext = f.AccessContext( + UAuthActions.Authorization.Roles.QueryAdmin); + + f.SetupAccessContext( + UAuthActions.Authorization.Roles.QueryAdmin, + "authorization.roles", + null, + accessContext); + + var expected = new PagedResult( + [], + 0, + 2, + 10, + nameof(Role.Name), + true); + + f.Roles + .Setup(x => x.QueryAsync( + accessContext, + It.Is(q => + q.Search == "admin" && + q.IncludeDeleted && + q.PageNumber == 2 && + q.PageSize == 10 && + q.SortBy == nameof(Role.Name) && + q.Descending), + ctx.RequestAborted)) + .ReturnsAsync(expected); + + var result = await f.Sut.QueryRolesAsync(ctx); + + AssertStatusCode( + result, + StatusCodes.Status200OK); + } + + [Fact] + public async Task RenameRoleAsync_WhenBodyContainsDifferentId_UsesRouteRoleId() + { + var f = new Fixture(); + + var routeRoleId = RoleId.New(); + var bodyRoleId = RoleId.New(); + + var ctx = f.Json(new RenameRoleRequest + { + Id = bodyRoleId, + Name = "Operators" + }); + + var accessContext = f.AccessContext( + UAuthActions.Authorization.Roles.RenameAdmin); + + f.SetupAccessContext( + UAuthActions.Authorization.Roles.RenameAdmin, + "authorization.roles", + routeRoleId.ToString(), + accessContext); + + f.Roles + .Setup(x => x.RenameAsync( + accessContext, + routeRoleId, + "Operators", + ctx.RequestAborted)) + .Returns(Task.CompletedTask); + + await f.Sut.RenameRoleAsync(routeRoleId, ctx); + + f.Roles.Verify(x => x.RenameAsync( + accessContext, + routeRoleId, + "Operators", + ctx.RequestAborted), + Times.Once); + + f.Roles.Verify(x => x.RenameAsync( + It.IsAny(), + bodyRoleId, + It.IsAny(), + It.IsAny()), + Times.Never); + } + + // ========================================================= + // Fixture + // ========================================================= + + private sealed class Fixture + { + public UserKey UserKey { get; } = UserKey.New(); + + public Mock AuthFlow { get; } + = new(MockBehavior.Strict); + + public Mock Authorization { get; } + = new(MockBehavior.Strict); + + public Mock UserRoles { get; } + = new(MockBehavior.Strict); + + public Mock Roles { get; } + = new(MockBehavior.Strict); + + public Mock AccessContextFactory { get; } + = new(MockBehavior.Strict); + + public AuthorizationEndpointHandler Sut { get; } + + public Fixture(bool isAuthenticated = true) + { + var flow = AuthFlowTestFactory.New( + isAuthenticated: isAuthenticated, + userKey: isAuthenticated ? UserKey : null); + + AuthFlow + .SetupGet(x => x.Current) + .Returns(flow); + + Sut = new AuthorizationEndpointHandler( + AuthFlow.Object, + Authorization.Object, + UserRoles.Object, + Roles.Object, + AccessContextFactory.Object); + } + + public DefaultHttpContext Http() + { + var ctx = new DefaultHttpContext(); + ctx.Response.Body = new MemoryStream(); + return ctx; + } + + public DefaultHttpContext Json(T value) + { + var ctx = Http(); + + var bytes = JsonSerializer.SerializeToUtf8Bytes(value); + + ctx.Request.ContentType = "application/json"; + ctx.Request.Body = new MemoryStream(bytes); + ctx.Request.ContentLength = bytes.Length; + + return ctx; + } + + public AccessContext AccessContext(string action) + => TestAccessContext.WithAction(action); + + public void SetupAccessContext( + string action, + string resource, + string? resourceId, + AccessContext result) + { + AccessContextFactory + .Setup(x => x.CreateAsync( + It.IsAny(), + action, + resource, + resourceId, + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(result); + } + } + + private static PagedResult EmptyRoles() + => new( + [], + 0, + 1, + 250, + null, + false); + + private static void AssertStatusCode( + IResult result, + int expectedStatusCode) + { + result.Should().BeAssignableTo(); + + var statusResult = (IStatusCodeHttpResult)result; + + statusResult.StatusCode.Should().Be(expectedStatusCode); + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationServiceTests.cs new file mode 100644 index 00000000..6e0fe9c7 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/AuthorizationServiceTests.cs @@ -0,0 +1,122 @@ +ο»Ώusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Reference; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class AuthorizationServiceTests +{ + [Fact] + public async Task AuthorizeAsync_WhenAccessOrchestratorAllows_ReturnsAllowed() + { + var f = new Fixture(); + var context = TestAccessContext.WithAction("orders.read"); + + f.AccessOrchestrator + .Setup(x => x.ExecuteAsync( + context, + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>( + async (_, command, ct) => await command.ExecuteAsync(ct)); + + var result = await f.Sut.AuthorizeAsync(context); + + result.IsAllowed.Should().BeTrue(); + result.RequiresReauthentication.Should().BeFalse(); + result.DenyReason.Should().BeNull(); + + f.AccessOrchestrator.Verify(x => x.ExecuteAsync( + context, + It.IsAny>(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task AuthorizeAsync_WhenAccessOrchestratorDenies_ReturnsDenied() + { + var f = new Fixture(); + var context = TestAccessContext.WithAction("orders.delete"); + + var exception = new UAuthAuthorizationException( + "access_denied"); + + f.AccessOrchestrator + .Setup(x => x.ExecuteAsync( + context, + It.IsAny>(), + It.IsAny())) + .ThrowsAsync(exception); + + var result = await f.Sut.AuthorizeAsync(context); + + result.IsAllowed.Should().BeFalse(); + result.RequiresReauthentication.Should().BeFalse(); + + // This deliberately tests CURRENT service behavior. + result.DenyReason.Should().Be(exception.Message); + } + + [Fact] + public async Task AuthorizeAsync_WhenUnexpectedExceptionOccurs_DoesNotSwallowException() + { + var f = new Fixture(); + var context = TestAccessContext.WithAction("orders.read"); + + var expected = new InvalidOperationException( + "store unavailable"); + + f.AccessOrchestrator + .Setup(x => x.ExecuteAsync( + context, + It.IsAny>(), + It.IsAny())) + .ThrowsAsync(expected); + + var act = () => f.Sut.AuthorizeAsync(context); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Should().BeSameAs(expected); + } + + [Fact] + public async Task AuthorizeAsync_WhenAlreadyCancelled_ThrowsBeforeAccessExecution() + { + var f = new Fixture(); + var context = TestAccessContext.WithAction("orders.read"); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => f.Sut.AuthorizeAsync( + context, + cts.Token); + + await act.Should() + .ThrowAsync(); + + f.AccessOrchestrator.VerifyNoOtherCalls(); + } + + private sealed class Fixture + { + public Mock AccessOrchestrator { get; } + = new(MockBehavior.Strict); + + public AuthorizationService Sut { get; } + + public Fixture() + { + Sut = new AuthorizationService( + AccessOrchestrator.Object); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/RoleServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/RoleServiceTests.cs new file mode 100644 index 00000000..6c01265b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/RoleServiceTests.cs @@ -0,0 +1,529 @@ +ο»Ώusing CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Reference; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class RoleServiceTests +{ + private static readonly DateTimeOffset Now = + new(2026, 9, 21, 12, 0, 0, TimeSpan.Zero); + + // ========================================================= + // Create + // ========================================================= + + [Fact] + public async Task CreateAsync_WhenValid_CreatesAndPersistsRole() + { + var f = new Fixture(); + var context = f.Context("roles.create"); + + var permissions = new[] + { + Permission.From("users.read"), + Permission.From("users.write") + }; + + Role? captured = null; + + f.RoleStore + .Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .Callback((role, _) => captured = role) + .Returns(Task.CompletedTask); + + var result = await f.Sut.CreateAsync( + context, + " Administrators ", + permissions); + + result.Should().BeSameAs(captured); + + captured.Should().NotBeNull(); + captured!.Tenant.Should().Be(context.ResourceTenant); + captured.Name.Should().Be("Administrators"); + captured.NormalizedName.Should().Be("ADMINISTRATORS"); + captured.CreatedAt.Should().Be(Now); + captured.Permissions.Should().BeEquivalentTo(permissions); + + f.RoleFactory.Verify( + x => x.Create(context.ResourceTenant), + Times.Once); + } + + // ========================================================= + // Rename + // ========================================================= + + [Fact] + public async Task RenameAsync_WhenRoleDoesNotExist_ThrowsNotFound() + { + var f = new Fixture(); + var context = f.Context("roles.rename"); + var roleId = RoleId.New(); + + f.RoleStore + .Setup(x => x.GetAsync( + new RoleKey(context.ResourceTenant, roleId), + It.IsAny())) + .ReturnsAsync((Role?)null); + + var act = () => f.Sut.RenameAsync( + context, + roleId, + "New Name"); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should().Be("role_not_found"); + + f.RoleStore.Verify( + x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RenameAsync_WhenRoleIsDeleted_ThrowsNotFound() + { + var f = new Fixture(); + var context = f.Context("roles.rename"); + var role = f.Role("Old Name", version: 4); + + role.MarkDeleted(Now.AddMinutes(-1)); + + f.SetupGetRole(context, role); + + var act = () => f.Sut.RenameAsync( + context, + role.Id, + "New Name"); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should().Be("role_not_found"); + + f.RoleStore.Verify( + x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RenameAsync_WhenValid_RenamesAndSavesWithOriginalVersion() + { + var f = new Fixture(); + var context = f.Context("roles.rename"); + var role = f.Role("Old Name", version: 7); + + f.SetupGetRole(context, role); + + f.RoleStore + .Setup(x => x.SaveAsync( + role, + 7, + It.IsAny())) + .Returns(Task.CompletedTask); + + await f.Sut.RenameAsync( + context, + role.Id, + " New Name "); + + role.Name.Should().Be("New Name"); + role.NormalizedName.Should().Be("NEW NAME"); + role.UpdatedAt.Should().Be(Now); + + f.RoleStore.Verify( + x => x.SaveAsync( + role, + 7, + It.IsAny()), + Times.Once); + } + + // ========================================================= + // Delete + // ========================================================= + + [Fact] + public async Task DeleteAsync_WhenRoleDoesNotExist_ThrowsNotFound() + { + var f = new Fixture(); + var context = f.Context("roles.delete"); + var roleId = RoleId.New(); + + f.RoleStore + .Setup(x => x.GetAsync( + new RoleKey(context.ResourceTenant, roleId), + It.IsAny())) + .ReturnsAsync((Role?)null); + + var act = () => f.Sut.DeleteAsync( + context, + roleId, + DeleteMode.Soft); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should().Be("role_not_found"); + + f.UserRoleStore.VerifyNoOtherCalls(); + } + + [Theory] + [InlineData(DeleteMode.Soft)] + [InlineData(DeleteMode.Hard)] + public async Task DeleteAsync_WhenValid_RemovesAssignmentsAndUsesRequestedMode( + DeleteMode mode) + { + var f = new Fixture(); + var context = f.Context("roles.delete"); + var role = f.Role("Admin", version: 9); + + f.SetupGetRole(context, role); + + f.UserRoleStore + .Setup(x => x.CountAssignmentsAsync( + role.Id, + It.IsAny())) + .ReturnsAsync(13); + + f.UserRoleStore + .Setup(x => x.RemoveAssignmentsByRoleAsync( + role.Id, + It.IsAny())) + .Returns(Task.CompletedTask); + + f.RoleStore + .Setup(x => x.DeleteAsync( + new RoleKey(context.ResourceTenant, role.Id), + 9, + mode, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + var result = await f.Sut.DeleteAsync( + context, + role.Id, + mode); + + result.RoleId.Should().Be(role.Id); + result.RemovedAssignments.Should().Be(13); + result.Mode.Should().Be(mode); + result.DeletedAt.Should().Be(Now); + + f.UserRoleStore.Verify( + x => x.RemoveAssignmentsByRoleAsync( + role.Id, + It.IsAny()), + Times.Once); + + f.RoleStore.Verify( + x => x.DeleteAsync( + new RoleKey(context.ResourceTenant, role.Id), + 9, + mode, + Now, + It.IsAny()), + Times.Once); + } + + // ========================================================= + // Set Permissions + // ========================================================= + + [Fact] + public async Task SetPermissionsAsync_WhenRoleDoesNotExist_ThrowsNotFound() + { + var f = new Fixture(); + var context = f.Context("roles.permissions.set"); + var roleId = RoleId.New(); + + f.RoleStore + .Setup(x => x.GetAsync( + new RoleKey(context.ResourceTenant, roleId), + It.IsAny())) + .ReturnsAsync((Role?)null); + + var act = () => f.Sut.SetPermissionsAsync( + context, + roleId, + [Permission.From("users.read")]); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should().Be("role_not_found"); + } + + [Fact] + public async Task SetPermissionsAsync_WhenRoleIsDeleted_ThrowsNotFound() + { + var f = new Fixture(); + var context = f.Context("roles.permissions.set"); + var role = f.Role("Admin", version: 3); + + role.MarkDeleted(Now.AddMinutes(-1)); + + f.SetupGetRole(context, role); + + var act = () => f.Sut.SetPermissionsAsync( + context, + role.Id, + [Permission.From("users.read")]); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should().Be("role_not_found"); + } + + [Fact] + public async Task SetPermissionsAsync_WhenValid_UpdatesAndSavesWithOriginalVersion() + { + var f = new Fixture(); + var context = f.Context("roles.permissions.set"); + + var role = f.Role("Admin", version: 12); + + f.SetupGetRole(context, role); + + f.RoleStore + .Setup(x => x.SaveAsync( + role, + 12, + It.IsAny())) + .Returns(Task.CompletedTask); + + var permissions = new[] + { + Permission.From("users.read") + }; + + await f.Sut.SetPermissionsAsync( + context, + role.Id, + permissions); + + role.Permissions.Should().ContainSingle().Which.Should().Be(Permission.From("users.read")); + + role.UpdatedAt.Should().Be(Now); + + f.RoleStore.Verify( + x => x.SaveAsync( + role, + 12, + It.IsAny()), + Times.Once); + } + + // ========================================================= + // Query + // ========================================================= + + [Fact] + public async Task QueryAsync_ForwardsQueryAndReturnsStoreResult() + { + var f = new Fixture(); + var context = f.Context("roles.list"); + + var query = new RoleQuery + { + Search = "admin", + IncludeDeleted = true, + PageNumber = 2, + PageSize = 25, + SortBy = "name", + Descending = true + }; + + var roles = new[] + { + f.Role("Admin"), + f.Role("Super Admin") + }; + + var expected = new PagedResult( + roles, + totalCount: 42, + pageNumber: 2, + pageSize: 25, + sortBy: "name", + descending: true); + + f.RoleStore + .Setup(x => x.QueryAsync( + query, + It.IsAny())) + .ReturnsAsync(expected); + + var result = await f.Sut.QueryAsync(context, query); + + result.Should().BeSameAs(expected); + + f.RoleFactory.Verify( + x => x.Create(context.ResourceTenant), + Times.Once); + } + + // ========================================================= + // Cancellation + // ========================================================= + + [Fact] + public async Task CreateAsync_WhenAlreadyCancelled_ThrowsBeforeAccessExecution() + { + var f = new Fixture(); + var context = f.Context("roles.create"); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => f.Sut.CreateAsync( + context, + "Admin", + null, + cts.Token); + + await act.Should() + .ThrowAsync(); + + f.AccessOrchestrator.VerifyNoOtherCalls(); + f.RoleFactory.VerifyNoOtherCalls(); + } + + // ========================================================= + // Fixture + // ========================================================= + + private sealed class Fixture + { + public Mock AccessOrchestrator { get; } + = new(MockBehavior.Strict); + + public Mock RoleFactory { get; } + = new(MockBehavior.Strict); + + public Mock RoleStore { get; } + = new(MockBehavior.Strict); + + public Mock UserRoleFactory { get; } + = new(MockBehavior.Strict); + + public Mock UserRoleStore { get; } + = new(MockBehavior.Strict); + + public Mock Clock { get; } + = new(MockBehavior.Strict); + + public RoleService Sut { get; } + + public Fixture() + { + Clock + .SetupGet(x => x.UtcNow) + .Returns(Now); + + // AccessOrchestrator is deliberately transparent here. + // RoleService behavior is the subject under test. + AccessOrchestrator + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns( + async (_, command, ct) => + await command.ExecuteAsync(ct)); + + AccessOrchestrator + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>( + async (_, command, ct) => + await command.ExecuteAsync(ct)); + + AccessOrchestrator + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>( + async (_, command, ct) => + await command.ExecuteAsync(ct)); + + AccessOrchestrator + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Returns>, CancellationToken>( + async (_, command, ct) => + await command.ExecuteAsync(ct)); + + RoleFactory + .Setup(x => x.Create(It.IsAny())) + .Returns(RoleStore.Object); + + UserRoleFactory + .Setup(x => x.Create(It.IsAny())) + .Returns(UserRoleStore.Object); + + Sut = new RoleService( + AccessOrchestrator.Object, + RoleFactory.Object, + UserRoleFactory.Object, + Clock.Object); + } + + public AccessContext Context(string action) + => TestAccessContext.WithAction(action); + + public Role Role( + string name, + long version = 0, + IEnumerable? permissions = null) + { + var role = global::CodeBeam.UltimateAuth.Authorization.Role.Create( + RoleId.New(), + TenantKey.Single, + name, + permissions, + Now.AddHours(-1)); + + role.Version = version; + return role; + } + + public void SetupGetRole( + AccessContext context, + Role role) + { + RoleStore + .Setup(x => x.GetAsync( + new RoleKey(context.ResourceTenant, role.Id), + It.IsAny())) + .ReturnsAsync(role); + } + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/EfCoreRoleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/EfCoreRoleStoreContractTests.cs new file mode 100644 index 00000000..c16a3583 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/EfCoreRoleStoreContractTests.cs @@ -0,0 +1,65 @@ +ο»Ώusing CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Contracts.Authorization; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Authorization.Contracts; + +public sealed class EfCoreRoleStoreContractTests : RoleStoreContractTests +{ + protected override async Task CreateDatabaseAsync() + { + var database = new EfRoleStoreTestDatabase(); + await database.InitializeAsync(); + + return database; + } + + private sealed class EfRoleStoreTestDatabase : IRoleStoreTestDatabase + { + private readonly SqliteConnection _connection; + private readonly DbContextOptions _options; + private readonly List _contexts = []; + + public EfRoleStoreTestDatabase() + { + _connection = new SqliteConnection("Data Source=:memory:"); + + _options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + } + + public async Task InitializeAsync() + { + await _connection.OpenAsync(); + + await using var db = new UAuthAuthorizationDbContext(_options); + await db.Database.EnsureCreatedAsync(); + } + + public IRoleStore CreateStore(TenantKey tenant) + { + var db = new UAuthAuthorizationDbContext(_options); + + _contexts.Add(db); + + return new EfCoreRoleStore( + db, + new TenantExecutionContext(tenant)); + } + + public async ValueTask DisposeAsync() + { + foreach (var context in _contexts) + { + await context.DisposeAsync(); + } + + await _connection.DisposeAsync(); + } + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/EfCoreUserRoleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/EfCoreUserRoleStoreContractTests.cs new file mode 100644 index 00000000..c4ae4559 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/EfCoreUserRoleStoreContractTests.cs @@ -0,0 +1,74 @@ +ο»Ώusing CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Authorization.Contracts; + +public sealed class EfCoreUserRoleStoreContractTests : UserRoleStoreContractTests +{ + protected override async Task + CreateDatabaseAsync() + { + var database = new EfUserRoleStoreTestDatabase(); + + await database.InitializeAsync(); + + return database; + } + + private sealed class EfUserRoleStoreTestDatabase : IUserRoleStoreTestDatabase + { + private readonly SqliteConnection _connection; + + private readonly DbContextOptions + _options; + + private readonly List + _contexts = []; + + public EfUserRoleStoreTestDatabase() + { + _connection = + new SqliteConnection("Data Source=:memory:"); + + _options = + new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + } + + public async Task InitializeAsync() + { + await _connection.OpenAsync(); + + await using var db = + new UAuthAuthorizationDbContext(_options); + + await db.Database.EnsureCreatedAsync(); + } + + public IUserRoleStore CreateStore(TenantKey tenant) + { + var db = + new UAuthAuthorizationDbContext(_options); + + _contexts.Add(db); + + return new EfCoreUserRoleStore( + db, + new TenantExecutionContext(tenant)); + } + + public async ValueTask DisposeAsync() + { + foreach (var context in _contexts) + { + await context.DisposeAsync(); + } + + await _connection.DisposeAsync(); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/IRoleStoreTestDatabase.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/IRoleStoreTestDatabase.cs new file mode 100644 index 00000000..34cb42b3 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/IRoleStoreTestDatabase.cs @@ -0,0 +1,9 @@ +ο»Ώusing CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Tests.Contracts.Authorization; + +public interface IRoleStoreTestDatabase : IAsyncDisposable +{ + IRoleStore CreateStore(TenantKey tenant); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/IUserRoleStoreTestDatabase.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/IUserRoleStoreTestDatabase.cs new file mode 100644 index 00000000..a4bcb503 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/IUserRoleStoreTestDatabase.cs @@ -0,0 +1,9 @@ +ο»Ώusing CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Authorization.Contracts; + +public interface IUserRoleStoreTestDatabase : IAsyncDisposable +{ + IUserRoleStore CreateStore(TenantKey tenant); +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/InMemoryRoleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/InMemoryRoleStoreContractTests.cs new file mode 100644 index 00000000..e5029507 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/InMemoryRoleStoreContractTests.cs @@ -0,0 +1,31 @@ +ο»Ώusing CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.InMemory; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +namespace CodeBeam.UltimateAuth.Tests.Contracts.Authorization; + +public sealed class InMemoryRoleStoreContractTests : RoleStoreContractTests +{ + protected override Task CreateDatabaseAsync() + { + return Task.FromResult( + new InMemoryRoleStoreTestDatabase()); + } + + private sealed class InMemoryRoleStoreTestDatabase + : IRoleStoreTestDatabase + { + public IRoleStore CreateStore(TenantKey tenant) + { + var executionContext = + new TenantExecutionContext(tenant); + + return new InMemoryRoleStore( + executionContext); + } + + public ValueTask DisposeAsync() + => ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/InMemoryUserRoleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/InMemoryUserRoleStoreContractTests.cs new file mode 100644 index 00000000..08ed7007 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/InMemoryUserRoleStoreContractTests.cs @@ -0,0 +1,37 @@ +ο»Ώusing CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.InMemory; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Authorization.Contracts; + +public sealed class InMemoryUserRoleStoreContractTests : UserRoleStoreContractTests +{ + protected override Task + CreateDatabaseAsync() + { + return Task.FromResult( + new InMemoryUserRoleStoreTestDatabase()); + } + + private sealed class InMemoryUserRoleStoreTestDatabase : IUserRoleStoreTestDatabase + { + private readonly Dictionary + _stores = []; + + public IUserRoleStore CreateStore(TenantKey tenant) + { + if (_stores.TryGetValue(tenant, out var store)) + return store; + + store = new InMemoryUserRoleStore( + new TenantExecutionContext(tenant)); + + _stores.Add(tenant, store); + + return store; + } + + public ValueTask DisposeAsync() + => ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/RoleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/RoleStoreContractTests.cs new file mode 100644 index 00000000..04f647f1 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/RoleStoreContractTests.cs @@ -0,0 +1,564 @@ +ο»Ώusing CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Contracts.Authorization; + +public abstract class RoleStoreContractTests +{ + protected abstract Task CreateDatabaseAsync(); + + protected virtual TenantKey Tenant => + TenantKey.Single; + + // --------------------------------------------------------- + // Add / Get + // --------------------------------------------------------- + + [Fact] + public async Task AddAsync_WhenValid_PersistsRole() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var role = CreateRole("Administrators"); + + await store.AddAsync(role); + + var result = await store.GetAsync( + new RoleKey(Tenant, role.Id)); + + result.Should().NotBeNull(); + result!.Id.Should().Be(role.Id); + result.Tenant.Should().Be(Tenant); + result.Name.Should().Be(role.Name); + result.NormalizedName.Should().Be(role.NormalizedName); + result.Permissions.Should().BeEquivalentTo(role.Permissions); + } + + [Fact] + public async Task AddAsync_WhenActiveNormalizedNameAlreadyExists_ThrowsConflict() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var first = CreateRole("Administrators"); + var duplicate = CreateRole(" administrators "); + + await store.AddAsync(first); + + var act = () => store.AddAsync(duplicate); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task AddAsync_WhenDeletedRoleHasSameNormalizedName_ThrowsConflict() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var first = CreateRole("Administrators"); + + await store.AddAsync(first); + + await store.DeleteAsync( + new RoleKey(Tenant, first.Id), + first.Version, + DeleteMode.Soft, + Now); + + var replacement = CreateRole("Administrators"); + + var act = () => store.AddAsync(replacement); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task AddAsync_WhenPreviousRoleWasHardDeleted_AllowsNameReuse() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var first = CreateRole("Administrators"); + + await store.AddAsync(first); + + await store.DeleteAsync( + new RoleKey(Tenant, first.Id), + first.Version, + DeleteMode.Hard, + Now); + + var replacement = CreateRole("Administrators"); + + var act = () => store.AddAsync(replacement); + + await act.Should().NotThrowAsync(); + + var result = await store.GetByNameAsync( + replacement.NormalizedName); + + result.Should().NotBeNull(); + result!.Id.Should().Be(replacement.Id); + } + + // --------------------------------------------------------- + // Name lookup + // --------------------------------------------------------- + + [Fact] + public async Task GetByNameAsync_WhenRoleExists_ReturnsRole() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var role = CreateRole("Administrators"); + + await store.AddAsync(role); + + var result = await store.GetByNameAsync( + role.NormalizedName); + + result.Should().NotBeNull(); + result!.Id.Should().Be(role.Id); + result.Name.Should().Be(role.Name); + } + + [Fact] + public async Task GetByNameAsync_WhenRoleIsDeleted_ReturnsNull() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var role = CreateRole("Administrators"); + + await store.AddAsync(role); + + await store.DeleteAsync( + new RoleKey(Tenant, role.Id), + role.Version, + DeleteMode.Soft, + Now); + + var result = await store.GetByNameAsync( + role.NormalizedName); + + result.Should().BeNull(); + } + + // --------------------------------------------------------- + // GetByIds + // --------------------------------------------------------- + + [Fact] + public async Task GetByIdsAsync_ReturnsOnlyRequestedRoles() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var admin = CreateRole("Administrators"); + var operatorRole = CreateRole("Operators"); + var unrelated = CreateRole("Auditors"); + + await store.AddAsync(admin); + await store.AddAsync(operatorRole); + await store.AddAsync(unrelated); + + var result = await store.GetByIdsAsync( + [admin.Id, operatorRole.Id]); + + result.Select(x => x.Id) + .Should() + .BeEquivalentTo([admin.Id, operatorRole.Id]); + + result.Should() + .NotContain(x => x.Id == unrelated.Id); + } + + [Fact] + public async Task GetByIdsAsync_DoesNotReturnDeletedRoles() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var active = CreateRole("Active"); + var deleted = CreateRole("Deleted"); + + await store.AddAsync(active); + await store.AddAsync(deleted); + + await store.DeleteAsync( + new RoleKey(Tenant, deleted.Id), + deleted.Version, + DeleteMode.Soft, + Now); + + var result = await store.GetByIdsAsync( + [active.Id, deleted.Id]); + + result.Should().ContainSingle(); + result.Single().Id.Should().Be(active.Id); + } + + // --------------------------------------------------------- + // Save / concurrency + // --------------------------------------------------------- + + [Fact] + public async Task SaveAsync_WhenRoleIsChanged_PersistsChanges() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var role = CreateRole("Administrators"); + + await store.AddAsync(role); + + var persisted = await store.GetAsync( + new RoleKey(Tenant, role.Id)); + + persisted.Should().NotBeNull(); + + var expectedVersion = persisted!.Version; + + persisted.Rename("Operators", Now.AddMinutes(1)); + + await store.SaveAsync( + persisted, + expectedVersion); + + var result = await store.GetAsync( + new RoleKey(Tenant, role.Id)); + + result.Should().NotBeNull(); + result!.Name.Should().Be("Operators"); + result.Version.Should().BeGreaterThan(expectedVersion); + } + + [Fact] + public async Task SaveAsync_WhenExpectedVersionIsStale_ThrowsConcurrency() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var role = CreateRole("Administrators"); + + await store.AddAsync(role); + + var persisted = await store.GetAsync( + new RoleKey(Tenant, role.Id)); + + persisted.Should().NotBeNull(); + + persisted!.Rename( + "Operators", + Now.AddMinutes(1)); + + var staleVersion = persisted.Version + 100; + + var act = () => store.SaveAsync( + persisted, + staleVersion); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task SaveAsync_WhenRenamedToExistingActiveRole_ThrowsConflict() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var admin = CreateRole("Administrators"); + var operators = CreateRole("Operators"); + + await store.AddAsync(admin); + await store.AddAsync(operators); + + var persisted = await store.GetAsync( + new RoleKey(Tenant, operators.Id)); + + persisted.Should().NotBeNull(); + + var expectedVersion = persisted!.Version; + + persisted.Rename( + "Administrators", + Now.AddMinutes(1)); + + var act = () => store.SaveAsync( + persisted, + expectedVersion); + + await act.Should() + .ThrowAsync(); + } + + // --------------------------------------------------------- + // Delete + // --------------------------------------------------------- + + [Fact] + public async Task DeleteAsync_WhenSoftDeleted_KeepsRoleButMarksDeleted() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var role = CreateRole("Administrators"); + + await store.AddAsync(role); + + var deletedAt = Now.AddMinutes(1); + + await store.DeleteAsync( + new RoleKey(Tenant, role.Id), + role.Version, + DeleteMode.Soft, + deletedAt); + + var result = await store.GetAsync( + new RoleKey(Tenant, role.Id)); + + result.Should().NotBeNull(); + result!.IsDeleted.Should().BeTrue(); + result.DeletedAt.Should().Be(deletedAt); + } + + [Fact] + public async Task DeleteAsync_WhenHardDeleted_RemovesRole() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var role = CreateRole("Administrators"); + + await store.AddAsync(role); + + await store.DeleteAsync( + new RoleKey(Tenant, role.Id), + role.Version, + DeleteMode.Hard, + Now); + + var result = await store.GetAsync( + new RoleKey(Tenant, role.Id)); + + result.Should().BeNull(); + + var exists = await store.ExistsAsync( + new RoleKey(Tenant, role.Id)); + + exists.Should().BeFalse(); + } + + [Fact] + public async Task DeleteAsync_WhenExpectedVersionIsStale_ThrowsConcurrency() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var role = CreateRole("Administrators"); + + await store.AddAsync(role); + + var act = () => store.DeleteAsync( + new RoleKey(Tenant, role.Id), + role.Version + 100, + DeleteMode.Soft, + Now); + + await act.Should() + .ThrowAsync(); + } + + // --------------------------------------------------------- + // Query + // --------------------------------------------------------- + + [Fact] + public async Task QueryAsync_WhenIncludeDeletedIsFalse_ExcludesDeletedRoles() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var active = CreateRole("Active"); + var deleted = CreateRole("Deleted"); + + await store.AddAsync(active); + await store.AddAsync(deleted); + + await store.DeleteAsync( + new RoleKey(Tenant, deleted.Id), + deleted.Version, + DeleteMode.Soft, + Now); + + var result = await store.QueryAsync( + new RoleQuery + { + IncludeDeleted = false + }); + + result.Items.Should() + .Contain(x => x.Id == active.Id); + + result.Items.Should() + .NotContain(x => x.Id == deleted.Id); + } + + [Fact] + public async Task QueryAsync_WhenIncludeDeletedIsTrue_IncludesDeletedRoles() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var active = CreateRole("Active"); + var deleted = CreateRole("Deleted"); + + await store.AddAsync(active); + await store.AddAsync(deleted); + + await store.DeleteAsync( + new RoleKey(Tenant, deleted.Id), + deleted.Version, + DeleteMode.Soft, + Now); + + var result = await store.QueryAsync( + new RoleQuery + { + IncludeDeleted = true + }); + + result.Items.Select(x => x.Id) + .Should() + .Contain([active.Id, deleted.Id]); + } + + [Fact] + public async Task QueryAsync_WhenSearchSpecified_SearchesNormalizedName() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var admin = CreateRole("Global Administrators"); + var operators = CreateRole("Operators"); + + await store.AddAsync(admin); + await store.AddAsync(operators); + + var result = await store.QueryAsync( + new RoleQuery + { + Search = " admin " + }); + + result.Items.Should().ContainSingle(); + result.Items.Single().Id.Should().Be(admin.Id); + } + + [Fact] + public async Task QueryAsync_SortsBeforeApplyingPagination() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + foreach (var name in new[] + { + "Charlie", + "Alpha", + "Echo", + "Bravo", + "Delta" + }) + { + await store.AddAsync(CreateRole(name)); + } + + var result = await store.QueryAsync( + new RoleQuery + { + PageNumber = 2, + PageSize = 2, + SortBy = nameof(Role.Name), + Descending = false + }); + + result.TotalCount.Should().Be(5); + result.PageNumber.Should().Be(2); + result.PageSize.Should().Be(2); + + result.Items + .Select(x => x.Name) + .Should() + .ContainInOrder("Charlie", "Delta"); + } + + // --------------------------------------------------------- + // Tenant isolation + // --------------------------------------------------------- + + [Fact] + public async Task Store_IsTenantIsolated() + { + await using var db = await CreateDatabaseAsync(); + + var tenantA = TestIds.Tenant("tenant-a"); + var tenantB = TestIds.Tenant("tenant-b"); + + var storeA = db.CreateStore(tenantA); + var storeB = db.CreateStore(tenantB); + + var roleA = CreateRole( + "Administrators", + tenantA); + + await storeA.AddAsync(roleA); + + var fromA = await storeA.GetAsync( + new RoleKey(tenantA, roleA.Id)); + + var fromB = await storeB.GetAsync( + new RoleKey(tenantB, roleA.Id)); + + fromA.Should().NotBeNull(); + fromB.Should().BeNull(); + + var queryB = await storeB.QueryAsync( + new RoleQuery + { + IncludeDeleted = true + }); + + queryB.Items.Should() + .NotContain(x => x.Id == roleA.Id); + } + + // --------------------------------------------------------- + // Helpers + // --------------------------------------------------------- + + protected static readonly DateTimeOffset Now = + new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); + + protected Role CreateRole( + string name, + TenantKey? tenant = null) + { + return Role.Create( + RoleId.New(), + tenant ?? Tenant, + name, + [], + Now); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/UserRoleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/UserRoleStoreContractTests.cs new file mode 100644 index 00000000..425cb690 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/Store/UserRoleStoreContractTests.cs @@ -0,0 +1,418 @@ +ο»Ώusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Authorization.Contracts; + +public abstract class UserRoleStoreContractTests +{ + protected abstract Task CreateDatabaseAsync(); + + protected virtual TenantKey Tenant => TenantKeys.Single; + + protected static readonly DateTimeOffset Now = + new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); + + // --------------------------------------------------------- + // Assign + // --------------------------------------------------------- + + [Fact] + public async Task AssignAsync_WhenValid_PersistsAssignment() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var user = UserKey.New(); + var roleId = RoleId.New(); + var assignedAt = Now.AddMinutes(-10); + + await store.AssignAsync(user, roleId, assignedAt); + + var result = await store.GetAssignmentsAsync(user); + + result.Should().ContainSingle(); + + var assignment = result.Single(); + + assignment.Tenant.Should().Be(Tenant); + assignment.UserKey.Should().Be(user); + assignment.RoleId.Should().Be(roleId); + assignment.AssignedAt.Should().Be(assignedAt); + } + + [Fact] + public async Task AssignAsync_WhenSameRoleAlreadyAssigned_ThrowsConflict() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var user = UserKey.New(); + var roleId = RoleId.New(); + + await store.AssignAsync(user, roleId, Now); + + var act = () => store.AssignAsync( + user, + roleId, + Now.AddMinutes(1)); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task AssignAsync_SameUserCanHaveMultipleRoles() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var user = UserKey.New(); + + var roleA = RoleId.New(); + var roleB = RoleId.New(); + + await store.AssignAsync(user, roleA, Now); + await store.AssignAsync(user, roleB, Now.AddMinutes(1)); + + var result = await store.GetAssignmentsAsync(user); + + result.Should().HaveCount(2); + + result.Select(x => x.RoleId) + .Should() + .BeEquivalentTo([roleA, roleB]); + } + + [Fact] + public async Task AssignAsync_SameRoleCanBeAssignedToMultipleUsers() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var userA = UserKey.New(); + var userB = UserKey.New(); + var roleId = RoleId.New(); + + await store.AssignAsync(userA, roleId, Now); + await store.AssignAsync(userB, roleId, Now.AddMinutes(1)); + + var assignmentsA = await store.GetAssignmentsAsync(userA); + var assignmentsB = await store.GetAssignmentsAsync(userB); + + assignmentsA.Should().ContainSingle(); + assignmentsB.Should().ContainSingle(); + + assignmentsA.Single().RoleId.Should().Be(roleId); + assignmentsB.Single().RoleId.Should().Be(roleId); + } + + // --------------------------------------------------------- + // GetAssignments + // --------------------------------------------------------- + + [Fact] + public async Task GetAssignmentsAsync_ReturnsOnlyRequestedUserAssignments() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var userA = UserKey.New(); + var userB = UserKey.New(); + + var roleA = RoleId.New(); + var roleB = RoleId.New(); + + await store.AssignAsync(userA, roleA, Now); + await store.AssignAsync(userB, roleB, Now); + + var result = await store.GetAssignmentsAsync(userA); + + result.Should().ContainSingle(); + result.Single().UserKey.Should().Be(userA); + result.Single().RoleId.Should().Be(roleA); + } + + [Fact] + public async Task GetAssignmentsAsync_WhenUserHasNoAssignments_ReturnsEmpty() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var result = await store.GetAssignmentsAsync(UserKey.New()); + + result.Should().BeEmpty(); + } + + // --------------------------------------------------------- + // Remove + // --------------------------------------------------------- + + [Fact] + public async Task RemoveAsync_WhenAssignmentExists_RemovesOnlyRequestedAssignment() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var user = UserKey.New(); + + var roleA = RoleId.New(); + var roleB = RoleId.New(); + + await store.AssignAsync(user, roleA, Now); + await store.AssignAsync(user, roleB, Now); + + await store.RemoveAsync(user, roleA); + + var result = await store.GetAssignmentsAsync(user); + + result.Should().ContainSingle(); + result.Single().RoleId.Should().Be(roleB); + } + + [Fact] + public async Task RemoveAsync_WhenAssignmentDoesNotExist_IsIdempotent() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var user = UserKey.New(); + var roleId = RoleId.New(); + + var act = () => store.RemoveAsync(user, roleId); + + await act.Should().NotThrowAsync(); + + var result = await store.GetAssignmentsAsync(user); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task RemoveAsync_DoesNotRemoveSameRoleFromOtherUser() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var userA = UserKey.New(); + var userB = UserKey.New(); + var roleId = RoleId.New(); + + await store.AssignAsync(userA, roleId, Now); + await store.AssignAsync(userB, roleId, Now); + + await store.RemoveAsync(userA, roleId); + + var assignmentsA = await store.GetAssignmentsAsync(userA); + var assignmentsB = await store.GetAssignmentsAsync(userB); + + assignmentsA.Should().BeEmpty(); + + assignmentsB.Should().ContainSingle(); + assignmentsB.Single().RoleId.Should().Be(roleId); + } + + // --------------------------------------------------------- + // Remove by role + // --------------------------------------------------------- + + [Fact] + public async Task RemoveAssignmentsByRoleAsync_RemovesRoleFromAllUsers() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var userA = UserKey.New(); + var userB = UserKey.New(); + + var targetRole = RoleId.New(); + var otherRole = RoleId.New(); + + await store.AssignAsync(userA, targetRole, Now); + await store.AssignAsync(userA, otherRole, Now); + await store.AssignAsync(userB, targetRole, Now); + + await store.RemoveAssignmentsByRoleAsync(targetRole); + + var assignmentsA = await store.GetAssignmentsAsync(userA); + var assignmentsB = await store.GetAssignmentsAsync(userB); + + assignmentsA.Should().ContainSingle(); + assignmentsA.Single().RoleId.Should().Be(otherRole); + + assignmentsB.Should().BeEmpty(); + } + + [Fact] + public async Task RemoveAssignmentsByRoleAsync_WhenNoAssignmentsExist_IsIdempotent() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var act = () => + store.RemoveAssignmentsByRoleAsync(RoleId.New()); + + await act.Should().NotThrowAsync(); + } + + // --------------------------------------------------------- + // Count + // --------------------------------------------------------- + + [Fact] + public async Task CountAssignmentsAsync_ReturnsNumberOfAssignmentsForRole() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var roleA = RoleId.New(); + var roleB = RoleId.New(); + + await store.AssignAsync(UserKey.New(), roleA, Now); + await store.AssignAsync(UserKey.New(), roleA, Now); + await store.AssignAsync(UserKey.New(), roleA, Now); + await store.AssignAsync(UserKey.New(), roleB, Now); + + var result = await store.CountAssignmentsAsync(roleA); + + result.Should().Be(3); + } + + [Fact] + public async Task CountAssignmentsAsync_WhenNoAssignmentsExist_ReturnsZero() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var result = + await store.CountAssignmentsAsync(RoleId.New()); + + result.Should().Be(0); + } + + // --------------------------------------------------------- + // Tenant isolation + // --------------------------------------------------------- + + [Fact] + public async Task GetAssignmentsAsync_IsTenantIsolated() + { + await using var db = await CreateDatabaseAsync(); + + var tenantA = TestIds.Tenant("tenant-a"); + var tenantB = TestIds.Tenant("tenant-b"); + + var storeA = db.CreateStore(tenantA); + var storeB = db.CreateStore(tenantB); + + var user = UserKey.New(); + var roleId = RoleId.New(); + + await storeA.AssignAsync(user, roleId, Now); + + var fromA = await storeA.GetAssignmentsAsync(user); + var fromB = await storeB.GetAssignmentsAsync(user); + + fromA.Should().ContainSingle(); + fromB.Should().BeEmpty(); + } + + [Fact] + public async Task CountAssignmentsAsync_IsTenantIsolated() + { + await using var db = await CreateDatabaseAsync(); + + var tenantA = TestIds.Tenant("tenant-a"); + var tenantB = TestIds.Tenant("tenant-b"); + + var storeA = db.CreateStore(tenantA); + var storeB = db.CreateStore(tenantB); + + var roleId = RoleId.New(); + + await storeA.AssignAsync(UserKey.New(), roleId, Now); + await storeA.AssignAsync(UserKey.New(), roleId, Now); + + await storeB.AssignAsync(UserKey.New(), roleId, Now); + + var countA = await storeA.CountAssignmentsAsync(roleId); + var countB = await storeB.CountAssignmentsAsync(roleId); + + countA.Should().Be(2); + countB.Should().Be(1); + } + + [Fact] + public async Task RemoveAssignmentsByRoleAsync_IsTenantIsolated() + { + await using var db = await CreateDatabaseAsync(); + + var tenantA = TestIds.Tenant("tenant-a"); + var tenantB = TestIds.Tenant("tenant-b"); + + var storeA = db.CreateStore(tenantA); + var storeB = db.CreateStore(tenantB); + + var roleId = RoleId.New(); + + var userA = UserKey.New(); + var userB = UserKey.New(); + + await storeA.AssignAsync(userA, roleId, Now); + await storeB.AssignAsync(userB, roleId, Now); + + await storeA.RemoveAssignmentsByRoleAsync(roleId); + + var assignmentsA = + await storeA.GetAssignmentsAsync(userA); + + var assignmentsB = + await storeB.GetAssignmentsAsync(userB); + + assignmentsA.Should().BeEmpty(); + + assignmentsB.Should().ContainSingle(); + assignmentsB.Single().RoleId.Should().Be(roleId); + } + + // --------------------------------------------------------- + // Cancellation + // --------------------------------------------------------- + + [Fact] + public async Task Operations_WhenCancellationRequested_ThrowOperationCanceledException() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + var user = UserKey.New(); + var roleId = RoleId.New(); + + var get = () => + store.GetAssignmentsAsync(user, cts.Token); + + var assign = () => + store.AssignAsync(user, roleId, Now, cts.Token); + + var remove = () => + store.RemoveAsync(user, roleId, cts.Token); + + var removeByRole = () => + store.RemoveAssignmentsByRoleAsync(roleId, cts.Token); + + var count = () => + store.CountAssignmentsAsync(roleId, cts.Token); + + await get.Should().ThrowAsync(); + await assign.Should().ThrowAsync(); + await remove.Should().ThrowAsync(); + await removeByRole.Should().ThrowAsync(); + await count.Should().ThrowAsync(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserRoleServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserRoleServiceTests.cs new file mode 100644 index 00000000..54ee688b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UserRoleServiceTests.cs @@ -0,0 +1,996 @@ +ο»Ώusing CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Reference; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class UserRoleServiceTests +{ + private static readonly DateTimeOffset Now = + new(2026, 9, 21, 12, 0, 0, TimeSpan.Zero); + + // ========================================================= + // Assign + // ========================================================= + + [Fact] + public async Task AssignAsync_WhenRoleExists_NormalizesRoleNameAndAssignsTargetUser() + { + var f = new Fixture(); + var context = f.Context("roles.assign"); + var target = UserKey.New(); + + var role = f.Role("Administrators"); + + f.RoleStore + .Setup(x => x.GetByNameAsync( + "ADMINISTRATORS", + It.IsAny())) + .ReturnsAsync(role); + + f.UserRoleStore + .Setup(x => x.AssignAsync( + target, + role.Id, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + await f.Sut.AssignAsync( + context, + target, + " administrators "); + + f.RoleStore.Verify(x => x.GetByNameAsync( + "ADMINISTRATORS", + It.IsAny()), + Times.Once); + + f.UserRoleStore.Verify(x => x.AssignAsync( + target, + role.Id, + Now, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task AssignAsync_WhenRoleDoesNotExist_ThrowsNotFound() + { + var f = new Fixture(); + var context = f.Context("roles.assign"); + var target = UserKey.New(); + + f.RoleStore + .Setup(x => x.GetByNameAsync( + "MISSING", + It.IsAny())) + .ReturnsAsync((Role?)null); + + var act = () => f.Sut.AssignAsync( + context, + target, + "missing"); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should().Be("role_not_found"); + + f.UserRoleStore.Verify( + x => x.AssignAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task AssignAsync_WhenRoleIsDeleted_ThrowsNotFound() + { + var f = new Fixture(); + var context = f.Context("roles.assign"); + var target = UserKey.New(); + + var role = f.Role("Admin"); + role.MarkDeleted(Now.AddMinutes(-1)); + + f.RoleStore + .Setup(x => x.GetByNameAsync( + "ADMIN", + It.IsAny())) + .ReturnsAsync(role); + + var act = () => f.Sut.AssignAsync( + context, + target, + "admin"); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should().Be("role_not_found"); + + f.UserRoleStore.Verify( + x => x.AssignAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task AssignAsync_UsesResourceTenantForBothStores() + { + var f = new Fixture(); + var context = f.Context("roles.assign"); + var target = UserKey.New(); + var role = f.Role("Admin"); + + f.RoleStore + .Setup(x => x.GetByNameAsync( + "ADMIN", + It.IsAny())) + .ReturnsAsync(role); + + f.UserRoleStore + .Setup(x => x.AssignAsync( + target, + role.Id, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + await f.Sut.AssignAsync( + context, + target, + "Admin"); + + f.RoleFactory.Verify( + x => x.Create(context.ResourceTenant), + Times.Once); + + f.UserRoleFactory.Verify( + x => x.Create(context.ResourceTenant), + Times.Once); + } + + // ========================================================= + // Remove + // ========================================================= + + [Fact] + public async Task RemoveAsync_WhenRoleExists_NormalizesRoleNameAndRemovesAssignment() + { + var f = new Fixture(); + var context = f.Context("roles.remove"); + var target = UserKey.New(); + + var role = f.Role("Operators"); + + f.RoleStore + .Setup(x => x.GetByNameAsync( + "OPERATORS", + It.IsAny())) + .ReturnsAsync(role); + + f.UserRoleStore + .Setup(x => x.RemoveAsync( + target, + role.Id, + It.IsAny())) + .Returns(Task.CompletedTask); + + await f.Sut.RemoveAsync( + context, + target, + " operators "); + + f.UserRoleStore.Verify(x => x.RemoveAsync( + target, + role.Id, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task RemoveAsync_WhenRoleDoesNotExist_IsIdempotent() + { + var f = new Fixture(); + var context = f.Context("roles.remove"); + var target = UserKey.New(); + + f.RoleStore + .Setup(x => x.GetByNameAsync( + "MISSING", + It.IsAny())) + .ReturnsAsync((Role?)null); + + var act = () => f.Sut.RemoveAsync( + context, + target, + "missing"); + + await act.Should().NotThrowAsync(); + + f.UserRoleStore.Verify( + x => x.RemoveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RemoveAsync_WhenRoleIsDeleted_StillRemovesAssignment() + { + var f = new Fixture(); + var context = f.Context("roles.remove"); + var target = UserKey.New(); + + var role = f.Role("Legacy"); + role.MarkDeleted(Now.AddMinutes(-5)); + + f.RoleStore + .Setup(x => x.GetByNameAsync( + "LEGACY", + It.IsAny())) + .ReturnsAsync(role); + + f.UserRoleStore + .Setup(x => x.RemoveAsync( + target, + role.Id, + It.IsAny())) + .Returns(Task.CompletedTask); + + await f.Sut.RemoveAsync( + context, + target, + "legacy"); + + f.UserRoleStore.Verify(x => x.RemoveAsync( + target, + role.Id, + It.IsAny()), + Times.Once); + } + + // ========================================================= + // GetRoles + // ========================================================= + + [Fact] + public async Task GetRolesAsync_WhenAssignmentsExist_JoinsAssignmentsWithRoles() + { + var f = new Fixture(); + var context = f.Context("roles.list"); + var target = UserKey.New(); + + var admin = f.Role("Admin"); + var auditor = f.Role("Auditor"); + + var adminAssignedAt = Now.AddDays(-10); + var auditorAssignedAt = Now.AddDays(-5); + + var assignments = new[] + { + f.Assignment(target, admin.Id, adminAssignedAt), + f.Assignment(target, auditor.Id, auditorAssignedAt) + }; + + f.UserRoleStore + .Setup(x => x.GetAssignmentsAsync( + target, + It.IsAny())) + .ReturnsAsync(assignments); + + f.RoleStore + .Setup(x => x.GetByIdsAsync( + It.Is>(ids => + ids.Count == 2 && + ids.Contains(admin.Id) && + ids.Contains(auditor.Id)), + It.IsAny())) + .ReturnsAsync(new[] { admin, auditor }); + + var result = await f.Sut.GetRolesAsync( + context, + target, + new PageRequest()); + + result.TotalCount.Should().Be(2); + result.Items.Should().HaveCount(2); + + result.Items.Should().ContainEquivalentOf( + new UserRoleInfo + { + Tenant = context.ResourceTenant, + UserKey = target, + RoleId = admin.Id, + Name = "Admin", + AssignedAt = adminAssignedAt + }); + + result.Items.Should().ContainEquivalentOf( + new UserRoleInfo + { + Tenant = context.ResourceTenant, + UserKey = target, + RoleId = auditor.Id, + Name = "Auditor", + AssignedAt = auditorAssignedAt + }); + } + + [Fact] + public async Task GetRolesAsync_WhenAssignmentReferencesMissingRole_IgnoresOrphanAssignment() + { + var f = new Fixture(); + var context = f.Context("roles.list"); + var target = UserKey.New(); + + var existingRole = f.Role("Admin"); + var missingRoleId = RoleId.New(); + + var assignments = new[] + { + f.Assignment( + target, + existingRole.Id, + Now.AddDays(-2)), + + f.Assignment( + target, + missingRoleId, + Now.AddDays(-1)) + }; + + f.UserRoleStore + .Setup(x => x.GetAssignmentsAsync( + target, + It.IsAny())) + .ReturnsAsync(assignments); + + f.RoleStore + .Setup(x => x.GetByIdsAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new[] { existingRole }); + + var result = await f.Sut.GetRolesAsync( + context, + target, + new PageRequest()); + + result.TotalCount.Should().Be(1); + result.Items.Should().ContainSingle(); + + result.Items[0].RoleId.Should().Be(existingRole.Id); + result.Items[0].Name.Should().Be("Admin"); + } + + [Fact] + public async Task GetRolesAsync_AppliesPaginationAfterJoin() + { + var f = new Fixture(); + var context = f.Context("roles.list"); + var target = UserKey.New(); + + var role1 = f.Role("Role 1"); + var role2 = f.Role("Role 2"); + var role3 = f.Role("Role 3"); + + var assignments = new[] + { + f.Assignment(target, role1.Id, Now.AddDays(-3)), + f.Assignment(target, role2.Id, Now.AddDays(-2)), + f.Assignment(target, role3.Id, Now.AddDays(-1)) + }; + + f.UserRoleStore + .Setup(x => x.GetAssignmentsAsync( + target, + It.IsAny())) + .ReturnsAsync(assignments); + + f.RoleStore + .Setup(x => x.GetByIdsAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new[] { role1, role2, role3 }); + + var result = await f.Sut.GetRolesAsync( + context, + target, + new PageRequest + { + PageNumber = 2, + PageSize = 1 + }); + + result.TotalCount.Should().Be(3); + result.PageNumber.Should().Be(2); + result.PageSize.Should().Be(1); + + result.Items.Should().ContainSingle(); + result.Items[0].RoleId.Should().Be(role2.Id); + + result.HasNext.Should().BeTrue(); + } + + [Fact] + public async Task GetRolesAsync_NormalizesInvalidPagingValues() + { + var f = new Fixture(); + var context = f.Context("roles.list"); + var target = UserKey.New(); + + f.UserRoleStore + .Setup(x => x.GetAssignmentsAsync( + target, + It.IsAny())) + .ReturnsAsync(Array.Empty()); + + f.RoleStore + .Setup(x => x.GetByIdsAsync( + It.Is>(ids => ids.Count == 0), + It.IsAny())) + .ReturnsAsync(Array.Empty()); + + var result = await f.Sut.GetRolesAsync( + context, + target, + new PageRequest + { + PageNumber = -10, + PageSize = 0 + }); + + result.PageNumber.Should().Be(1); + result.PageSize.Should().Be(250); + result.TotalCount.Should().Be(0); + result.Items.Should().BeEmpty(); + } + + [Fact] + public async Task GetRolesAsync_WhenPageSizeExceedsMaximum_ClampsPageSize() + { + var f = new Fixture(); + var context = f.Context("roles.list"); + var target = UserKey.New(); + + f.UserRoleStore + .Setup(x => x.GetAssignmentsAsync( + target, + It.IsAny())) + .ReturnsAsync(Array.Empty()); + + f.RoleStore + .Setup(x => x.GetByIdsAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(Array.Empty()); + + var result = await f.Sut.GetRolesAsync( + context, + target, + new PageRequest + { + PageNumber = 1, + PageSize = 5000, + MaxPageSize = 100 + }); + + result.PageSize.Should().Be(100); + } + + [Fact] + public async Task GetRolesAsync_PreservesPagingMetadata() + { + var f = new Fixture(); + var context = f.Context("roles.list"); + var target = UserKey.New(); + + f.UserRoleStore + .Setup(x => x.GetAssignmentsAsync( + target, + It.IsAny())) + .ReturnsAsync(Array.Empty()); + + f.RoleStore + .Setup(x => x.GetByIdsAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(Array.Empty()); + + var result = await f.Sut.GetRolesAsync( + context, + target, + new PageRequest + { + PageNumber = 3, + PageSize = 20, + SortBy = "name", + Descending = true + }); + + result.PageNumber.Should().Be(3); + result.PageSize.Should().Be(20); + result.SortBy.Should().Be("name"); + result.Descending.Should().BeTrue(); + } + + [Fact] + public async Task GetRolesAsync_UsesResourceTenantForBothStores() + { + var f = new Fixture(); + var context = f.Context("roles.list"); + var target = UserKey.New(); + + f.UserRoleStore + .Setup(x => x.GetAssignmentsAsync( + target, + It.IsAny())) + .ReturnsAsync(Array.Empty()); + + f.RoleStore + .Setup(x => x.GetByIdsAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(Array.Empty()); + + await f.Sut.GetRolesAsync( + context, + target, + new PageRequest()); + + f.RoleFactory.Verify( + x => x.Create(context.ResourceTenant), + Times.Once); + + f.UserRoleFactory.Verify( + x => x.Create(context.ResourceTenant), + Times.Once); + } + + // ========================================================= + // Cancellation + // ========================================================= + + [Fact] + public async Task AssignAsync_WhenAlreadyCancelled_DoesNotExecuteAccessCommand() + { + var f = new Fixture(); + var context = f.Context("roles.assign"); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => f.Sut.AssignAsync( + context, + UserKey.New(), + "Admin", + cts.Token); + + await act.Should() + .ThrowAsync(); + + f.AccessOrchestrator.VerifyNoOtherCalls(); + f.RoleFactory.VerifyNoOtherCalls(); + f.UserRoleFactory.VerifyNoOtherCalls(); + } + + [Fact] + public async Task GetRolesAsync_WhenSortByNameAscending_SortsByName() + { + var f = new Fixture(); + var context = f.Context("roles.list"); + var target = UserKey.New(); + + var charlie = f.Role("Charlie"); + var alpha = f.Role("Alpha"); + var bravo = f.Role("Bravo"); + + // Deliberately not alphabetic. + var assignments = new[] + { + f.Assignment(target, charlie.Id, Now.AddDays(-3)), + f.Assignment(target, alpha.Id, Now.AddDays(-2)), + f.Assignment(target, bravo.Id, Now.AddDays(-1)) + }; + + f.SetupGetRoles(target, assignments, charlie, alpha, bravo); + + var result = await f.Sut.GetRolesAsync( + context, + target, + new PageRequest + { + SortBy = nameof(UserRoleInfo.Name), + Descending = false + }); + + result.Items + .Select(x => x.Name) + .Should() + .ContainInOrder("Alpha", "Bravo", "Charlie"); + } + + [Fact] + public async Task GetRolesAsync_WhenSortByNameDescending_SortsByNameDescending() + { + var f = new Fixture(); + var context = f.Context("roles.list"); + var target = UserKey.New(); + + var bravo = f.Role("Bravo"); + var charlie = f.Role("Charlie"); + var alpha = f.Role("Alpha"); + + var assignments = new[] + { + f.Assignment(target, bravo.Id, Now.AddDays(-3)), + f.Assignment(target, charlie.Id, Now.AddDays(-2)), + f.Assignment(target, alpha.Id, Now.AddDays(-1)) + }; + + f.SetupGetRoles(target, assignments, bravo, charlie, alpha); + + var result = await f.Sut.GetRolesAsync( + context, + target, + new PageRequest + { + SortBy = nameof(UserRoleInfo.Name), + Descending = true + }); + + result.Items + .Select(x => x.Name) + .Should() + .ContainInOrder("Charlie", "Bravo", "Alpha"); + } + + [Fact] + public async Task GetRolesAsync_WhenSortByAssignedAtAscending_SortsByAssignedAt() + { + var f = new Fixture(); + var context = f.Context("roles.list"); + var target = UserKey.New(); + + var role1 = f.Role("Role 1"); + var role2 = f.Role("Role 2"); + var role3 = f.Role("Role 3"); + + var oldest = Now.AddDays(-10); + var middle = Now.AddDays(-5); + var newest = Now.AddDays(-1); + + var assignments = new[] + { + f.Assignment(target, role1.Id, newest), + f.Assignment(target, role2.Id, oldest), + f.Assignment(target, role3.Id, middle) + }; + + f.SetupGetRoles(target, assignments, role1, role2, role3); + + var result = await f.Sut.GetRolesAsync( + context, + target, + new PageRequest + { + SortBy = nameof(UserRoleInfo.AssignedAt), + Descending = false + }); + + result.Items + .Select(x => x.AssignedAt) + .Should() + .ContainInOrder(oldest, middle, newest); + } + + [Fact] + public async Task GetRolesAsync_WhenSortByAssignedAtDescending_SortsByAssignedAtDescending() + { + var f = new Fixture(); + var context = f.Context("roles.list"); + var target = UserKey.New(); + + var role1 = f.Role("Role 1"); + var role2 = f.Role("Role 2"); + var role3 = f.Role("Role 3"); + + var oldest = Now.AddDays(-10); + var middle = Now.AddDays(-5); + var newest = Now.AddDays(-1); + + var assignments = new[] + { + f.Assignment(target, role1.Id, middle), + f.Assignment(target, role2.Id, oldest), + f.Assignment(target, role3.Id, newest) + }; + + f.SetupGetRoles(target, assignments, role1, role2, role3); + + var result = await f.Sut.GetRolesAsync( + context, + target, + new PageRequest + { + SortBy = nameof(UserRoleInfo.AssignedAt), + Descending = true + }); + + result.Items + .Select(x => x.AssignedAt) + .Should() + .ContainInOrder(newest, middle, oldest); + } + + [Fact] + public async Task GetRolesAsync_WhenSortByIsNotSpecified_DefaultsToNameAscending() + { + var f = new Fixture(); + var context = f.Context("roles.list"); + var target = UserKey.New(); + + var zebra = f.Role("Zebra"); + var admin = f.Role("Admin"); + var manager = f.Role("Manager"); + + var assignments = new[] + { + f.Assignment(target, zebra.Id, Now.AddDays(-3)), + f.Assignment(target, admin.Id, Now.AddDays(-2)), + f.Assignment(target, manager.Id, Now.AddDays(-1)) + }; + + f.SetupGetRoles(target, assignments, zebra, admin, manager); + + var result = await f.Sut.GetRolesAsync( + context, + target, + new PageRequest()); + + result.Items + .Select(x => x.Name) + .Should() + .ContainInOrder("Admin", "Manager", "Zebra"); + } + + [Fact] + public async Task GetRolesAsync_SortsBeforeApplyingPagination() + { + var f = new Fixture(); + var context = f.Context("roles.list"); + var target = UserKey.New(); + + var charlie = f.Role("Charlie"); + var alpha = f.Role("Alpha"); + var echo = f.Role("Echo"); + var bravo = f.Role("Bravo"); + var delta = f.Role("Delta"); + + // Deliberately: + // Charlie, Alpha, Echo, Bravo, Delta + // + // Correct sorted result: + // Alpha, Bravo, Charlie, Delta, Echo + var assignments = new[] + { + f.Assignment(target, charlie.Id, Now.AddMinutes(-5)), + f.Assignment(target, alpha.Id, Now.AddMinutes(-4)), + f.Assignment(target, echo.Id, Now.AddMinutes(-3)), + f.Assignment(target, bravo.Id, Now.AddMinutes(-2)), + f.Assignment(target, delta.Id, Now.AddMinutes(-1)) + }; + + f.SetupGetRoles( + target, + assignments, + charlie, + alpha, + echo, + bravo, + delta); + + var result = await f.Sut.GetRolesAsync( + context, + target, + new PageRequest + { + PageNumber = 2, + PageSize = 2, + SortBy = nameof(UserRoleInfo.Name) + }); + + result.TotalCount.Should().Be(5); + result.PageNumber.Should().Be(2); + result.PageSize.Should().Be(2); + + result.Items + .Select(x => x.Name) + .Should() + .ContainInOrder("Charlie", "Delta"); + } + + [Fact] + public async Task GetRolesAsync_WhenNamesAreEqual_UsesRoleIdAsDeterministicTieBreaker() + { + var f = new Fixture(); + var context = f.Context("roles.list"); + var target = UserKey.New(); + + var lowerId = Role.FromProjection( + RoleId.From(Guid.Parse("00000000-0000-0000-0000-000000000001")), + TenantKey.Single, + "Admin", + [], + Now.AddDays(-2), + null, + null, + 0); + + var higherId = Role.FromProjection( + RoleId.From(Guid.Parse("00000000-0000-0000-0000-000000000002")), + TenantKey.Single, + "Admin", + [], + Now.AddDays(-1), + null, + null, + 0); + + // Reverse storage order deliberately. + var assignments = new[] + { + f.Assignment(target, higherId.Id, Now), + f.Assignment(target, lowerId.Id, Now) + }; + + f.SetupGetRoles( + target, + assignments, + higherId, + lowerId); + + var result = await f.Sut.GetRolesAsync( + context, + target, + new PageRequest + { + SortBy = nameof(UserRoleInfo.Name) + }); + + result.Items.Select(x => x.RoleId) + .Should() + .ContainInOrder(lowerId.Id, higherId.Id); + } + + // ========================================================= + // Fixture + // ========================================================= + + private sealed class Fixture + { + public Mock AccessOrchestrator { get; } + = new(MockBehavior.Strict); + + public Mock UserRoleFactory { get; } + = new(MockBehavior.Strict); + + public Mock UserRoleStore { get; } + = new(MockBehavior.Strict); + + public Mock RoleFactory { get; } + = new(MockBehavior.Strict); + + public Mock RoleStore { get; } + = new(MockBehavior.Strict); + + public Mock Clock { get; } + = new(MockBehavior.Strict); + + public UserRoleService Sut { get; } + + public Fixture() + { + Clock + .SetupGet(x => x.UtcNow) + .Returns(Now); + + AccessOrchestrator + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns( + async (_, command, ct) => + await command.ExecuteAsync(ct)); + + AccessOrchestrator + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Returns< + AccessContext, + AccessCommand>, + CancellationToken>( + async (_, command, ct) => + await command.ExecuteAsync(ct)); + + UserRoleFactory + .Setup(x => x.Create(It.IsAny())) + .Returns(UserRoleStore.Object); + + RoleFactory + .Setup(x => x.Create(It.IsAny())) + .Returns(RoleStore.Object); + + Sut = new UserRoleService( + AccessOrchestrator.Object, + UserRoleFactory.Object, + RoleFactory.Object, + Clock.Object); + } + + public AccessContext Context(string action) + => TestAccessContext.WithAction(action); + + public Role Role(string name) + => global::CodeBeam.UltimateAuth.Authorization.Role.Create( + RoleId.New(), + TenantKey.Single, + name, + permissions: null, + now: Now.AddHours(-1)); + + public UserRole Assignment( + UserKey user, + RoleId roleId, + DateTimeOffset assignedAt) + => new() + { + Tenant = TenantKey.Single, + UserKey = user, + RoleId = roleId, + AssignedAt = assignedAt + }; + + public void SetupGetRoles( + UserKey target, + IReadOnlyCollection assignments, + params Role[] roles) + { + UserRoleStore + .Setup(x => x.GetAssignmentsAsync( + target, + It.IsAny())) + .ReturnsAsync(assignments); + + RoleStore + .Setup(x => x.GetByIdsAsync( + It.Is>(ids => + ids.Count == assignments.Count && + assignments.All(a => ids.Contains(a.RoleId))), + It.IsAny())) + .ReturnsAsync(roles); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthStateViewTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthStateViewTests.cs index e53b5840..277146e7 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthStateViewTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthStateViewTests.cs @@ -1,15 +1,14 @@ ο»Ώusing Bunit; -using CodeBeam.UltimateAuth.Authorization.Contracts; using CodeBeam.UltimateAuth.Authorization.Reference; using CodeBeam.UltimateAuth.Client; using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Core.Contracts; using CodeBeam.UltimateAuth.Core.Domain; using CodeBeam.UltimateAuth.Tests.Unit.Helpers; using FluentAssertions; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; using Moq; -using System.Security.Claims; namespace CodeBeam.UltimateAuth.Tests.Unit; @@ -113,7 +112,7 @@ public void Should_Require_All_When_MatchAll_True() var cut = RenderWithAuth(ctx, state, p => p .Add(x => x.Roles, "admin,user") - .Add(x => x.MatchAll, true) + .Add(x => x.MatchMode, AuthorizationMatchMode.All) .Add(x => x.NotAuthorized, Html("
no
")) ); @@ -129,7 +128,47 @@ public void Should_Allow_Any_When_MatchAll_False() var cut = RenderWithAuth(ctx, state, p => p .Add(x => x.Roles, "admin,user") - .Add(x => x.MatchAll, false) + .Add(x => x.MatchMode, AuthorizationMatchMode.Any) + .Add(x => x.Authorized, s => b => b.AddContent(0, "ok")) + ); + + cut.Markup.Should().Contain("ok"); + } + + [Fact] + public void Should_Fail_When_One_Category_Does_Not_Match_In_Category_Mode() + { + using var ctx = new BunitContext(); + + var state = TestAuthState.WithRoles("admin"); + + ctx.Services.AddSingleton(Mock.Of()); + + var cut = RenderWithAuth(ctx, state, p => p + .Add(x => x.Roles, "admin,user") + .Add(x => x.Permissions, "write") + .Add(x => x.MatchMode, AuthorizationMatchMode.Category) + .Add(x => x.NotAuthorized, Html("
no
")) + ); + + cut.Markup.Should().Contain("no"); + } + + [Fact] + public void Should_Require_At_Least_One_Match_Per_Category_When_MatchMode_Is_Category() + { + using var ctx = new BunitContext(); + + var state = TestAuthState.Create( + roles: ["admin"], + permissions: ["write"]); + + ctx.Services.AddSingleton(Mock.Of()); + + var cut = RenderWithAuth(ctx, state, p => p + .Add(x => x.Roles, "admin,user") + .Add(x => x.Permissions, "write,delete") + .Add(x => x.MatchMode, AuthorizationMatchMode.Category) .Add(x => x.Authorized, s => b => b.AddContent(0, "ok")) ); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthAppLifecycleTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthAppLifecycleTests.cs new file mode 100644 index 00000000..dc12cb93 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthAppLifecycleTests.cs @@ -0,0 +1,281 @@ +ο»Ώusing Bunit; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using FluentAssertions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Client.Blazor; + +public sealed class UAuthAppLifecycleTests : BunitContext +{ + private readonly Mock _stateManager = new(); + private readonly Mock _bootstrapper = new(); + private readonly Mock _coordinator = new(); + + private readonly UAuthState _state = UAuthState.Anonymous(); + + public UAuthAppLifecycleTests() + { + this.AddAuthorization(); + + _stateManager + .SetupGet(x => x.State) + .Returns(_state); + + _bootstrapper + .Setup(x => x.EnsureStartedAsync()) + .Returns(Task.CompletedTask); + + _stateManager + .Setup(x => x.EnsureAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + _coordinator + .Setup(x => x.StartAsync()) + .Returns(Task.CompletedTask); + + _coordinator + .Setup(x => x.StopAsync()) + .Returns(Task.CompletedTask); + + Services.AddSingleton(_stateManager.Object); + Services.AddSingleton(_bootstrapper.Object); + Services.AddSingleton(_coordinator.Object); + } + + [Fact] + public void FirstRender_StartsBootstrapperAndEnsuresStateExactlyOnce() + { + var cut = Render(p => p + .AddChildContent("
content
")); + + cut.WaitForAssertion(() => + { + _bootstrapper.Verify( + x => x.EnsureStartedAsync(), + Times.Once); + + _stateManager.Verify( + x => x.EnsureAsync(false), + Times.Once); + }); + } + + [Fact] + public void FirstRender_BootstrapsBeforeEnsuringState() + { + var sequence = new MockSequence(); + + _bootstrapper + .InSequence(sequence) + .Setup(x => x.EnsureStartedAsync()) + .Returns(Task.CompletedTask); + + _stateManager + .InSequence(sequence) + .Setup(x => x.EnsureAsync(false)) + .Returns(Task.CompletedTask); + + Render(p => p + .AddChildContent("content")); + } + + [Fact] + public void FirstRender_WhenAnonymous_DoesNotStartCoordinator() + { + Render(p => p + .AddChildContent("content")); + + _coordinator.Verify( + x => x.StartAsync(), + Times.Never); + } + + [Fact] + public void FirstRender_WhenAuthenticated_StartsCoordinator() + { + var state = AuthenticatedState(); + + _stateManager + .SetupGet(x => x.State) + .Returns(state); + + Render(p => p + .AddChildContent("content")); + + _coordinator.Verify( + x => x.StartAsync(), + Times.Once); + } + + [Fact] + public void AuthenticatedStateChange_StartsCoordinator() + { + var cut = Render(p => p + .AddChildContent("content")); + + _coordinator.Invocations.Clear(); + + ApplyAuthenticatedSnapshot(_state); + + cut.WaitForAssertion(() => + { + _coordinator.Verify( + x => x.StartAsync(), + Times.Once); + }); + } + + [Fact] + public void WhenStateNeedsValidation_ForcesEnsure() + { + var state = AuthenticatedState(); + state.MarkStale(); + + _stateManager + .SetupGet(x => x.State) + .Returns(state); + + Render(p => p + .AddChildContent("content")); + + _stateManager.Verify( + x => x.EnsureAsync(true), + Times.AtLeastOnce); + } + + [Fact] + public void ReauthRequired_MarksStateStale() + { + var state = AuthenticatedState(); + + _stateManager + .SetupGet(x => x.State) + .Returns(state); + + Render(p => p + .AddChildContent("content")); + + _coordinator.Raise(x => x.ReauthRequired += null); + + _stateManager.Verify( + x => x.MarkStale(), + Times.Once); + } + + [Fact] + public void ReauthRequired_InvokesConsumerCallback() + { + var callbackCount = 0; + + var cut = Render(p => p + .Add(x => x.OnReauthRequired, + EventCallback.Factory.Create( + this, + () => callbackCount++)) + .AddChildContent("content")); + + _coordinator.Raise(x => x.ReauthRequired += null); + + cut.WaitForAssertion(() => callbackCount.Should().Be(1)); + } + + [Fact] + public async Task Dispose_StopsStartedCoordinator() + { + var state = AuthenticatedState(); + + _stateManager + .SetupGet(x => x.State) + .Returns(state); + + var cut = Render(p => p + .AddChildContent("content")); + + _coordinator.Invocations.Clear(); + + await cut.Instance.DisposeAsync(); + + _coordinator.Verify( + x => x.StopAsync(), + Times.Once); + } + + [Fact] + public async Task Dispose_WhenCoordinatorStartedAfterAuthentication_StopsCoordinator() + { + var cut = Render(p => p + .AddChildContent("content")); + + ApplyAuthenticatedSnapshot(_state); + + cut.WaitForAssertion(() => + { + _coordinator.Verify( + x => x.StartAsync(), + Times.Once); + }); + + _coordinator.Invocations.Clear(); + + await cut.Instance.DisposeAsync(); + + _coordinator.Verify( + x => x.StopAsync(), + Times.Once); + } + + private static UAuthState AuthenticatedState( + string userKey = "user-1", + string? userName = "alice", + SessionState sessionState = SessionState.Active) + { + var state = UAuthState.Anonymous(); + + state.ApplySnapshot( + CreateAuthSnapshot( + userKey, + userName, + sessionState), + DateTimeOffset.UtcNow); + + return state; + } + + private static AuthStateSnapshot CreateAuthSnapshot( + string userKey = "user-1", + string? userName = "alice", + SessionState sessionState = SessionState.Active) + { + return new AuthStateSnapshot + { + Identity = new AuthIdentitySnapshot + { + UserKey = UserKey.FromString(userKey), + Tenant = TenantKeys.Single, + PrimaryUserName = userName, + DisplayName = userName, + SessionState = sessionState, + UserStatus = UserStatus.Active + }, + Claims = ClaimsSnapshot.From( + (ClaimTypes.Role, "User")) + }; + } + + private static void ApplyAuthenticatedSnapshot(UAuthState state) + { + state.ApplySnapshot( + CreateAuthSnapshot(), + DateTimeOffset.UtcNow); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthComponentBaseTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthComponentBaseTests.cs new file mode 100644 index 00000000..29e4837c --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthComponentBaseTests.cs @@ -0,0 +1,406 @@ +ο»Ώusing Bunit; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using FluentAssertions; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Client.Blazor; + +public sealed class UAuthComponentBaseTests : BunitContext +{ + [Fact] + public void Render_WithoutUAuthState_ThrowsInvalidOperationException() + { + var act = () => Render(); + + act.Should() + .Throw() + .WithMessage("*requires a cascading parameter*UAuthState*"); + } + + [Fact] + public void Render_WithUAuthState_ReceivesCascadingState() + { + var state = UAuthState.Anonymous(); + + var cut = RenderWithState(state); + + var component = cut + .FindComponent() + .Instance; + + component.CurrentAuthState + .Should() + .BeSameAs(state); + } + + [Fact] + public void Render_WithoutAuthorizeAttribute_DoesNotCallUnauthorized() + { + var state = UAuthState.Anonymous(); + + var cut = RenderWithState(state); + + var component = cut + .FindComponent() + .Instance; + + component.UnauthorizedCount.Should().Be(0); + component.ForbiddenCount.Should().Be(0); + } + + [Fact] + public void AuthStateCleared_WithRoleRequirement_WhenAnonymous_CallsUnauthorized_NotForbidden() + { + var state = UAuthState.Anonymous(); + + var cut = RenderWithState(state); + + var component = cut + .FindComponent() + .Instance; + + // Initial render must not make a premature authorization decision. + component.UnauthorizedCount.Should().Be(0); + component.ForbiddenCount.Should().Be(0); + + state.Clear(); + + component.UnauthorizedCount.Should().Be(1); + component.ForbiddenCount.Should().Be(0); + } + + [Fact] + public void Dispose_DoesNotThrow() + { + var state = UAuthState.Anonymous(); + + var cut = RenderWithState(state); + + var act = () => cut.Dispose(); + + act.Should().NotThrow(); + } + + [Fact] + public void Render_WithAuthorizeAttribute_WhenAuthenticated_DoesNotReject() + { + var state = AuthenticatedState(); + + var cut = RenderWithState(state); + + var component = cut + .FindComponent() + .Instance; + + component.UnauthorizedCount.Should().Be(0); + component.ForbiddenCount.Should().Be(0); + } + + [Fact] + public void Render_WithRequiredRole_WhenUserHasRole_DoesNotReject() + { + var state = AuthenticatedState( + roles: ["admin"]); + + var cut = RenderWithState(state); + + var component = cut + .FindComponent() + .Instance; + + component.UnauthorizedCount.Should().Be(0); + component.ForbiddenCount.Should().Be(0); + } + + [Fact] + public void AuthStateChanged_WithRequiredRole_WhenUserDoesNotHaveRole_CallsForbidden() + { + var state = AuthenticatedState( + roles: ["User"]); + + var cut = RenderWithState(state); + + var component = cut + .FindComponent() + .Instance; + + component.UnauthorizedCount.Should().Be(0); + component.ForbiddenCount.Should().Be(0); + + state.MarkStale(); + + component.UnauthorizedCount.Should().Be(0); + component.ForbiddenCount.Should().Be(1); + } + + [Fact] + public void Render_WithRequiredPermission_WhenUserHasPermission_DoesNotReject() + { + var state = AuthenticatedState( + permissions: ["users.read"]); + + var cut = RenderWithState(state); + + var component = cut + .FindComponent() + .Instance; + + component.UnauthorizedCount.Should().Be(0); + component.ForbiddenCount.Should().Be(0); + } + + [Fact] + public void AuthStateChanged_WithRequiredPermission_WhenUserDoesNotHavePermission_CallsForbidden() + { + var state = AuthenticatedState( + permissions: ["users.list"]); + + var cut = RenderWithState(state); + + var component = cut + .FindComponent() + .Instance; + + // No premature decision during initial render. + component.UnauthorizedCount.Should().Be(0); + component.ForbiddenCount.Should().Be(0); + + state.MarkStale(); + + component.UnauthorizedCount.Should().Be(0); + component.ForbiddenCount.Should().Be(1); + } + + [Fact] + public void Render_WithMultipleRoles_AllowsWhenAnyRoleMatches() + { + var state = AuthenticatedState( + roles: ["manager"]); + + var cut = RenderWithState(state); + + var component = cut + .FindComponent() + .Instance; + + component.UnauthorizedCount.Should().Be(0); + component.ForbiddenCount.Should().Be(0); + } + + [Fact] + public void Render_WithMultiplePermissions_AllowsWhenAnyPermissionMatches() + { + var state = AuthenticatedState( + permissions: ["users.write"]); + + var cut = RenderWithState(state); + + var component = cut + .FindComponent() + .Instance; + + component.UnauthorizedCount.Should().Be(0); + component.ForbiddenCount.Should().Be(0); + } + + [Fact] + public void StateChange_ForwardsReasonToComponent() + { + var state = AuthenticatedState(); + + var cut = RenderWithState(state); + + var component = cut + .FindComponent() + .Instance; + + state.MarkStale(); + + component.StateChangedCount.Should().Be(1); + component.LastChangeReason + .Should().Be(UAuthStateChangeReason.MarkedStale); + } + + [Fact] + public void StateChange_ReevaluatesAuthorization() + { + var state = AuthenticatedState(); + + var cut = + RenderWithState(state); + + var component = cut + .FindComponent() + .Instance; + + component.UnauthorizedCount.Should().Be(0); + + state.Clear(); + + component.UnauthorizedCount.Should().Be(1); + } + + [Fact] + public void Dispose_UnsubscribesFromAuthState() + { + var state = AuthenticatedState(); + + var cut = RenderWithState(state); + + var component = cut + .FindComponent() + .Instance; + + state.MarkStale(); + + component.StateChangedCount.Should().Be(1); + + component.Dispose(); + + state.MarkValidated(DateTimeOffset.UtcNow); + + component.StateChangedCount.Should().Be(1); + component.LastChangeReason + .Should().Be(UAuthStateChangeReason.MarkedStale); + } + + [Fact] + public void AuthStateCleared_WithAuthorizeAttribute_CallsUnauthorized() + { + var state = AuthenticatedState(); + + var cut = RenderWithState(state); + + var component = cut + .FindComponent() + .Instance; + + component.UnauthorizedCount.Should().Be(0); + + state.Clear(); + + component.UnauthorizedCount.Should().Be(1); + } + + [Fact] + public void InitialRender_WithAuthorizeAttribute_DoesNotEvaluateAuthorizationPrematurely() + { + var state = UAuthState.Anonymous(); + + var cut = RenderWithState(state); + + var component = cut + .FindComponent() + .Instance; + + component.UnauthorizedCount.Should().Be(0); + component.ForbiddenCount.Should().Be(0); + } + + private IRenderedComponent> RenderWithState(UAuthState state) where TComponent : IComponent + { + return Render>(parameters => + parameters + .Add(x => x.Value, state) + .AddChildContent()); + } + + [UAuthAuthorize] + private sealed class AuthenticationRequiredComponent + : TestComponent + { + } + + [UAuthAuthorize(Roles = "admin")] + private sealed class AdminRequiredComponent + : TestComponent + { + } + + [UAuthAuthorize(Permissions = "users.read")] + private sealed class ReadUsersRequiredComponent + : TestComponent + { + } + + [UAuthAuthorize(Roles = "admin,manager")] + private sealed class AdminOrManagerComponent : TestComponent + { + } + + [UAuthAuthorize(Permissions = "users.read,users.write")] + private sealed class ReadOrWriteUsersComponent : TestComponent + { + } + + + private class TestComponent : UAuthComponentBase + { + public int StateChangedCount { get; private set; } + + public UAuthStateChangeReason? LastChangeReason { get; private set; } + + public int UnauthorizedCount { get; private set; } + + public int ForbiddenCount { get; private set; } + + public UAuthState CurrentAuthState => AuthState; + + protected override void HandleAuthStateChanged( + UAuthStateChangeReason reason) + { + StateChangedCount++; + LastChangeReason = reason; + } + + protected override void OnUnauthorized() + { + UnauthorizedCount++; + } + + protected override void OnForbidden() + { + ForbiddenCount++; + } + + protected override void BuildRenderTree( + RenderTreeBuilder builder) + { + builder.AddContent(0, "uauth-test-component"); + } + } + + private static UAuthState AuthenticatedState(string[]? roles = null, string[]? permissions = null) + { + var claims = new List<(string Type, string Value)>(); + + foreach (var role in roles ?? []) + claims.Add((ClaimTypes.Role, role)); + + foreach (var permission in permissions ?? []) + claims.Add(("uauth:permission", permission)); + + var state = UAuthState.Anonymous(); + + state.ApplySnapshot( + new AuthStateSnapshot + { + Identity = new AuthIdentitySnapshot + { + UserKey = UserKey.New(), + Tenant = TenantKey.Single, + PrimaryUserName = "alice" + }, + Claims = ClaimsSnapshot.From(claims.ToArray()) + }, + DateTimeOffset.UtcNow); + + return state; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthHubLayoutBaseTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthHubLayoutBaseTests.cs new file mode 100644 index 00000000..3e68262b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthHubLayoutBaseTests.cs @@ -0,0 +1,87 @@ +ο»Ώusing Bunit; +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using FluentAssertions; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Client.Blazor; + +public sealed class UAuthHubLayoutBaseTests : BunitContext +{ + private readonly Mock _reader = new(); + + public UAuthHubLayoutBaseTests() + { + Services.AddSingleton(_reader.Object); + } + + [Fact] + public void Render_WithoutHubQuery_DoesNotReadHubState() + { + var cut = Render(); + + cut.Instance.CurrentHubState.Should().BeNull(); + cut.Instance.HasCurrentHub.Should().BeFalse(); + cut.Instance.HubActive.Should().BeFalse(); + cut.Instance.HubExpired.Should().BeFalse(); + cut.Instance.HubError.Should().BeNull(); + + _reader.Verify( + x => x.GetStateAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public void Render_WithInvalidHubQuery_DoesNotReadHubState() + { + Nav.NavigateTo("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/login?uauth_hub=invalid"); + + var cut = Render(); + + cut.Instance.CurrentHubState.Should().BeNull(); + + _reader.Verify( + x => x.GetStateAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public void ResolveHubKey_UsesUAuthHubQueryParameter() + { + Nav.NavigateTo("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/login?uauth_hub=my-hub-value&hub=legacy-value"); + + var cut = Render(); + + cut.Instance.ResolvedHubKey + .Should() + .Be("my-hub-value"); + } + + private NavigationManager Nav => + Services.GetRequiredService(); + + private sealed class TestHubLayout : UAuthHubLayoutBase + { + public HubFlowState? CurrentHubState => HubState; + public bool HasCurrentHub => HasHub; + public bool HubActive => IsHubActive; + public bool HubExpired => IsExpired; + public HubErrorCode? HubError => Error; + + public string? ResolvedHubKey => ResolveHubKey(); + + protected override void BuildRenderTree( + RenderTreeBuilder builder) + { + builder.AddContent(0, Body); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthHubPageBaseTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthHubPageBaseTests.cs new file mode 100644 index 00000000..2ebe17d8 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthHubPageBaseTests.cs @@ -0,0 +1,438 @@ +ο»Ώusing Bunit; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using FluentAssertions; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Client.Blazor; + +public sealed class UAuthHubPageBaseTests : BunitContext +{ + private readonly Mock _reader = new(); + + public UAuthHubPageBaseTests() + { + Services.AddSingleton(_reader.Object); + } + + [Fact] + public void Render_WithoutHubKey_DoesNotReadHubState() + { + var state = UAuthState.Anonymous(); + + var cut = RenderPage(state); + + var page = GetPage(cut); + + page.CurrentHubState.Should().BeNull(); + page.HubActive.Should().BeFalse(); + + VerifyReaderNeverCalled(); + } + + [Fact] + public void Render_WithInvalidHubKey_DoesNotReadHubState() + { + var state = UAuthState.Anonymous(); + + NavigateWithHub("invalid-hub-key"); + + var cut = RenderPage(state); + + var page = GetPage(cut); + + page.CurrentHubState.Should().BeNull(); + page.HubActive.Should().BeFalse(); + + VerifyReaderNeverCalled(); + } + + [Fact] + public void Render_WithLegacyHubQuery_DoesNotReadHubState() + { + var state = UAuthState.Anonymous(); + var hubId = HubSessionId.New(); + + Navigate($"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/login?hub={hubId.Value}"); + + var cut = RenderPage(state); + + var page = GetPage(cut); + + page.CurrentHubState.Should().BeNull(); + page.HubActive.Should().BeFalse(); + + VerifyReaderNeverCalled(); + } + + [Fact] + public void Render_WithValidHubKey_LoadsHubState() + { + var state = UAuthState.Anonymous(); + var hubId = HubSessionId.New(); + + var hubState = CreateHubState(hubId); + + SetupHubState(hubId, hubState); + + NavigateWithHub(hubId); + + var cut = RenderPage(state); + + var page = GetPage(cut); + + page.CurrentHubState.Should().BeSameAs(hubState); + page.HubActive.Should().BeTrue(); + + VerifyReaderCalled(hubId, Times.Once()); + } + + [Fact] + public void Render_WhenHubDoesNotExist_IsHubActiveFalse() + { + var state = UAuthState.Anonymous(); + var hubId = HubSessionId.New(); + + var hubState = CreateHubState( + hubId, + exists: false, + active: true); + + SetupHubState(hubId, hubState); + + NavigateWithHub(hubId); + + var cut = RenderPage(state); + + var page = GetPage(cut); + + page.CurrentHubState.Should().BeSameAs(hubState); + page.HubActive.Should().BeFalse(); + } + + [Fact] + public void Render_WhenHubExistsButIsInactive_IsHubActiveFalse() + { + var state = UAuthState.Anonymous(); + var hubId = HubSessionId.New(); + + var hubState = CreateHubState( + hubId, + exists: true, + active: false); + + SetupHubState(hubId, hubState); + + NavigateWithHub(hubId); + + var cut = RenderPage(state); + + var page = GetPage(cut); + + page.CurrentHubState.Should().BeSameAs(hubState); + page.HubActive.Should().BeFalse(); + } + + [Fact] + public void Render_WhenHubExistsAndIsActive_IsHubActiveTrue() + { + var state = UAuthState.Anonymous(); + var hubId = HubSessionId.New(); + + var hubState = CreateHubState( + hubId, + exists: true, + active: true); + + SetupHubState(hubId, hubState); + + NavigateWithHub(hubId); + + var cut = RenderPage(state); + + var page = GetPage(cut); + + page.HubActive.Should().BeTrue(); + } + + [Fact] + public void Render_PreservesCompleteHubState() + { + var state = UAuthState.Anonymous(); + var hubId = HubSessionId.New(); + + var hubState = CreateHubState( + hubId, + exists: true, + active: false, + expired: true, + completed: false, + error: HubErrorCode.InvalidCredentials, + attemptCount: 3, + returnUrl: "/dashboard"); + + SetupHubState(hubId, hubState); + + NavigateWithHub(hubId); + + var cut = RenderPage(state); + + var page = GetPage(cut); + + page.CurrentHubState.Should().BeSameAs(hubState); + + page.CurrentHubState!.Exists.Should().BeTrue(); + page.CurrentHubState.IsActive.Should().BeFalse(); + page.CurrentHubState.IsExpired.Should().BeTrue(); + page.CurrentHubState.IsCompleted.Should().BeFalse(); + page.CurrentHubState.Error.Should().Be(HubErrorCode.InvalidCredentials); + page.CurrentHubState.AttemptCount.Should().Be(3); + page.CurrentHubState.ReturnUrl.Should().Be("/dashboard"); + } + + [Fact] + public async Task ReloadStateAsync_ReloadsCurrentHubState() + { + var state = UAuthState.Anonymous(); + var hubId = HubSessionId.New(); + + var initial = CreateHubState( + hubId, + active: true); + + var updated = CreateHubState( + hubId, + active: false, + expired: true); + + _reader + .SetupSequence(x => x.GetStateAsync( + hubId, + It.IsAny())) + .ReturnsAsync(initial) + .ReturnsAsync(updated); + + NavigateWithHub(hubId); + + var cut = RenderPage(state); + + var page = GetPage(cut); + + page.CurrentHubState.Should().BeSameAs(initial); + page.HubActive.Should().BeTrue(); + + await page.ReloadStateAsync(); + + page.CurrentHubState.Should().BeSameAs(updated); + page.HubActive.Should().BeFalse(); + page.CurrentHubState!.IsExpired.Should().BeTrue(); + + VerifyReaderCalled(hubId, Times.Exactly(2)); + } + + [Fact] + public async Task ReloadStateAsync_WhenReaderReturnsNull_ClearsState() + { + var state = UAuthState.Anonymous(); + var hubId = HubSessionId.New(); + + var initial = CreateHubState(hubId); + + _reader + .SetupSequence(x => x.GetStateAsync( + hubId, + It.IsAny())) + .ReturnsAsync(initial) + .ReturnsAsync((HubFlowState?)null); + + NavigateWithHub(hubId); + + var cut = RenderPage(state); + + var page = GetPage(cut); + + page.CurrentHubState.Should().BeSameAs(initial); + + await page.ReloadStateAsync(); + + page.CurrentHubState.Should().BeNull(); + page.HubActive.Should().BeFalse(); + } + + [Fact] + public async Task ReloadStateAsync_WithInvalidHubKey_ClearsExistingState() + { + var state = UAuthState.Anonymous(); + var hubId = HubSessionId.New(); + + var initial = CreateHubState(hubId); + + SetupHubState(hubId, initial); + + NavigateWithHub(hubId); + + var cut = RenderPage(state); + + var page = GetPage(cut); + + page.CurrentHubState.Should().BeSameAs(initial); + + page.SetHubKey("invalid-hub-key"); + + await page.ReloadStateAsync(); + + page.CurrentHubState.Should().BeNull(); + page.HubActive.Should().BeFalse(); + + // Only the initial render should have reached the reader. + VerifyReaderCalled(hubId, Times.Once()); + } + + [Fact] + public async Task ReloadStateAsync_WithoutHubKey_ClearsExistingState() + { + var state = UAuthState.Anonymous(); + var hubId = HubSessionId.New(); + + var initial = CreateHubState(hubId); + + SetupHubState(hubId, initial); + + NavigateWithHub(hubId); + + var cut = RenderPage(state); + + var page = GetPage(cut); + + page.CurrentHubState.Should().BeSameAs(initial); + page.HubActive.Should().BeTrue(); + + page.SetHubKey(null); + + await page.ReloadStateAsync(); + + page.CurrentHubState.Should().BeNull(); + page.HubActive.Should().BeFalse(); + + VerifyReaderCalled(hubId, Times.Once()); + } + + // ----------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------- + + private void SetupHubState( + HubSessionId hubId, + HubFlowState state) + { + _reader + .Setup(x => x.GetStateAsync( + hubId, + It.IsAny())) + .ReturnsAsync(state); + } + + private static HubFlowState CreateHubState( + HubSessionId hubId, + bool exists = true, + bool active = true, + bool expired = false, + bool completed = false, + HubErrorCode? error = null, + int attemptCount = 0, + string? returnUrl = null) + { + return new HubFlowState + { + HubSessionId = hubId, + Exists = exists, + IsActive = active, + IsExpired = expired, + IsCompleted = completed, + Error = error, + AttemptCount = attemptCount, + ReturnUrl = returnUrl + }; + } + + private void NavigateWithHub(HubSessionId hubId) + { + NavigateWithHub(hubId.Value); + } + + private void NavigateWithHub(string hubKey) + { + var uri = Nav.GetUriWithQueryParameter( + UAuthConstants.Query.Hub, + hubKey); + + Nav.NavigateTo(uri); + } + + private void Navigate(string relativeUri) + { + Nav.NavigateTo(relativeUri); + } + + private TestHubPage GetPage( + IRenderedComponent> cut) + { + return cut.FindComponent().Instance; + } + + private void VerifyReaderNeverCalled() + { + _reader.Verify( + x => x.GetStateAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + private void VerifyReaderCalled( + HubSessionId hubId, + Times times) + { + _reader.Verify( + x => x.GetStateAsync( + hubId, + It.IsAny()), + times); + } + + private IRenderedComponent> + RenderPage(UAuthState state) + { + return Render>(parameters => + parameters + .Add(x => x.Value, state) + .AddChildContent()); + } + + private NavigationManager Nav => + Services.GetRequiredService(); + + private sealed class TestHubPage : UAuthHubPageBase + { + public HubFlowState? CurrentHubState => HubState; + + public bool HubActive => IsHubActive; + + public void SetHubKey(string? value) + { + HubKey = value; + } + + protected override void BuildRenderTree( + RenderTreeBuilder builder) + { + builder.AddContent(0, "hub-page"); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthLoginFormSubmissionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthLoginFormSubmissionTests.cs new file mode 100644 index 00000000..e4d851aa --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthLoginFormSubmissionTests.cs @@ -0,0 +1,689 @@ +ο»Ώusing Bunit; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Client.Services; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using FluentAssertions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Client.Blazor; + +public sealed class UAuthLoginFormSubmissionTests : BunitContext +{ + private readonly Mock _deviceIdProvider = new(); + private readonly Mock _client = new(); + private readonly Mock _flows = new(); + private readonly Mock _credentialResolver = new(); + private readonly Mock _hubFlowReader = new(); + private readonly Mock _hubCapabilities = new(); + + public UAuthLoginFormSubmissionTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + + _client + .SetupGet(x => x.Flows) + .Returns(_flows.Object); + + _hubCapabilities + .SetupGet(x => x.SupportsPkce) + .Returns(true); + + Services.AddSingleton(_deviceIdProvider.Object); + Services.AddSingleton(_client.Object); + Services.AddSingleton(_credentialResolver.Object); + Services.AddSingleton(_hubFlowReader.Object); + Services.AddSingleton(_hubCapabilities.Object); + + Services.AddSingleton( + Options.Create(new UAuthClientOptions())); + } + + // ----------------------------------------------------------------- + // Password validation + // ----------------------------------------------------------------- + + [Theory] + [InlineData(null, "secret")] + [InlineData("", "secret")] + [InlineData(" ", "secret")] + [InlineData("alice", null)] + [InlineData("alice", "")] + [InlineData("alice", " ")] + public async Task PasswordSubmit_WhenCredentialsMissing_ThrowsValidationException(string? identifier, string? secret) + { + var cut = RenderPasswordForm( + identifier, + secret, + UAuthSubmitMode.TryOnly); + + var act = () => cut.Instance.SubmitAsync(); + + await act.Should().ThrowAsync(); + } + + // ----------------------------------------------------------------- + // Password / TryOnly + // ----------------------------------------------------------------- + + [Fact] + public async Task Password_TryOnly_SendsExpectedLoginRequest() + { + LoginRequest? captured = null; + + var result = new TryLoginResult + { + Success = true + }; + + _client + .Setup(x => x.Flows.TryLoginAsync( + It.IsAny(), + UAuthSubmitMode.TryOnly, + It.IsAny())) + .Callback( + (request, _, _) => captured = request) + .ReturnsAsync(result); + + var cut = RenderPasswordForm( + "alice@example.com", + "secret-123", + UAuthSubmitMode.TryOnly); + + await cut.Instance.SubmitAsync(); + + captured.Should().NotBeNull(); + captured!.Identifier.Should().Be("alice@example.com"); + captured.Secret.Should().Be("secret-123"); + } + + [Fact] + public async Task Password_TryOnly_UsesTryOnlyMode() + { + var result = CreateTryLoginResult(); + + _client + .Setup(x => x.Flows.TryLoginAsync( + It.IsAny(), + UAuthSubmitMode.TryOnly, + It.IsAny())) + .ReturnsAsync(result); + + var cut = RenderPasswordForm( + "alice", + "secret", + UAuthSubmitMode.TryOnly); + + await cut.Instance.SubmitAsync(); + + _client.Verify( + x => x.Flows.TryLoginAsync( + It.Is(r => + r.Identifier == "alice" && + r.Secret == "secret"), + UAuthSubmitMode.TryOnly, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task Password_TryOnly_EmitsTryResult() + { + var result = new TryLoginResult + { + Success = true + }; + + _client + .Setup(x => x.Flows.TryLoginAsync( + It.IsAny(), + UAuthSubmitMode.TryOnly, + It.IsAny())) + .ReturnsAsync(result); + + IUAuthTryResult? emitted = null; + + var cut = Render(p => p + .Add(x => x.Identifier, "alice") + .Add(x => x.Secret, "secret") + .Add(x => x.SubmitMode, UAuthSubmitMode.TryOnly) + .Add(x => x.OnTryResult, + r => emitted = r)); + + await cut.Instance.SubmitAsync(); + + emitted.Should().BeSameAs(result); + } + + // ----------------------------------------------------------------- + // Password / TryAndCommit + // ----------------------------------------------------------------- + + [Fact] + public async Task Password_TryAndCommit_SendsEffectiveReturnUrl() + { + string? capturedReturnUrl = null; + + var result = new TryLoginResult + { + Success = true + }; + + _client + .Setup(x => x.Flows.TryLoginAsync( + It.IsAny(), + UAuthSubmitMode.TryAndCommit, + It.IsAny())) + .Callback( + (_, _, returnUrl) => + capturedReturnUrl = returnUrl) + .ReturnsAsync(result); + + var cut = Render(p => p + .Add(x => x.Identifier, "alice") + .Add(x => x.Secret, "secret") + .Add(x => x.ReturnUrl, "/dashboard") + .Add(x => x.SubmitMode, UAuthSubmitMode.TryAndCommit)); + + await cut.Instance.SubmitAsync(); + + capturedReturnUrl.Should().Be("/dashboard"); + } + + [Fact] + public async Task Password_TryAndCommit_EmitsTryResult() + { + var result = CreateTryLoginResult(); + + _client + .Setup(x => x.Flows.TryLoginAsync( + It.IsAny(), + UAuthSubmitMode.TryAndCommit, + It.IsAny())) + .ReturnsAsync(result); + + IUAuthTryResult? emitted = null; + + var cut = Render(p => p + .Add(x => x.Identifier, "alice") + .Add(x => x.Secret, "secret") + .Add(x => x.ReturnUrl, "/home") + .Add(x => x.SubmitMode, UAuthSubmitMode.TryAndCommit) + .Add(x => x.OnTryResult, + r => emitted = r)); + + await cut.Instance.SubmitAsync(); + + emitted.Should().BeSameAs(result); + } + + // ----------------------------------------------------------------- + // Password / DirectCommit + // ----------------------------------------------------------------- + + [Fact] + public async Task Password_DirectCommit_UsesNativeFormSubmission() + { + var cut = RenderPasswordForm( + "alice", + "secret", + UAuthSubmitMode.DirectCommit); + + await cut.Instance.SubmitAsync(); + + JSInterop.VerifyInvoke("uauth.submitForm"); + } + + [Fact] + public async Task Password_DirectCommit_DoesNotCallTryLogin() + { + var cut = RenderPasswordForm( + "alice", + "secret", + UAuthSubmitMode.DirectCommit); + + await cut.Instance.SubmitAsync(); + + _client.Verify( + x => x.Flows.TryLoginAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Password_DirectCommit_DoesNotEmitTryResult() + { + var callbackCount = 0; + + var cut = Render(p => p + .Add(x => x.Identifier, "alice") + .Add(x => x.Secret, "secret") + .Add(x => x.SubmitMode, UAuthSubmitMode.DirectCommit) + .Add(x => x.OnTryResult, + _ => callbackCount++)); + + await cut.Instance.SubmitAsync(); + + callbackCount.Should().Be(0); + } + + [Fact] + public async Task Password_DirectCommit_DoesNotCallFlowClient() + { + var cut = RenderPasswordForm( + "alice", + "secret", + UAuthSubmitMode.DirectCommit); + + await cut.Instance.SubmitAsync(); + + _client.Verify( + x => x.Flows.TryLoginAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + + _client.Verify( + x => x.Flows.LoginAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + // ----------------------------------------------------------------- + // PKCE validation + // ----------------------------------------------------------------- + + [Fact] + public void Pkce_WhenHubDoesNotSupportPkce_Throws() + { + _hubCapabilities + .SetupGet(x => x.SupportsPkce) + .Returns(false); + + var hubId = HubSessionId.New(); + + var act = () => Render(p => p + .Add(x => x.LoginType, UAuthLoginType.Pkce) + .Add(x => x.HubSessionId, hubId)); + + act.Should() + .Throw() + .WithMessage("*PKCE login requires UAuthHub*"); + } + + [Fact] + public async Task Pkce_WhenCredentialsMissing_Throws() + { + var hubId = HubSessionId.New(); + + _credentialResolver + .Setup(x => x.ResolveAsync( + hubId, + It.IsAny())) + .ReturnsAsync((HubCredentials?)null); + + _hubFlowReader + .Setup(x => x.GetStateAsync( + hubId, + It.IsAny())) + .ReturnsAsync(CreateHubState(hubId)); + + var cut = RenderPkceForm( + hubId, + "alice", + "secret", + UAuthSubmitMode.TryOnly); + + var act = () => cut.Instance.SubmitAsync(); + + await act.Should() + .ThrowAsync() + .WithMessage("Missing PKCE credentials."); + } + + // ----------------------------------------------------------------- + // PKCE / TryOnly + // ----------------------------------------------------------------- + + [Fact] + public async Task Pkce_TryOnly_BuildsExpectedRequest() + { + var hubId = HubSessionId.New(); + + SetupPkce( + hubId, + authorizationCode: "auth-code", + codeVerifier: "verifier", + returnUrl: "/dashboard"); + + PkceCompleteRequest? captured = null; + + var result = new TryPkceLoginResult + { + Success = true + }; + + _client + .Setup(x => x.Flows.TryCompletePkceLoginAsync( + It.IsAny(), + UAuthSubmitMode.TryOnly)) + .Callback( + (request, _) => captured = request) + .ReturnsAsync(result); + + var cut = RenderPkceForm( + hubId, + "alice@example.com", + "secret-123", + UAuthSubmitMode.TryOnly); + + await cut.Instance.SubmitAsync(); + + captured.Should().NotBeNull(); + + captured!.Identifier.Should().Be("alice@example.com"); + captured.Secret.Should().Be("secret-123"); + + captured.AuthorizationCode.Should().Be("auth-code"); + captured.CodeVerifier.Should().Be("verifier"); + + captured.ReturnUrl.Should().Be("/dashboard"); + captured.HubSessionId.Should().Be(hubId.Value); + } + + [Fact] + public async Task Pkce_TryOnly_EmitsTryResult() + { + var hubId = HubSessionId.New(); + + SetupPkce(hubId); + + var result = CreateTryPkceLoginResult(); + + _client + .Setup(x => x.Flows.TryCompletePkceLoginAsync( + It.IsAny(), + UAuthSubmitMode.TryOnly)) + .ReturnsAsync(result); + + IUAuthTryResult? emitted = null; + + var cut = Render(p => p + .Add(x => x.LoginType, UAuthLoginType.Pkce) + .Add(x => x.HubSessionId, hubId) + .Add(x => x.Identifier, "alice") + .Add(x => x.Secret, "secret") + .Add(x => x.SubmitMode, UAuthSubmitMode.TryOnly) + .Add(x => x.OnTryResult, + r => emitted = r)); + + await cut.Instance.SubmitAsync(); + + emitted.Should().BeSameAs(result); + } + + // ----------------------------------------------------------------- + // PKCE / TryAndCommit + // ----------------------------------------------------------------- + + [Fact] + public async Task Pkce_TryAndCommit_UsesTryAndCommitMode() + { + var hubId = HubSessionId.New(); + + SetupPkce(hubId); + + var result = new TryPkceLoginResult + { + Success = true + }; + + _client + .Setup(x => x.Flows.TryCompletePkceLoginAsync( + It.IsAny(), + UAuthSubmitMode.TryAndCommit)) + .ReturnsAsync(result); + + var cut = RenderPkceForm( + hubId, + "alice", + "secret", + UAuthSubmitMode.TryAndCommit); + + await cut.Instance.SubmitAsync(); + + _client.Verify( + x => x.Flows.TryCompletePkceLoginAsync( + It.Is(r => + r.Identifier == "alice" && + r.Secret == "secret" && + r.HubSessionId == hubId.Value), + UAuthSubmitMode.TryAndCommit), + Times.Once); + } + + // ----------------------------------------------------------------- + // PKCE / DirectCommit + // ----------------------------------------------------------------- + + [Fact] + public async Task Pkce_DirectCommit_CompletesPkceDirectly() + { + var hubId = HubSessionId.New(); + + SetupPkce(hubId); + + _flows + .Setup(x => x.CompletePkceLoginAsync( + It.IsAny())) + .Returns(Task.CompletedTask); + + var cut = Render(p => p + .Add(x => x.LoginType, UAuthLoginType.Pkce) + .Add(x => x.HubSessionId, hubId) + .Add(x => x.Identifier, "alice") + .Add(x => x.Secret, "secret") + .Add(x => x.ReturnUrl, "/home") + .Add(x => x.SubmitMode, UAuthSubmitMode.DirectCommit)); + + await cut.Instance.SubmitAsync(); + + _flows.Verify( + x => x.CompletePkceLoginAsync( + It.Is(r => + r.Identifier == "alice" && + r.Secret == "secret" && + r.ReturnUrl == "/home")), + Times.Once); + } + + [Fact] + public async Task Pkce_DirectCommit_DoesNotUseNativeFormSubmission() + { + var hubId = HubSessionId.New(); + + SetupPkce(hubId); + + _flows + .Setup(x => x.CompletePkceLoginAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + var cut = RenderPkceForm( + hubId, + "alice", + "secret", + UAuthSubmitMode.DirectCommit); + + await cut.Instance.SubmitAsync(); + + JSInterop.Invocations + .Should() + .NotContain(x => x.Identifier == "uauth.submitForm"); + } + + [Fact] + public async Task Pkce_DirectCommit_DoesNotEmitTryResult() + { + var hubId = HubSessionId.New(); + + SetupPkce(hubId); + + _flows + .Setup(x => x.CompletePkceLoginAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + var callbackCount = 0; + + var cut = Render(p => p + .Add(x => x.LoginType, UAuthLoginType.Pkce) + .Add(x => x.HubSessionId, hubId) + .Add(x => x.Identifier, "alice") + .Add(x => x.Secret, "secret") + .Add(x => x.SubmitMode, UAuthSubmitMode.DirectCommit) + .Add(x => x.OnTryResult, + _ => callbackCount++)); + + await cut.Instance.SubmitAsync(); + + callbackCount.Should().Be(0); + } + + // ----------------------------------------------------------------- + // Hub resolution + // ----------------------------------------------------------------- + + [Fact] + public async Task Pkce_HubSessionIdFromQuery_IsUsed() + { + var hubId = HubSessionId.New(); + + SetupPkce(hubId); + + var nav = Services + .GetRequiredService(); + + nav.NavigateTo( + $"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/login?{UAuthConstants.Query.Hub}={Uri.EscapeDataString(hubId.Value)}"); + + var result = CreateTryPkceLoginResult(); + + _client + .Setup(x => x.Flows.TryCompletePkceLoginAsync( + It.IsAny(), + UAuthSubmitMode.TryOnly)) + .ReturnsAsync(result); + + var cut = Render(p => p + .Add(x => x.LoginType, UAuthLoginType.Pkce) + .Add(x => x.Identifier, "alice") + .Add(x => x.Secret, "secret") + .Add(x => x.SubmitMode, UAuthSubmitMode.TryOnly)); + + await cut.Instance.SubmitAsync(); + + _credentialResolver.Verify( + x => x.ResolveAsync( + hubId, + It.IsAny()), + Times.Once); + } + + // ----------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------- + + private IRenderedComponent RenderPasswordForm( + string? identifier, + string? secret, + UAuthSubmitMode mode) + { + return Render(p => p + .Add(x => x.Identifier, identifier) + .Add(x => x.Secret, secret) + .Add(x => x.LoginType, UAuthLoginType.Password) + .Add(x => x.SubmitMode, mode)); + } + + private IRenderedComponent RenderPkceForm( + HubSessionId hubId, + string identifier, + string secret, + UAuthSubmitMode mode) + { + return Render(p => p + .Add(x => x.Identifier, identifier) + .Add(x => x.Secret, secret) + .Add(x => x.LoginType, UAuthLoginType.Pkce) + .Add(x => x.HubSessionId, hubId) + .Add(x => x.SubmitMode, mode)); + } + + private void SetupPkce( + HubSessionId hubId, + string authorizationCode = "authorization-code", + string codeVerifier = "code-verifier", + string returnUrl = "/home") + { + var credentials = new HubCredentials + { + AuthorizationCode = authorizationCode, + CodeVerifier = codeVerifier + }; + + _credentialResolver + .Setup(x => x.ResolveAsync( + hubId, + It.IsAny())) + .ReturnsAsync(credentials); + + _hubFlowReader + .Setup(x => x.GetStateAsync( + hubId, + It.IsAny())) + .ReturnsAsync( + CreateHubState( + hubId, + returnUrl)); + } + + private static HubFlowState CreateHubState(HubSessionId hubId, string returnUrl = "/home") + { + return new HubFlowState + { + HubSessionId = hubId, + Exists = true, + IsActive = true, + IsExpired = false, + IsCompleted = false, + ReturnUrl = returnUrl + }; + } + + private static TryLoginResult CreateTryLoginResult() + { + return new TryLoginResult + { + Success = true + }; + } + + private static TryPkceLoginResult CreateTryPkceLoginResult() + { + return new TryPkceLoginResult + { + Success = true + }; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthLoginFormTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthLoginFormTests.cs new file mode 100644 index 00000000..50832445 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthLoginFormTests.cs @@ -0,0 +1,755 @@ +ο»Ώusing Bunit; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Client.Device; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using FluentAssertions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.JSInterop; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Client.Blazor; + +public sealed class UAuthLoginFormTests : BunitContext +{ + private readonly Mock _deviceIdProvider = new(); + private readonly Mock _uauthClient = new(); + private readonly Mock _hubCredentialResolver = new(); + private readonly Mock _hubFlowReader = new(); + private readonly Mock _hubCapabilities = new(); + + public UAuthLoginFormTests() + { + var options = new UAuthClientOptions(); + + Services.AddSingleton>( + Options.Create(options)); + + Services.AddSingleton(_deviceIdProvider.Object); + Services.AddSingleton(_uauthClient.Object); + Services.AddSingleton(_hubCredentialResolver.Object); + Services.AddSingleton(_hubFlowReader.Object); + Services.AddSingleton(_hubCapabilities.Object); + + _deviceIdProvider + .Setup(x => x.GetOrCreateAsync( + It.IsAny())) + .ReturnsAsync(default(DeviceId)); + + _hubCapabilities + .SetupGet(x => x.SupportsPkce) + .Returns(true); + } + + // ----------------------------------------------------------------- + // Basic rendering + // ----------------------------------------------------------------- + + [Fact] + public void Render_RendersForm() + { + var cut = RenderLoginForm(); + + cut.FindAll("form").Should().ContainSingle(); + } + + [Fact] + public void Render_RendersIdentifierHiddenInput() + { + var cut = RenderLoginForm( + identifier: "alice@example.com"); + + var input = cut.Find("input[name='Identifier']"); + + input.GetAttribute("value") + .Should() + .Be("alice@example.com"); + } + + [Fact] + public void Render_RendersSecretHiddenInput() + { + var cut = RenderLoginForm( + secret: "super-secret"); + + var input = cut.Find("input[name='Secret']"); + + input.GetAttribute("value") + .Should() + .Be("super-secret"); + } + + [Fact] + public void Render_SecretInput_DisablesAutocomplete() + { + var cut = RenderLoginForm( + secret: "secret"); + + var input = cut.Find("input[name='Secret']"); + + input.GetAttribute("autocomplete") + .Should() + .Be("off"); + } + + // ----------------------------------------------------------------- + // UltimateAuth form contract + // ----------------------------------------------------------------- + + [Fact] + public void Render_UsesClientProfileFormContractName() + { + var cut = RenderLoginForm(); + + cut.FindAll( + $"input[name='{UAuthConstants.Form.ClientProfile}']") + .Should() + .ContainSingle(); + } + + [Fact] + public void Render_UsesDeviceFormContractName() + { + var cut = RenderLoginForm(); + + cut.FindAll( + $"input[name='{UAuthConstants.Form.Device}']") + .Should() + .ContainSingle(); + } + + [Fact] + public void DirectCommit_RendersReturnUrlUsingFormContractName() + { + var cut = RenderLoginForm( + returnUrl: "/home", + submitMode: UAuthSubmitMode.DirectCommit); + + var input = cut.Find( + $"input[name='{UAuthConstants.Form.ReturnUrl}']"); + + input.GetAttribute("value") + .Should() + .Be("/home"); + } + + [Theory] + [InlineData(UAuthSubmitMode.TryOnly)] + [InlineData(UAuthSubmitMode.TryAndCommit)] + public void NonDirectCommit_DoesNotRenderReturnUrlFormField( + UAuthSubmitMode submitMode) + { + var cut = RenderLoginForm( + returnUrl: "/home", + submitMode: submitMode); + + cut.FindAll( + $"input[name='{UAuthConstants.Form.ReturnUrl}']") + .Should() + .BeEmpty(); + } + + // ----------------------------------------------------------------- + // Password / PKCE markup + // ----------------------------------------------------------------- + + [Fact] + public void PasswordLogin_DoesNotRenderAuthorizationCode() + { + var cut = RenderLoginForm( + loginType: UAuthLoginType.Password); + + cut.FindAll( + "input[name='authorization_code']") + .Should() + .BeEmpty(); + } + + [Fact] + public void PasswordLogin_DoesNotRenderCodeVerifier() + { + var cut = RenderLoginForm( + loginType: UAuthLoginType.Password); + + cut.FindAll( + "input[name='code_verifier']") + .Should() + .BeEmpty(); + } + + // ----------------------------------------------------------------- + // Enter-key behavior + // ----------------------------------------------------------------- + + [Fact] + public void AllowEnterKeyToSubmitTrue_RendersHiddenSubmitButton() + { + var cut = RenderLoginForm( + allowEnterKeyToSubmit: true); + + var buttons = cut.FindAll( + "button[type='submit']"); + + buttons.Should().ContainSingle(); + + buttons[0] + .HasAttribute("hidden") + .Should() + .BeTrue(); + } + + [Fact] + public void AllowEnterKeyToSubmitFalse_DoesNotRenderSubmitButton() + { + var cut = RenderLoginForm( + allowEnterKeyToSubmit: false); + + cut.FindAll( + "button[type='submit']") + .Should() + .BeEmpty(); + } + + // ----------------------------------------------------------------- + // Child content + // ----------------------------------------------------------------- + + [Fact] + public void Render_RendersChildContent() + { + var cut = RenderLoginForm( + childContent: builder => + { + builder.AddMarkupContent( + 0, + "Login UI"); + }); + + cut.Find("#login-content") + .TextContent + .Should() + .Be("Login UI"); + } + + // ----------------------------------------------------------------- + // Endpoint + // ----------------------------------------------------------------- + + [Fact] + public void ExplicitEndpoint_IsUsedAsFormAction() + { + var cut = RenderLoginForm( + endpoint: "/custom-login"); + + var form = cut.Find("form"); + + form.GetAttribute("action") + .Should() + .Contain("/custom-login"); + } + + [Fact] + public void ReturnUrl_IsAddedToEndpointUsingQueryContract() + { + var cut = RenderLoginForm( + endpoint: "/custom-login", + returnUrl: "/home"); + + var action = cut + .Find("form") + .GetAttribute("action"); + + action.Should().Contain( + $"{UAuthConstants.Query.ReturnUrl}="); + + action.Should().Contain( + Uri.EscapeDataString("/home")); + } + + [Fact] + public void ReturnUrl_IsNotAddedUsingFormContractNameAsQueryParameter() + { + UAuthConstants.Query.ReturnUrl + .Should() + .NotBe(UAuthConstants.Form.ReturnUrl); + + var cut = RenderLoginForm( + endpoint: "/custom-login", + returnUrl: "/home"); + + var action = cut + .Find("form") + .GetAttribute("action"); + + action.Should().Contain( + $"{UAuthConstants.Query.ReturnUrl}="); + } + + // ----------------------------------------------------------------- + // Effective return URL + // ----------------------------------------------------------------- + + [Fact] + public void DirectCommit_WithExplicitReturnUrl_UsesExplicitReturnUrl() + { + Navigate("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/login"); + + var cut = RenderLoginForm( + returnUrl: "/dashboard", + submitMode: UAuthSubmitMode.DirectCommit); + + var input = cut.Find( + $"input[name='{UAuthConstants.Form.ReturnUrl}']"); + + input.GetAttribute("value") + .Should() + .Be("/dashboard"); + } + + [Fact] + public void PasswordDirectCommit_WithoutExplicitReturnUrl_UsesCurrentNavigationUri() + { + Navigate("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/login?foo=bar"); + + var cut = RenderLoginForm( + submitMode: UAuthSubmitMode.DirectCommit, + loginType: UAuthLoginType.Password); + + var input = cut.Find( + $"input[name='{UAuthConstants.Form.ReturnUrl}']"); + + input.GetAttribute("value") + .Should() + .Be(Nav.Uri); + } + + // ----------------------------------------------------------------- + // Device initialization + // ----------------------------------------------------------------- + + [Fact] + public void Render_RequestsDeviceIdExactlyOnce() + { + RenderLoginForm(); + + _deviceIdProvider.Verify( + x => x.GetOrCreateAsync( + It.IsAny()), + Times.Once); + } + + // ----------------------------------------------------------------- + // Password must not touch Hub + // ----------------------------------------------------------------- + + [Fact] + public void PasswordLogin_DoesNotResolveHubCredentials() + { + RenderLoginForm( + loginType: UAuthLoginType.Password); + + _hubCredentialResolver.Verify( + x => x.ResolveAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public void PasswordLogin_DoesNotReadHubState() + { + RenderLoginForm( + loginType: UAuthLoginType.Password); + + _hubFlowReader.Verify( + x => x.GetStateAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + // ----------------------------------------------------------------- + // PKCE / Hub + // ----------------------------------------------------------------- + + [Fact] + public void Pkce_WhenHubIsNotSupported_ThrowsInvalidOperationException() + { + _hubCapabilities + .SetupGet(x => x.SupportsPkce) + .Returns(false); + + var act = () => RenderLoginForm( + loginType: UAuthLoginType.Pkce); + + act.Should() + .Throw() + .WithMessage("*PKCE login requires UAuthHub*"); + } + + [Fact] + public void Pkce_WithExplicitHubSessionId_ResolvesCredentials() + { + var hubId = HubSessionId.New(); + + SetupPkceHub(hubId); + + RenderPkceLoginForm(hubId); + + _hubCredentialResolver.Verify( + x => x.ResolveAsync( + hubId, + It.IsAny()), + Times.Once); + } + + [Fact] + public void Pkce_WithExplicitHubSessionId_ReadsHubState() + { + var hubId = HubSessionId.New(); + + SetupPkceHub(hubId); + + RenderPkceLoginForm(hubId); + + _hubFlowReader.Verify( + x => x.GetStateAsync( + hubId, + It.IsAny()), + Times.Once); + } + + [Fact] + public void Pkce_WithHubInQuery_ResolvesCredentials() + { + var hubId = HubSessionId.New(); + + SetupPkceHub(hubId); + + Navigate( + $"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/login?{UAuthConstants.Query.Hub}=" + + Uri.EscapeDataString(hubId.Value)); + + RenderLoginForm( + loginType: UAuthLoginType.Pkce); + + _hubCredentialResolver.Verify( + x => x.ResolveAsync( + hubId, + It.IsAny()), + Times.Once); + } + + [Fact] + public void Pkce_WithHubInQuery_ReadsHubState() + { + var hubId = HubSessionId.New(); + + SetupPkceHub(hubId); + + Navigate( + $"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/login?{UAuthConstants.Query.Hub}=" + + Uri.EscapeDataString(hubId.Value)); + + RenderLoginForm( + loginType: UAuthLoginType.Pkce); + + _hubFlowReader.Verify( + x => x.GetStateAsync( + hubId, + It.IsAny()), + Times.Once); + } + + [Fact] + public void Pkce_WithInvalidHubInQuery_DoesNotResolveCredentials() + { + Navigate( + $"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/login?{UAuthConstants.Query.Hub}=invalid-hub"); + + RenderLoginForm( + loginType: UAuthLoginType.Pkce); + + _hubCredentialResolver.Verify( + x => x.ResolveAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public void Pkce_WithInvalidHubInQuery_DoesNotReadHubState() + { + Navigate( + $"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/login?{UAuthConstants.Query.Hub}=invalid-hub"); + + RenderLoginForm( + loginType: UAuthLoginType.Pkce); + + _hubFlowReader.Verify( + x => x.GetStateAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public void Pkce_ExplicitHubSessionId_TakesPrecedenceOverQueryHub() + { + var parameterHub = HubSessionId.New(); + var queryHub = HubSessionId.New(); + + SetupPkceHub(parameterHub); + + Navigate( + $"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/login?{UAuthConstants.Query.Hub}=" + + Uri.EscapeDataString(queryHub.Value)); + + RenderPkceLoginForm(parameterHub); + + _hubCredentialResolver.Verify( + x => x.ResolveAsync( + parameterHub, + It.IsAny()), + Times.Once); + + _hubCredentialResolver.Verify( + x => x.ResolveAsync( + queryHub, + It.IsAny()), + Times.Never); + } + + [Fact] + public void Pkce_WithCredentials_RendersAuthorizationCode() + { + var hubId = HubSessionId.New(); + + SetupPkceHub( + hubId, + authorizationCode: "auth-code-123", + codeVerifier: "verifier-456"); + + var cut = RenderPkceLoginForm(hubId); + + var input = cut.Find( + "input[name='authorization_code']"); + + input.GetAttribute("value") + .Should() + .Be("auth-code-123"); + } + + [Fact] + public void Pkce_WithCredentials_RendersCodeVerifier() + { + var hubId = HubSessionId.New(); + + SetupPkceHub( + hubId, + authorizationCode: "auth-code-123", + codeVerifier: "verifier-456"); + + var cut = RenderPkceLoginForm(hubId); + + var input = cut.Find( + "input[name='code_verifier']"); + + input.GetAttribute("value") + .Should() + .Be("verifier-456"); + } + + [Fact] + public void Pkce_WithoutExplicitReturnUrl_UsesHubFlowReturnUrl() + { + var hubId = HubSessionId.New(); + + SetupPkceHub( + hubId, + returnUrl: "/from-hub"); + + var cut = RenderPkceLoginForm( + hubId, + submitMode: UAuthSubmitMode.DirectCommit); + + var input = cut.Find( + $"input[name='{UAuthConstants.Form.ReturnUrl}']"); + + input.GetAttribute("value") + .Should() + .Be("/from-hub"); + } + + [Fact] + public void Pkce_ExplicitReturnUrl_TakesPrecedenceOverHubFlowReturnUrl() + { + var hubId = HubSessionId.New(); + + SetupPkceHub( + hubId, + returnUrl: "/from-hub"); + + var cut = RenderPkceLoginForm( + hubId, + returnUrl: "/explicit", + submitMode: UAuthSubmitMode.DirectCommit); + + var input = cut.Find( + $"input[name='{UAuthConstants.Form.ReturnUrl}']"); + + input.GetAttribute("value") + .Should() + .Be("/explicit"); + } + + [Fact] + public void PkceEndpoint_WithReturnUrl_UsesQueryReturnUrlContract() + { + var hubId = HubSessionId.New(); + + SetupPkceHub( + hubId, + returnUrl: "/dashboard"); + + var cut = RenderPkceLoginForm(hubId); + + var action = cut + .Find("form") + .GetAttribute("action"); + + action.Should() + .Contain($"{UAuthConstants.Query.ReturnUrl}="); + + action.Should() + .Contain(Uri.EscapeDataString("/dashboard")); + } + + [Fact] + public void PkceEndpoint_WithHub_UsesUAuthHubQueryContract() + { + var hubId = HubSessionId.New(); + + SetupPkceHub(hubId); + + var cut = RenderPkceLoginForm(hubId); + + var action = cut + .Find("form") + .GetAttribute("action"); + + action.Should() + .Contain($"{UAuthConstants.Query.Hub}="); + } + + // ----------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------- + + private IRenderedComponent RenderLoginForm( + string? identifier = "alice", + string? secret = "secret", + string? endpoint = null, + string? returnUrl = null, + UAuthLoginType loginType = UAuthLoginType.Password, + UAuthSubmitMode submitMode = UAuthSubmitMode.TryAndCommit, + bool allowEnterKeyToSubmit = true, + RenderFragment? childContent = null) + { + return Render(parameters => + { + parameters + .Add(x => x.Identifier, identifier) + .Add(x => x.Secret, secret) + .Add(x => x.LoginType, loginType) + .Add(x => x.SubmitMode, submitMode) + .Add( + x => x.AllowEnterKeyToSubmit, + allowEnterKeyToSubmit); + + if (endpoint is not null) + parameters.Add(x => x.Endpoint, endpoint); + + if (returnUrl is not null) + parameters.Add(x => x.ReturnUrl, returnUrl); + + if (childContent is not null) + parameters.Add( + x => x.ChildContent, + childContent); + }); + } + + private void SetupPkceHub( + HubSessionId hubId, + string authorizationCode = "authorization-code", + string codeVerifier = "code-verifier", + string? returnUrl = "/home", + bool exists = true, + bool active = true) + { + var credentials = new HubCredentials + { + AuthorizationCode = authorizationCode, + CodeVerifier = codeVerifier + }; + + var state = new HubFlowState + { + HubSessionId = hubId, + ReturnUrl = returnUrl, + Exists = exists, + IsActive = active + }; + + _hubCredentialResolver + .Setup(x => x.ResolveAsync( + hubId, + It.IsAny())) + .ReturnsAsync(credentials); + + _hubFlowReader + .Setup(x => x.GetStateAsync( + hubId, + It.IsAny())) + .ReturnsAsync(state); + } + + private IRenderedComponent RenderPkceLoginForm( + HubSessionId hubId, + string? returnUrl = null, + UAuthSubmitMode submitMode = UAuthSubmitMode.TryAndCommit) + { + return Render(parameters => + { + parameters + .Add(x => x.Identifier, "alice") + .Add(x => x.Secret, "secret") + .Add(x => x.HubSessionId, hubId) + .Add(x => x.LoginType, UAuthLoginType.Pkce) + .Add(x => x.SubmitMode, submitMode); + + if (returnUrl is not null) + { + parameters.Add( + x => x.ReturnUrl, + returnUrl); + } + }); + } + + private void Navigate(string relativeUri) + { + Nav.NavigateTo(relativeUri); + } + + private NavigationManager Nav => + Services.GetRequiredService(); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthPageBaseTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthPageBaseTests.cs new file mode 100644 index 00000000..532da561 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthPageBaseTests.cs @@ -0,0 +1,553 @@ +ο»Ώusing Bunit; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using FluentAssertions; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Client.Blazor; + +public sealed class UAuthPageBaseTests : BunitContext +{ + [Fact] + public void Render_WithoutQuery_UsesDefaultValues() + { + var state = UAuthState.Anonymous(); + + var cut = RenderPage(state); + + var page = cut.FindComponent().Instance; + + page.ParsedPayload.Should().BeNull(); + page.ParsedReturnUrl.Should().BeNull(); + page.ParsedIdentifier.Should().BeNull(); + page.HasFocusRequest.Should().BeFalse(); + + page.PayloadCallbackCount.Should().Be(0); + page.FocusCallbackCount.Should().Be(0); + } + + [Fact] + public void Render_WithFocusOne_InvokesFocusCallbackExactlyOnce() + { + var state = UAuthState.Anonymous(); + + Navigate("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/login?uauth_focus=1"); + + var cut = RenderPage(state); + + var page = cut.FindComponent().Instance; + + page.FocusCallbackCount.Should().Be(1); + } + + [Theory] + [InlineData("0")] + [InlineData("true")] + [InlineData("false")] + [InlineData("yes")] + [InlineData("2")] + public void Render_WithNonOneFocus_DoesNotInvokeFocusCallback( + string focus) + { + var state = UAuthState.Anonymous(); + + Navigate($"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/login?uauth_focus={focus}"); + + var cut = RenderPage(state); + + var page = cut.FindComponent().Instance; + + page.FocusCallbackCount.Should().Be(0); + } + + [Fact] + public void Render_WithReturnUrl_ParsesReturnUrl() + { + var state = UAuthState.Anonymous(); + + Navigate("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/login?uauth_return_url=%2Fdashboard%3Ftab%3Dsecurity"); + + var cut = RenderPage(state); + + var page = cut.FindComponent().Instance; + + page.ParsedReturnUrl + .Should() + .Be("/dashboard?tab=security"); + } + + [Fact] + public void Render_WithIdentifier_ParsesIdentifier() + { + var state = UAuthState.Anonymous(); + + Navigate("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/login?uauth_identifier=alice%40example.com"); + + var cut = RenderPage(state); + + var page = cut.FindComponent().Instance; + + page.ParsedIdentifier.Should().Be("alice@example.com"); + } + + [Fact] + public void Render_WithInvalidBase64Payload_DoesNotThrow() + { + var state = UAuthState.Anonymous(); + + Navigate("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/login?uauth=%25%25%25invalid%25%25%25"); + + var act = () => RenderPage(state); + + act.Should().NotThrow(); + } + + [Fact] + public void Render_WithInvalidBase64Payload_LeavesPayloadNull() + { + var state = UAuthState.Anonymous(); + + Navigate("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/login?uauth=%25%25%25invalid%25%25%25"); + + var cut = RenderPage(state); + + var page = cut.FindComponent().Instance; + + page.ParsedPayload.Should().BeNull(); + page.PayloadCallbackCount.Should().Be(0); + } + + [Fact] + public void Render_WithValidBase64ButInvalidJson_LeavesPayloadNull() + { + var state = UAuthState.Anonymous(); + + var encoded = Microsoft.AspNetCore.WebUtilities.WebEncoders + .Base64UrlEncode( + System.Text.Encoding.UTF8.GetBytes("not-json")); + + Navigate($"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/login?uauth={encoded}"); + + var cut = RenderPage(state); + + var page = cut.FindComponent().Instance; + + page.ParsedPayload.Should().BeNull(); + page.PayloadCallbackCount.Should().Be(0); + } + + [Fact] + public void Render_DefaultBehavior_ClearsQueryAfterProcessing() + { + var state = UAuthState.Anonymous(); + + Navigate("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/login?uauth_identifier=alice%40example.com&uauth_focus=1"); + + RenderPage(state); + + Nav.Uri.Should().Be("http://localhost/login"); + } + + [Fact] + public void Render_WhenClearQueryDisabled_PreservesQuery() + { + var state = UAuthState.Anonymous(); + + Navigate("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/login?uauth_identifier=alice%40example.com&uauth_focus=1"); + + RenderPage(state); + + Nav.Uri.Should() + .Contain("uauth_identifier=alice%40example.com"); + + Nav.Uri.Should() + .Contain("uauth_focus=1"); + } + + [Fact] + public void Render_QueryCleanup_PreservesPath() + { + var state = UAuthState.Anonymous(); + + Navigate("/account/security/login?uauth_focus=1"); + + RenderPage(state); + + Nav.Uri.Should() + .Be("http://localhost/account/security/login"); + } + + [Fact] + public void Render_WithFocusAndQueryCleanup_StillInvokesFocusBeforeCleanup() + { + var state = UAuthState.Anonymous(); + + Navigate("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/login?uauth_focus=1"); + + var cut = RenderPage(state); + + var page = cut.FindComponent().Instance; + + page.FocusCallbackCount.Should().Be(1); + Nav.Uri.Should().Be("http://localhost/login"); + } + + [Fact] + public void Render_WithIdentifierAndQueryCleanup_ParsesValueBeforeCleanup() + { + var state = UAuthState.Anonymous(); + + Navigate("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/login?uauth_identifier=alice%40example.com"); + + var cut = RenderPage(state); + + var page = cut.FindComponent().Instance; + + page.ParsedIdentifier.Should().Be("alice@example.com"); + Nav.Uri.Should().Be("http://localhost/login"); + } + + [Fact] + public void Render_QueryCleanup_PreservesUnrelatedQueryParameters() + { + var state = UAuthState.Anonymous(); + + Navigate( + "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/login?uauth_focus=1&culture=tr-TR&theme=dark"); + + RenderPage(state); + + Nav.Uri.Should().Contain("culture=tr-TR"); + Nav.Uri.Should().Contain("theme=dark"); + Nav.Uri.Should().NotContain("uauth_focus"); + } + + [Fact] + public void Render_WithOnlyUnrelatedQuery_DoesNotClearQuery() + { + var state = UAuthState.Anonymous(); + + Navigate("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/login?culture=tr-TR&theme=dark"); + + RenderPage(state); + + Nav.Uri.Should() + .Be("http://localhost/login?culture=tr-TR&theme=dark"); + } + + [Fact] + public void Render_QueryCleanup_RemovesAllConsumedUAuthParameters() + { + var state = UAuthState.Anonymous(); + + Navigate( + "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/login?uauth_focus=1" + + "&uauth_identifier=alice" + + "&uauth_return_url=%2Fdashboard" + + "&culture=tr-TR"); + + RenderPage(state); + + Nav.Uri.Should().Be( + "http://localhost/login?culture=tr-TR"); + } + + [Fact] + public void Render_QueryCleanup_PreservesMultipleValuesOfUnrelatedParameter() + { + var state = UAuthState.Anonymous(); + + Navigate( + "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/login?uauth_focus=1&tag=one&tag=two"); + + RenderPage(state); + + Nav.Uri.Should().Contain("tag=one"); + Nav.Uri.Should().Contain("tag=two"); + Nav.Uri.Should().NotContain("uauth_focus"); + } + + [Fact] + public void Render_WithValidPayload_ParsesPayload() + { + var state = UAuthState.Anonymous(); + var payload = CreatePayload(); + var encoded = EncodePayload(payload); + + Navigate($"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/login?uauth={encoded}"); + + var cut = RenderPage(state); + + var page = cut.FindComponent().Instance; + + page.ParsedPayload.Should().NotBeNull(); + page.LastPayload.Should().NotBeNull(); + } + + [Fact] + public void Render_WithValidPayload_InvokesPayloadCallbackExactlyOnce() + { + var state = UAuthState.Anonymous(); + var payload = CreatePayload(); + var encoded = EncodePayload(payload); + + Navigate($"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/login?uauth={encoded}"); + + var cut = RenderPage(state); + + var page = cut.FindComponent().Instance; + + page.PayloadCallbackCount.Should().Be(1); + } + + [Fact] + public void AuthStateRerender_DoesNotConsumeSamePayloadAgain() + { + var state = UAuthState.Anonymous(); + var payload = CreatePayload(); + var encoded = EncodePayload(payload); + + Navigate($"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/login?uauth={encoded}"); + + var cut = RenderPage(state); + + var page = cut + .FindComponent() + .Instance; + + page.PayloadCallbackCount.Should().Be(1); + + state.Touch(); + + cut.WaitForAssertion(() => + { + page.PayloadCallbackCount.Should().Be(1); + }); + } + + [Fact] + public void NewUri_AllowsNewPayloadToBeConsumed() + { + var state = UAuthState.Anonymous(); + + var firstPayload = CreatePayload( + status: "failed", + reason: AuthFailureReason.InvalidCredentials, + remainingAttempts: 2); + + var firstEncoded = EncodePayload(firstPayload); + + Navigate($"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/login?uauth={firstEncoded}"); + + var cut = RenderPage(state); + + var page = cut + .FindComponent() + .Instance; + + page.PayloadCallbackCount.Should().Be(1); + page.LastPayload!.Status.Should().Be("failed"); + + var secondPayload = CreatePayload( + status: "locked", + reason: AuthFailureReason.LockedOut, + remainingAttempts: 0); + + var secondEncoded = EncodePayload(secondPayload); + + Navigate($"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/login?uauth={secondEncoded}"); + + cut.Render(parameters => + parameters + .Add(x => x.Value, state) + .AddChildContent()); + + page = cut + .FindComponent() + .Instance; + + page.PayloadCallbackCount.Should().Be(2); + + page.LastPayload.Should().NotBeNull(); + page.LastPayload!.Status.Should().Be("locked"); + page.LastPayload.Reason.Should().Be(AuthFailureReason.LockedOut); + } + + [Fact] + public void Render_WithPayloadAndFocus_ConsumesBothExactlyOnce() + { + var state = UAuthState.Anonymous(); + var payload = CreatePayload(); + var encoded = EncodePayload(payload); + + Navigate( + $"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/login?uauth={encoded}&uauth_focus=1"); + + var cut = RenderPage(state); + + var page = cut + .FindComponent() + .Instance; + + page.PayloadCallbackCount.Should().Be(1); + page.FocusCallbackCount.Should().Be(1); + + cut.InvokeAsync(page.RequestRender); + + cut.WaitForAssertion(() => + { + page.PayloadCallbackCount.Should().Be(1); + page.FocusCallbackCount.Should().Be(1); + }); + } + + [Fact] + public void Rerender_DoesNotConsumeSamePayloadAgain() + { + var state = UAuthState.Anonymous(); + var payload = CreatePayload(); + var encoded = EncodePayload(payload); + + Navigate($"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/login?uauth={encoded}"); + + var cut = RenderPage(state); + + var page = cut + .FindComponent() + .Instance; + + page.PayloadCallbackCount.Should().Be(1); + + cut.InvokeAsync(page.RequestRender); + + cut.WaitForAssertion(() => + { + page.PayloadCallbackCount.Should().Be(1); + }); + } + + [Fact] + public void WithoutQueryCleanup_ParsedReturnUrlIsPreserved() + { + var state = UAuthState.Anonymous(); + + Navigate("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/login?uauth_return_url=%2Fhome"); + + var cut = RenderPage(state); + + var page = cut + .FindComponent() + .Instance; + + page.ParsedReturnUrl.Should().Be("/home"); + + Nav.Uri.Should() + .Be("http://localhost/login?uauth_return_url=%2Fhome"); + + cut.Render(parameters => + parameters + .Add(x => x.Value, state) + .AddChildContent()); + + page = cut + .FindComponent() + .Instance; + + page.ParsedReturnUrl.Should().Be("/home"); + } + + private void Navigate(string relativeUri) + { + Nav.NavigateTo(relativeUri); + } + + private NavigationManager Nav => + Services.GetRequiredService(); + + private IRenderedComponent> + RenderPage(UAuthState state) + where TPage : IComponent + { + return Render>(parameters => + parameters + .Add(x => x.Value, state) + .AddChildContent()); + } + + private static AuthFlowPayload CreatePayload( + AuthFlowType flow = AuthFlowType.Login, + string status = "failed", + AuthFailureReason? reason = AuthFailureReason.InvalidCredentials, + int? remainingAttempts = 3, + DateTimeOffset? lockoutUntilUtc = null, + int version = 1) + { + return new AuthFlowPayload + { + V = version, + Flow = flow, + Status = status, + Reason = reason, + RemainingAttempts = remainingAttempts, + LockoutUntil = lockoutUntilUtc?.ToUnixTimeSeconds() + }; + } + + private static string EncodePayload(AuthFlowPayload payload) + { + var json = System.Text.Json.JsonSerializer.Serialize(payload); + var bytes = System.Text.Encoding.UTF8.GetBytes(json); + + return Microsoft.AspNetCore.WebUtilities.WebEncoders + .Base64UrlEncode(bytes); + } + + private class TestPage : UAuthPageBase + { + public AuthFlowPayload? ParsedPayload => UAuthPayload; + public string? ParsedReturnUrl => ReturnUrl; + public string? ParsedIdentifier => Identifier; + public bool HasFocusRequest => ShouldFocus; + + public int PayloadCallbackCount { get; private set; } + public int FocusCallbackCount { get; private set; } + + public AuthFlowPayload? LastPayload { get; private set; } + + protected override Task OnUAuthPayloadAsync( + AuthFlowPayload payload) + { + PayloadCallbackCount++; + LastPayload = payload; + + return Task.CompletedTask; + } + + protected override Task OnFocusRequestedAsync() + { + FocusCallbackCount++; + + return Task.CompletedTask; + } + + protected override void BuildRenderTree( + RenderTreeBuilder builder) + { + builder.AddContent(0, "uauth-test-page"); + } + + public void RequestRender() + { + StateHasChanged(); + } + } + + private sealed class PersistentQueryPage : TestPage + { + protected override bool ClearUAuthQueryAfterParse => false; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthRequestClientTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthRequestClientTests.cs new file mode 100644 index 00000000..d138cbf3 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthRequestClientTests.cs @@ -0,0 +1,170 @@ +ο»Ώusing Bunit; +using CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Options; +using FluentAssertions; +using Microsoft.Extensions.Options; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Client.Blazor; + +public sealed class UAuthRequestClientTests : BunitContext +{ + private readonly Mock _bootstrapper = new(); + + public UAuthRequestClientTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + + _bootstrapper + .Setup(x => x.EnsureStartedAsync()) + .Returns(Task.CompletedTask); + } + + private UAuthRequestClient CreateSut(UAuthClientProfile profile = UAuthClientProfile.BlazorWasm) + { + var options = Options.Create(new UAuthClientOptions + { + ClientProfile = profile + }); + + return new UAuthRequestClient(JSInterop.JSRuntime, _bootstrapper.Object, options); + } + + [Fact] + public async Task NavigateAsync_InvokesUAuthPost() + { + var sut = CreateSut(); + await sut.NavigateAsync("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/login"); + + var invocation = JSInterop.Invocations.Single(x => x.Identifier == "uauth.post"); + invocation.Arguments.Should().ContainSingle(); + + _bootstrapper.Verify(x => x.EnsureStartedAsync(), Times.Once); + } + + [Fact] + public async Task SendFormAsync_ReturnsTransportResult() + { + var expected = CreateTransportResult(200); + + JSInterop + .Setup( + "uauth.post", + invocation => true) + .SetResult(expected); + + var sut = CreateSut(); + var result = await sut.SendFormAsync("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/login"); + result.Should().BeSameAs(expected); + } + + [Fact] + public async Task SendFormAsync_WhenJavascriptReturnsNull_ThrowsProtocolException() + { + JSInterop + .Setup("uauth.post") + .SetResult(null!); + + var sut = CreateSut(); + + var act = () => sut.SendFormAsync("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/login"); + + await act.Should() + .ThrowAsync() + .WithMessage("Invalid error response format."); + } + + [Fact] + public async Task SendJsonAsync_WhenStatusIsZero_ThrowsTransportException() + { + JSInterop + .Setup( + "uauth.postJson", + _ => true) + .SetResult(CreateTransportResult(0)); + + var sut = CreateSut(); + + var act = () => sut.SendJsonAsync("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/api/login", new { Identifier = "alice" }); + + await act.Should().ThrowAsync().WithMessage("Network error."); + } + + [Fact] + public async Task SendFormAsync_WhenCancellationAlreadyRequested_DoesNotBootstrapOrInvokeJavascript() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var sut = CreateSut(); + var act = () => sut.SendFormAsync("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/login", ct: cts.Token); + await act.Should().ThrowAsync(); + + _bootstrapper.Verify(x => x.EnsureStartedAsync(), Times.Never); + + JSInterop.Invocations.Should().BeEmpty(); + } + + [Fact] + public async Task TryAndCommitAsync_WhenCancellationAlreadyRequested_DoesNotBootstrapOrInvokeJavascript() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var sut = CreateSut(); + + var act = () => sut.TryAndCommitAsync( + "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/login/try", + "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/login/commit", + new { Identifier = "alice" }, + cts.Token); + + await act.Should().ThrowAsync(); + + _bootstrapper.Verify(x => x.EnsureStartedAsync(), Times.Never); + + JSInterop.Invocations.Should().BeEmpty(); + } + + [Fact] + public async Task SendFormAsync_WhenBootstrapperFails_DoesNotInvokeJavascript() + { + _bootstrapper + .Setup(x => x.EnsureStartedAsync()) + .ThrowsAsync(new InvalidOperationException("bootstrap failed")); + + var sut = CreateSut(); + var act = () => sut.SendFormAsync("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/login"); + await act.Should().ThrowAsync().WithMessage("bootstrap failed"); + + JSInterop.Invocations.Should().BeEmpty(); + } + + private sealed class TestTryResult + { + } + + private static T GetProperty(object instance, string propertyName) + { + var property = instance + .GetType() + .GetProperty(propertyName); + + property.Should().NotBeNull( + $"JS request should contain property '{propertyName}'"); + + return (T)property!.GetValue(instance)!; + } + + private static UAuthTransportResult CreateTransportResult(int status) + { + return new UAuthTransportResult + { + Status = status + }; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthStateViewTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthStateViewTests.cs new file mode 100644 index 00000000..4ff25daf --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthStateViewTests.cs @@ -0,0 +1,733 @@ +ο»Ώusing Bunit; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Client.Blazor; + +public sealed class UAuthStateViewTests : BunitContext +{ + private const string AuthorizedContent = "AUTH-CONTENT"; + private const string DeniedContent = "DENIED-CONTENT"; + private const string InactiveContent = "INACTIVE-CONTENT"; + + private readonly Mock _authorization = new(); + + public UAuthStateViewTests() + { + Services.AddSingleton(_authorization.Object); + } + + // ============================================================ + // Authentication + // ============================================================ + + [Fact] + public void AnonymousUser_RendersNotAuthorized() + { + var state = UAuthState.Anonymous(); + + var cut = RenderView(state); + + AssertDenied(cut.Markup); + } + + [Theory] + [InlineData(AuthorizationMatchMode.Any)] + [InlineData(AuthorizationMatchMode.All)] + [InlineData(AuthorizationMatchMode.Category)] + public void AnonymousUser_WithoutRequirements_IsNotAuthorized( + AuthorizationMatchMode matchMode) + { + var state = UAuthState.Anonymous(); + + var cut = RenderView( + state, + matchMode: matchMode); + + AssertDenied(cut.Markup); + } + + [Theory] + [InlineData(AuthorizationMatchMode.Any)] + [InlineData(AuthorizationMatchMode.All)] + [InlineData(AuthorizationMatchMode.Category)] + public void AuthenticatedUser_WithoutRequirements_IsAuthorized( + AuthorizationMatchMode matchMode) + { + var state = AuthenticatedState(); + + var cut = RenderView( + state, + matchMode: matchMode); + + AssertAuthorized(cut.Markup); + } + + // ============================================================ + // Roles + // ============================================================ + + [Fact] + public void MatchingRole_RendersAuthorized() + { + var state = AuthenticatedState( + roles: ["Admin"]); + + var cut = RenderView( + state, + roles: "Admin"); + + AssertAuthorized(cut.Markup); + } + + [Fact] + public void MissingRole_RendersNotAuthorized() + { + var state = AuthenticatedState( + roles: ["User"]); + + var cut = RenderView( + state, + roles: "Admin"); + + AssertDenied(cut.Markup); + } + + [Fact] + public void RolesCsv_TrimsWhitespaceAndIgnoresEmptyEntries() + { + var state = AuthenticatedState( + roles: ["Admin"]); + + var cut = RenderView( + state, + roles: " User, , Admin, "); + + AssertAuthorized(cut.Markup); + } + + // ============================================================ + // Permissions + // ============================================================ + + [Fact] + public void MatchingPermission_RendersAuthorized() + { + var state = AuthenticatedState( + permissions: ["users.read"]); + + var cut = RenderView( + state, + permissions: "users.read"); + + AssertAuthorized(cut.Markup); + } + + [Fact] + public void MissingPermission_RendersNotAuthorized() + { + var state = AuthenticatedState( + permissions: ["users.list"]); + + var cut = RenderView( + state, + permissions: "users.read"); + + AssertDenied(cut.Markup); + } + + [Fact] + public void PermissionsCsv_TrimsWhitespaceAndIgnoresEmptyEntries() + { + var state = AuthenticatedState( + permissions: ["users.read"]); + + var cut = RenderView( + state, + permissions: " users.write, , users.read, "); + + AssertAuthorized(cut.Markup); + } + + // ============================================================ + // Any + // ============================================================ + + [Fact] + public void Any_WhenOneRoleMatches_RendersAuthorized() + { + var state = AuthenticatedState( + roles: ["Admin"]); + + var cut = RenderView( + state, + roles: "User,Admin", + matchMode: AuthorizationMatchMode.Any); + + AssertAuthorized(cut.Markup); + } + + [Fact] + public void Any_WhenOnePermissionMatches_RendersAuthorized() + { + var state = AuthenticatedState( + permissions: ["users.read"]); + + var cut = RenderView( + state, + permissions: "users.write,users.read", + matchMode: AuthorizationMatchMode.Any); + + AssertAuthorized(cut.Markup); + } + + [Fact] + public void Any_WhenRoleFailsButPermissionMatches_RendersAuthorized() + { + var state = AuthenticatedState( + roles: ["User"], + permissions: ["users.read"]); + + var cut = RenderView( + state, + roles: "Admin", + permissions: "users.read", + matchMode: AuthorizationMatchMode.Any); + + AssertAuthorized(cut.Markup); + } + + [Fact] + public void Any_WhenNothingMatches_RendersNotAuthorized() + { + var state = AuthenticatedState( + roles: ["User"], + permissions: ["users.list"]); + + var cut = RenderView( + state, + roles: "Admin", + permissions: "users.read", + matchMode: AuthorizationMatchMode.Any); + + AssertDenied(cut.Markup); + } + + // ============================================================ + // All + // ============================================================ + + [Fact] + public void All_WhenAllValuesMatch_RendersAuthorized() + { + var state = AuthenticatedState( + roles: ["Admin", "Manager"], + permissions: ["users.read", "users.write"]); + + var cut = RenderView( + state, + roles: "Admin,Manager", + permissions: "users.read,users.write", + matchMode: AuthorizationMatchMode.All); + + AssertAuthorized(cut.Markup); + } + + [Fact] + public void All_WhenOneRoleFails_RendersNotAuthorized() + { + var state = AuthenticatedState( + roles: ["Admin"], + permissions: ["users.read"]); + + var cut = RenderView( + state, + roles: "Admin,Manager", + permissions: "users.read", + matchMode: AuthorizationMatchMode.All); + + AssertDenied(cut.Markup); + } + + [Fact] + public void All_WhenOnePermissionFails_RendersNotAuthorized() + { + var state = AuthenticatedState( + roles: ["Admin"], + permissions: ["users.read"]); + + var cut = RenderView( + state, + roles: "Admin", + permissions: "users.read,users.write", + matchMode: AuthorizationMatchMode.All); + + AssertDenied(cut.Markup); + } + + // ============================================================ + // Category + // ============================================================ + + [Fact] + public void Category_WhenAtLeastOneValueFromEachCategoryMatches_RendersAuthorized() + { + var state = AuthenticatedState( + roles: ["Admin"], + permissions: ["users.read"]); + + var cut = RenderView( + state, + roles: "User,Admin", + permissions: "users.write,users.read", + matchMode: AuthorizationMatchMode.Category); + + AssertAuthorized(cut.Markup); + } + + [Fact] + public void Category_WhenRoleCategoryFails_RendersNotAuthorized() + { + var state = AuthenticatedState( + roles: ["User"], + permissions: ["users.read"]); + + var cut = RenderView( + state, + roles: "Admin,Manager", + permissions: "users.read", + matchMode: AuthorizationMatchMode.Category); + + AssertDenied(cut.Markup); + } + + [Fact] + public void Category_WhenPermissionCategoryFails_RendersNotAuthorized() + { + var state = AuthenticatedState( + roles: ["Admin"], + permissions: ["users.list"]); + + var cut = RenderView( + state, + roles: "Admin", + permissions: "users.read,users.write", + matchMode: AuthorizationMatchMode.Category); + + AssertDenied(cut.Markup); + } + + // ============================================================ + // Policy + // ============================================================ + + [Fact] + public void Policy_WhenSucceeded_RendersAuthorized() + { + var state = AuthenticatedState(); + + SetupPolicy( + "CanManageUsers", + AuthorizationResult.Success()); + + var cut = RenderView( + state, + policy: "CanManageUsers"); + + AssertAuthorized(cut.Markup); + } + + [Fact] + public void Policy_WhenFailed_RendersNotAuthorized() + { + var state = AuthenticatedState(); + + SetupPolicy( + "CanManageUsers", + AuthorizationResult.Failed()); + + var cut = RenderView( + state, + policy: "CanManageUsers"); + + AssertDenied(cut.Markup); + } + + [Fact] + public void Category_WhenRoleAndPermissionMatchButPolicyFails_RendersNotAuthorized() + { + var state = AuthenticatedState( + roles: ["Admin"], + permissions: ["users.read"]); + + SetupPolicy( + "CanManageUsers", + AuthorizationResult.Failed()); + + var cut = RenderView( + state, + roles: "Admin", + permissions: "users.read", + policy: "CanManageUsers", + matchMode: AuthorizationMatchMode.Category); + + AssertDenied(cut.Markup); + } + + [Fact] + public void Any_WhenRoleFailsButPolicySucceeds_RendersAuthorized() + { + var state = AuthenticatedState( + roles: ["User"]); + + SetupPolicy( + "CanManageUsers", + AuthorizationResult.Success()); + + var cut = RenderView( + state, + roles: "Admin", + policy: "CanManageUsers", + matchMode: AuthorizationMatchMode.Any); + + AssertAuthorized(cut.Markup); + } + + [Fact] + public void Policy_UsesPrincipalCreatedFromUAuthState() + { + var state = AuthenticatedState( + roles: ["Admin"], + permissions: ["users.read"]); + + ClaimsPrincipal? receivedPrincipal = null; + + _authorization + .Setup(x => x.AuthorizeAsync( + It.IsAny(), + It.IsAny(), + "CanManageUsers")) + .Callback( + (principal, _, _) => receivedPrincipal = principal) + .ReturnsAsync(AuthorizationResult.Success()); + + RenderView( + state, + policy: "CanManageUsers"); + + receivedPrincipal.Should().NotBeNull(); + receivedPrincipal!.Identity!.IsAuthenticated.Should().BeTrue(); + receivedPrincipal.IsInRole("Admin").Should().BeTrue(); + receivedPrincipal.HasClaim( + "uauth:permission", + "users.read").Should().BeTrue(); + } + + // ============================================================ + // Session state + // ============================================================ + + [Fact] + public void InactiveSession_WhenRequireActiveTrue_RendersInactive() + { + var state = AuthenticatedState( + sessionState: SessionState.Revoked); + + var cut = RenderView( + state, + requireActive: true); + + AssertInactive(cut.Markup); + } + + [Fact] + public void InactiveSession_WithoutInactiveTemplate_FallsBackToNotAuthorized() + { + var state = AuthenticatedState( + sessionState: SessionState.Revoked); + + var cut = RenderView( + state, + requireActive: true, + includeInactive: false); + + AssertDenied(cut.Markup); + } + + [Fact] + public void InactiveSession_WhenRequireActiveFalse_RendersAuthorized() + { + var state = AuthenticatedState( + sessionState: SessionState.Revoked); + + var cut = RenderView( + state, + requireActive: false); + + AssertAuthorized(cut.Markup); + } + + [Fact] + public void NullSessionState_WhenRequireActiveTrue_IsNotConsideredInactive() + { + var state = AuthenticatedState( + sessionState: null); + + var cut = RenderView( + state, + requireActive: true); + + AssertAuthorized(cut.Markup); + } + + [Fact] + public void ActiveSession_WhenRequireActiveTrue_RendersAuthorized() + { + var state = AuthenticatedState( + sessionState: SessionState.Active); + + var cut = RenderView( + state, + requireActive: true); + + AssertAuthorized(cut.Markup); + } + + // ============================================================ + // Parameter re-evaluation + // ============================================================ + + [Fact] + public void ChangingRequireActive_ReevaluatesState() + { + var state = AuthenticatedState( + sessionState: SessionState.Revoked); + + var cut = Render(parameters => parameters + .Add(x => x.State, state) + .Add(x => x.RequireActive, true)); + + cut.Markup.Should().Contain(InactiveContent); + cut.Markup.Should().NotContain(AuthorizedContent); + + cut.Render(parameters => parameters + .Add(x => x.State, state) + .Add(x => x.RequireActive, false)); + + cut.Markup.Should().Contain(AuthorizedContent); + cut.Markup.Should().NotContain(InactiveContent); + } + + // ============================================================ + // Helpers + // ============================================================ + + private IRenderedComponent> RenderView( + UAuthState state, + string? roles = null, + string? permissions = null, + string? policy = null, + AuthorizationMatchMode matchMode = AuthorizationMatchMode.Category, + bool requireActive = true, + bool includeInactive = true) + { + return Render>(parameters => + parameters + .Add(x => x.Value, state) + .AddChildContent(builder => + { + builder.OpenComponent(0); + + if (roles is not null) + { + builder.AddAttribute( + 1, + nameof(UAuthStateView.Roles), + roles); + } + + if (permissions is not null) + { + builder.AddAttribute( + 2, + nameof(UAuthStateView.Permissions), + permissions); + } + + if (policy is not null) + { + builder.AddAttribute( + 3, + nameof(UAuthStateView.Policy), + policy); + } + + builder.AddAttribute( + 4, + nameof(UAuthStateView.MatchMode), + matchMode); + + builder.AddAttribute( + 5, + nameof(UAuthStateView.RequireActive), + requireActive); + + builder.AddAttribute( + 6, + nameof(UAuthStateView.Authorized), + (RenderFragment)(_ => child => + child.AddContent(0, AuthorizedContent))); + + builder.AddAttribute( + 7, + nameof(UAuthStateView.NotAuthorized), + (RenderFragment)(child => + child.AddContent(0, DeniedContent))); + + if (includeInactive) + { + builder.AddAttribute( + 8, + nameof(UAuthStateView.Inactive), + (RenderFragment)(_ => child => + child.AddContent(0, InactiveContent))); + } + + builder.CloseComponent(); + })); + } + + private void SetupPolicy( + string policy, + AuthorizationResult result) + { + _authorization + .Setup(x => x.AuthorizeAsync( + It.IsAny(), + It.IsAny(), + policy)) + .ReturnsAsync(result); + } + + private static void AssertAuthorized(string markup) + { + markup.Should().Contain(AuthorizedContent); + markup.Should().NotContain(DeniedContent); + markup.Should().NotContain(InactiveContent); + } + + private static void AssertDenied(string markup) + { + markup.Should().Contain(DeniedContent); + markup.Should().NotContain(AuthorizedContent); + markup.Should().NotContain(InactiveContent); + } + + private static void AssertInactive(string markup) + { + markup.Should().Contain(InactiveContent); + markup.Should().NotContain(AuthorizedContent); + markup.Should().NotContain(DeniedContent); + } + + private static UAuthState AuthenticatedState( + string[]? roles = null, + string[]? permissions = null, + SessionState? sessionState = SessionState.Active) + { + var claims = new List<(string Type, string Value)>(); + + foreach (var role in roles ?? []) + claims.Add((ClaimTypes.Role, role)); + + foreach (var permission in permissions ?? []) + claims.Add(("uauth:permission", permission)); + + var snapshot = new AuthStateSnapshot + { + Identity = new AuthIdentitySnapshot + { + UserKey = UserKey.FromGuid(Guid.NewGuid()), + Tenant = TenantKey.FromExternal("tenant-a"), + PrimaryUserName = "alice", + DisplayName = "Alice", + UserStatus = UserStatus.Active, + SessionState = sessionState + }, + Claims = ClaimsSnapshot.From(claims.ToArray()) + }; + + var state = UAuthState.Anonymous(); + + state.ApplySnapshot( + snapshot, + DateTimeOffset.UtcNow); + + return state; + } + + private sealed class StateViewHost : ComponentBase + { + [Parameter] + public UAuthState State { get; set; } = default!; + + [Parameter] + public bool RequireActive { get; set; } + + protected override void BuildRenderTree( + RenderTreeBuilder builder) + { + builder.OpenComponent>(0); + + builder.AddAttribute( + 1, + nameof(CascadingValue.Value), + State); + + builder.AddAttribute( + 2, + nameof(CascadingValue.ChildContent), + (RenderFragment)(content => + { + content.OpenComponent(0); + + content.AddAttribute( + 1, + nameof(UAuthStateView.RequireActive), + RequireActive); + + content.AddAttribute( + 2, + nameof(UAuthStateView.Authorized), + (RenderFragment)(_ => child => + child.AddContent(0, AuthorizedContent))); + + content.AddAttribute( + 3, + nameof(UAuthStateView.NotAuthorized), + (RenderFragment)(child => + child.AddContent(0, DeniedContent))); + + content.AddAttribute( + 4, + nameof(UAuthStateView.Inactive), + (RenderFragment)(_ => child => + child.AddContent(0, InactiveContent))); + + content.CloseComponent(); + })); + + builder.CloseComponent(); + } + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginRedirectTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginRedirectTests.cs new file mode 100644 index 00000000..1288b2b2 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthLoginRedirectTests.cs @@ -0,0 +1,236 @@ +ο»Ώusing Bunit; +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Core.Defaults; +using FluentAssertions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Client.Blazor; + +public sealed class UAuthLoginRedirectTests : BunitContext +{ + private NavigationManager Nav => + Services.GetRequiredService(); + + [Fact] + public void Render_WithoutReturnUrl_NavigatesToLoginPage() + { + Navigate(UAuthConstants.Routes.LoginRedirect); + + Render(); + + Nav.Uri.Should().Be("http://localhost/login"); + } + + [Fact] + public void Render_WithRelativeReturnUrl_PreservesReturnUrl() + { + NavigateToRedirect("/home"); + + Render(); + + Nav.Uri.Should().Be( + "http://localhost/login?uauth_return_url=%2Fhome"); + } + + [Fact] + public void Render_WithNestedRelativeReturnUrl_PreservesAndEncodesReturnUrl() + { + NavigateToRedirect("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/account/security?tab=sessions"); + + Render(); + + var uri = Nav.ToAbsoluteUri(Nav.Uri); + + uri.AbsolutePath.Should().Be("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/login"); + + var query = + Microsoft.AspNetCore.WebUtilities.QueryHelpers + .ParseQuery(uri.Query); + + query[UAuthConstants.Query.ReturnUrl] + .ToString() + .Should() + .Be("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/account/security?tab=sessions"); + } + + [Fact] + public void Render_WithDotRelativeReturnUrl_PreservesReturnUrl() + { + NavigateToRedirect("./home"); + + Render(); + + GetReturnUrlFromCurrentUri() + .Should() + .Be("./home"); + } + + [Fact] + public void Render_WithParentRelativeReturnUrl_PreservesReturnUrl() + { + NavigateToRedirect("../home"); + + Render(); + + GetReturnUrlFromCurrentUri() + .Should() + .Be("../home"); + } + + [Fact] + public void Render_WithAbsoluteHttpsReturnUrl_PreservesReturnUrl() + { + const string returnUrl = + "https://example.com/account/security"; + + NavigateToRedirect(returnUrl); + + Render(); + + GetReturnUrlFromCurrentUri() + .Should() + .Be(returnUrl); + } + + [Fact] + public void Render_WithAbsoluteHttpReturnUrl_PreservesReturnUrl() + { + const string returnUrl = + "http://example.com/account/security"; + + NavigateToRedirect(returnUrl); + + Render(); + + GetReturnUrlFromCurrentUri() + .Should() + .Be(returnUrl); + } + + [Theory] + [InlineData("javascript:alert(1)")] + [InlineData("ftp://example.com/file")] + [InlineData("mailto:test@example.com")] + public void Render_WithUnsupportedAbsoluteScheme_DropsReturnUrl( + string returnUrl) + { + NavigateToRedirect(returnUrl); + + Render(); + + Nav.ToAbsoluteUri(Nav.Uri) + .AbsolutePath + .Should() + .Be("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/login"); + + GetReturnUrlFromCurrentUri() + .Should() + .BeNull(); + } + + [Fact] + public void Render_WithFreshLogin_DropsReturnUrl() + { + var uri = Nav.GetUriWithQueryParameters( + UAuthConstants.Routes.LoginRedirect, + new Dictionary + { + ["fresh"] = "1", + [UAuthConstants.Query.ReturnUrl] = "/home" + }); + + Nav.NavigateTo(uri); + + Render(); + + Nav.ToAbsoluteUri(Nav.Uri) + .AbsolutePath + .Should() + .Be("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/login"); + + GetReturnUrlFromCurrentUri() + .Should() + .BeNull(); + } + + [Fact] + public void Render_WithFreshParameterRegardlessOfValue_DropsReturnUrl() + { + var uri = Nav.GetUriWithQueryParameters( + UAuthConstants.Routes.LoginRedirect, + new Dictionary + { + ["fresh"] = "false", + [UAuthConstants.Query.ReturnUrl] = "/home" + }); + + Nav.NavigateTo(uri); + + Render(); + + GetReturnUrlFromCurrentUri() + .Should() + .BeNull(); + } + + [Fact] + public void Render_WithLegacyReturnUrlQuery_DoesNotConsumeIt() + { + Navigate( + $"{UAuthConstants.Routes.LoginRedirect}" + + "?return_url=%2Fhome"); + + Render(); + + Nav.ToAbsoluteUri(Nav.Uri) + .AbsolutePath + .Should() + .Be("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/login"); + + GetReturnUrlFromCurrentUri() + .Should() + .BeNull(); + } + + [Fact] + public void Render_WithUAuthReturnUrl_ConsumesIt() + { + NavigateToRedirect("/home"); + + Render(); + + GetReturnUrlFromCurrentUri() + .Should() + .Be("/home"); + } + + private void NavigateToRedirect(string returnUrl) + { + Nav.NavigateTo(UAuthConstants.Routes.LoginRedirect); + + var uri = Nav.GetUriWithQueryParameter(UAuthConstants.Query.ReturnUrl, returnUrl); + + Nav.NavigateTo(uri); + } + + private void Navigate(string relativeUri) + { + Nav.NavigateTo(relativeUri); + } + + private string? GetReturnUrlFromCurrentUri() + { + var uri = Nav.ToAbsoluteUri(Nav.Uri); + + var query = + Microsoft.AspNetCore.WebUtilities.QueryHelpers + .ParseQuery(uri.Query); + + return query.TryGetValue( + UAuthConstants.Query.ReturnUrl, + out var value) + ? value.ToString() + : null; + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj index 1b44b87b..f26c8560 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj @@ -8,15 +8,21 @@ - - - - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + - - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs new file mode 100644 index 00000000..a30dcddb --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialEndpointHandlerTests.cs @@ -0,0 +1,727 @@ +ο»Ώusing System.Text; +using System.Text.Json; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class CredentialEndpointHandlerTests +{ + // ========================================================= + // GetAll - Self + // ========================================================= + + [Fact] + public async Task GetAllAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var f = new Fixture(isAuthenticated: false); + + var result = await f.Sut.GetAllAsync(f.HttpContext); + + result.Should().BeOfType(); + + f.AccessContextFactory.VerifyNoOtherCalls(); + f.Credentials.VerifyNoOtherCalls(); + } + + [Fact] + public async Task GetAllAsync_WhenAuthenticated_UsesListSelfAndReturnsOk() + { + var f = new Fixture(); + var access = f.SelfAccess(UAuthActions.Credentials.ListSelf); + + f.SetupAccess( + UAuthActions.Credentials.ListSelf, + f.UserKey.Value, + access); + + var serviceResult = new GetCredentialsResult(); + + f.Credentials + .Setup(x => x.GetAllAsync( + access, + f.HttpContext.RequestAborted)) + .ReturnsAsync(serviceResult); + + var result = await f.Sut.GetAllAsync(f.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().BeSameAs(serviceResult); + } + + // ========================================================= + // Add - Self + // ========================================================= + + [Fact] + public async Task AddAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var f = new Fixture(isAuthenticated: false); + + f.SetJsonBody(new AddCredentialRequest + { + Type = CredentialType.Password, + Secret = "new-password" + }); + + var result = await f.Sut.AddAsync(f.HttpContext); + + result.Should().BeOfType(); + + f.AccessContextFactory.VerifyNoOtherCalls(); + f.Credentials.VerifyNoOtherCalls(); + } + + [Fact] + public async Task AddAsync_WhenAuthenticated_ForwardsRequestUsingAddSelf() + { + var f = new Fixture(); + var access = f.SelfAccess(UAuthActions.Credentials.AddSelf); + + f.SetJsonBody(new AddCredentialRequest + { + Type = CredentialType.Password, + Secret = "new-password", + Source = "test" + }); + + f.SetupAccess( + UAuthActions.Credentials.AddSelf, + f.UserKey.Value, + access); + + var serviceResult = AddCredentialResult.Success( + Guid.NewGuid(), + CredentialType.Password); + + f.Credentials + .Setup(x => x.AddAsync( + access, + It.Is(r => + r.Type == CredentialType.Password && + r.Secret == "new-password" && + r.Source == "test"), + f.HttpContext.RequestAborted)) + .ReturnsAsync(serviceResult); + + var result = await f.Sut.AddAsync(f.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().BeSameAs(serviceResult); + } + + // ========================================================= + // Change Secret - Self + // ========================================================= + + [Fact] + public async Task ChangeSecretAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var f = new Fixture(isAuthenticated: false); + + f.SetJsonBody(new ChangeCredentialRequest + { + CurrentSecret = "old", + NewSecret = "new" + }); + + var result = await f.Sut.ChangeSecretAsync(f.HttpContext); + + result.Should().BeOfType(); + + f.AccessContextFactory.VerifyNoOtherCalls(); + f.Credentials.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ChangeSecretAsync_WhenAuthenticated_ForwardsRequestUsingChangeSelf() + { + var f = new Fixture(); + var access = f.SelfAccess(UAuthActions.Credentials.ChangeSelf); + + f.SetJsonBody(new ChangeCredentialRequest + { + CurrentSecret = "old-password", + NewSecret = "new-password" + }); + + f.SetupAccess( + UAuthActions.Credentials.ChangeSelf, + f.UserKey.Value, + access); + + var serviceResult = + ChangeCredentialResult.Success(CredentialType.Password); + + f.Credentials + .Setup(x => x.ChangeSecretAsync( + access, + It.Is(r => + r.CurrentSecret == "old-password" && + r.NewSecret == "new-password"), + f.HttpContext.RequestAborted)) + .ReturnsAsync(serviceResult); + + var result = await f.Sut.ChangeSecretAsync(f.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().BeSameAs(serviceResult); + } + + // ========================================================= + // Revoke - Self + // ========================================================= + + [Fact] + public async Task RevokeAsync_WhenAuthenticated_UsesRevokeSelfAndReturnsNoContent() + { + var f = new Fixture(); + var access = f.SelfAccess(UAuthActions.Credentials.RevokeSelf); + var credentialId = Guid.NewGuid(); + + f.SetJsonBody(new RevokeCredentialRequest + { + Id = credentialId + }); + + f.SetupAccess( + UAuthActions.Credentials.RevokeSelf, + f.UserKey.Value, + access); + + f.Credentials + .Setup(x => x.RevokeAsync( + access, + It.Is(r => + r.Id == credentialId), + f.HttpContext.RequestAborted)) + .ReturnsAsync(CredentialActionResult.Success()); + + var result = await f.Sut.RevokeAsync(f.HttpContext); + + result.Should().BeOfType(); + } + + // ========================================================= + // Begin Reset - Anonymous + // ========================================================= + + [Fact] + public async Task BeginResetAsync_WhenUnauthenticated_IsAllowed() + { + var f = new Fixture(isAuthenticated: false); + + const string identifier = "alice@example.com"; + + f.SetJsonBody(new BeginResetCredentialRequest + { + Identifier = identifier, + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + var access = TestAccessContext.WithAction( + UAuthActions.Credentials.BeginResetAnonymous); + + f.SetupAccess( + UAuthActions.Credentials.BeginResetAnonymous, + identifier, + access); + + var serviceResult = new BeginCredentialResetResult(); + + f.Credentials + .Setup(x => x.BeginResetAsync( + access, + It.Is(r => + r.Identifier == identifier && + r.CredentialType == CredentialType.Password && + r.ResetCodeType == ResetCodeType.Token), + f.HttpContext.RequestAborted)) + .ReturnsAsync(serviceResult); + + var result = await f.Sut.BeginResetAsync(f.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().BeSameAs(serviceResult); + } + + // ========================================================= + // Complete Reset - Anonymous + // ========================================================= + + [Fact] + public async Task CompleteResetAsync_WhenUnauthenticated_IsAllowed() + { + var f = new Fixture(isAuthenticated: false); + + const string identifier = "alice@example.com"; + + f.SetJsonBody(new CompleteResetCredentialRequest + { + Identifier = identifier, + CredentialType = CredentialType.Password, + ResetToken = "reset-token", + NewSecret = "new-password" + }); + + var access = TestAccessContext.WithAction( + UAuthActions.Credentials.CompleteResetAnonymous); + + f.SetupAccess( + UAuthActions.Credentials.CompleteResetAnonymous, + identifier, + access); + + f.Credentials + .Setup(x => x.CompleteResetAsync( + access, + It.Is(r => + r.Identifier == identifier && + r.ResetToken == "reset-token" && + r.NewSecret == "new-password"), + f.HttpContext.RequestAborted)) + .ReturnsAsync(CredentialActionResult.Success()); + + var result = await f.Sut.CompleteResetAsync(f.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value!.Succeeded.Should().BeTrue(); + } + + // ========================================================= + // GetAll - Admin + // ========================================================= + + [Fact] + public async Task GetAllAdminAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var f = new Fixture(isAuthenticated: false); + + var result = await f.Sut.GetAllAdminAsync( + UserKey.New(), + f.HttpContext); + + result.Should().BeOfType(); + + f.AccessContextFactory.VerifyNoOtherCalls(); + f.Credentials.VerifyNoOtherCalls(); + } + + [Fact] + public async Task GetAllAdminAsync_WhenAuthenticated_UsesListAdminForTargetUser() + { + var f = new Fixture(); + var target = UserKey.New(); + + var access = f.AdminAccess( + target, + UAuthActions.Credentials.ListAdmin); + + f.SetupAccess( + UAuthActions.Credentials.ListAdmin, + target.Value, + access); + + var serviceResult = new GetCredentialsResult(); + + f.Credentials + .Setup(x => x.GetAllAsync( + access, + f.HttpContext.RequestAborted)) + .ReturnsAsync(serviceResult); + + var result = await f.Sut.GetAllAdminAsync( + target, + f.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().BeSameAs(serviceResult); + } + + // ========================================================= + // Add - Admin + // ========================================================= + + [Fact] + public async Task AddAdminAsync_WhenAuthenticated_UsesAddAdminForTargetUser() + { + var f = new Fixture(); + var target = UserKey.New(); + + var access = f.AdminAccess( + target, + UAuthActions.Credentials.AddAdmin); + + f.SetJsonBody(new AddCredentialRequest + { + Type = CredentialType.Password, + Secret = "admin-set-password" + }); + + f.SetupAccess( + UAuthActions.Credentials.AddAdmin, + target.Value, + access); + + var serviceResult = AddCredentialResult.Success( + Guid.NewGuid(), + CredentialType.Password); + + f.Credentials + .Setup(x => x.AddAsync( + access, + It.Is(r => + r.Secret == "admin-set-password"), + f.HttpContext.RequestAborted)) + .ReturnsAsync(serviceResult); + + var result = await f.Sut.AddAdminAsync( + target, + f.HttpContext); + + result.Should().BeOfType>(); + } + + // ========================================================= + // Change Secret - Admin + // ========================================================= + + [Fact] + public async Task ChangeSecretAdminAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var f = new Fixture(isAuthenticated: false); + + var result = await f.Sut.ChangeSecretAdminAsync( + UserKey.New(), + f.HttpContext); + + result.Should().BeOfType(); + + f.AccessContextFactory.VerifyNoOtherCalls(); + f.Credentials.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ChangeSecretAdminAsync_WhenAuthenticated_UsesChangeAdminForTargetUser() + { + var f = new Fixture(); + var target = UserKey.New(); + + var access = f.AdminAccess( + target, + UAuthActions.Credentials.ChangeAdmin); + + f.SetJsonBody(new ChangeCredentialRequest + { + NewSecret = "new-admin-password" + }); + + f.SetupAccess( + UAuthActions.Credentials.ChangeAdmin, + target.Value, + access); + + var serviceResult = + ChangeCredentialResult.Success(CredentialType.Password); + + f.Credentials + .Setup(x => x.ChangeSecretAsync( + access, + It.Is(r => + r.NewSecret == "new-admin-password"), + f.HttpContext.RequestAborted)) + .ReturnsAsync(serviceResult); + + var result = await f.Sut.ChangeSecretAdminAsync( + target, + f.HttpContext); + + result.Should().BeOfType>(); + } + + // ========================================================= + // Revoke - Admin + // ========================================================= + + [Fact] + public async Task RevokeAdminAsync_WhenAuthenticated_UsesRevokeAdminAndReturnsNoContent() + { + var f = new Fixture(); + var target = UserKey.New(); + var credentialId = Guid.NewGuid(); + + var access = f.AdminAccess( + target, + UAuthActions.Credentials.RevokeAdmin); + + f.SetJsonBody(new RevokeCredentialRequest + { + Id = credentialId + }); + + f.SetupAccess( + UAuthActions.Credentials.RevokeAdmin, + target.Value, + access); + + f.Credentials + .Setup(x => x.RevokeAsync( + access, + It.Is(r => + r.Id == credentialId), + f.HttpContext.RequestAborted)) + .ReturnsAsync(CredentialActionResult.Success()); + + var result = await f.Sut.RevokeAdminAsync( + target, + f.HttpContext); + + result.Should().BeOfType(); + } + + // ========================================================= + // Delete - Admin + // ========================================================= + + [Theory] + [InlineData(DeleteMode.Soft)] + [InlineData(DeleteMode.Hard)] + public async Task DeleteAdminAsync_WhenAuthenticated_ForwardsDeleteModeAndReturnsNoContent( + DeleteMode mode) + { + var f = new Fixture(); + var target = UserKey.New(); + var credentialId = Guid.NewGuid(); + + var access = f.AdminAccess( + target, + UAuthActions.Credentials.DeleteAdmin); + + f.SetJsonBody(new DeleteCredentialRequest + { + Id = credentialId, + Mode = mode + }); + + f.SetupAccess( + UAuthActions.Credentials.DeleteAdmin, + target.Value, + access); + + f.Credentials + .Setup(x => x.DeleteAsync( + access, + It.Is(r => + r.Id == credentialId && + r.Mode == mode), + f.HttpContext.RequestAborted)) + .ReturnsAsync(CredentialActionResult.Success()); + + var result = await f.Sut.DeleteAdminAsync( + target, + f.HttpContext); + + result.Should().BeOfType(); + } + + // ========================================================= + // Begin Reset - Admin + // ========================================================= + + [Fact] + public async Task BeginResetAdminAsync_WhenAuthenticated_UsesTargetUserAsResourceId() + { + var f = new Fixture(); + var target = UserKey.New(); + + var access = f.AdminAccess( + target, + UAuthActions.Credentials.BeginResetAdmin); + + f.SetJsonBody(new BeginResetCredentialRequest + { + // Important: + // Admin endpoint's authorization target is userKey, + // not this identifier. + Identifier = "alice@example.com", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + f.SetupAccess( + UAuthActions.Credentials.BeginResetAdmin, + target.Value, + access); + + f.Credentials + .Setup(x => x.BeginResetAsync( + access, + It.IsAny(), + f.HttpContext.RequestAborted)) + .ReturnsAsync(new BeginCredentialResetResult()); + + var result = await f.Sut.BeginResetAdminAsync( + target, + f.HttpContext); + + result.Should().BeOfType(); + } + + // ========================================================= + // Complete Reset - Admin + // ========================================================= + + [Fact] + public async Task CompleteResetAdminAsync_WhenAuthenticated_UsesTargetUserAsResourceId() + { + var f = new Fixture(); + var target = UserKey.New(); + + var access = f.AdminAccess( + target, + UAuthActions.Credentials.CompleteResetAdmin); + + f.SetJsonBody(new CompleteResetCredentialRequest + { + Identifier = "alice@example.com", + CredentialType = CredentialType.Password, + ResetToken = "token", + NewSecret = "new-password" + }); + + f.SetupAccess( + UAuthActions.Credentials.CompleteResetAdmin, + target.Value, + access); + + f.Credentials + .Setup(x => x.CompleteResetAsync( + access, + It.IsAny(), + f.HttpContext.RequestAborted)) + .ReturnsAsync(CredentialActionResult.Success()); + + var result = await f.Sut.CompleteResetAdminAsync( + target, + f.HttpContext); + + result.Should().BeOfType(); + } + + // ========================================================= + // Fixture + // ========================================================= + + private sealed class Fixture + { + public Mock AuthFlow { get; } + = new(MockBehavior.Strict); + + public Mock AccessContextFactory { get; } + = new(MockBehavior.Strict); + + public Mock Credentials { get; } + = new(MockBehavior.Strict); + + public DefaultHttpContext HttpContext { get; } = new(); + + public UserKey UserKey { get; } = UserKey.New(); + + public AuthFlowContext Flow { get; } + + public CredentialEndpointHandler Sut { get; } + + public Fixture(bool isAuthenticated = true) + { + Flow = AuthFlowTestFactory.New( + isAuthenticated: isAuthenticated, + userKey: isAuthenticated ? UserKey : null); + + AuthFlow + .SetupGet(x => x.Current) + .Returns(Flow); + + Sut = new CredentialEndpointHandler( + AuthFlow.Object, + AccessContextFactory.Object, + Credentials.Object); + } + + public AccessContext SelfAccess(string action) + => TestAccessContext.ForUser( + UserKey, + action, + resource: "credentials"); + + public AccessContext AdminAccess( + UserKey target, + string action) + => TestAccessContext.ForTargetUser( + UserKey, + target, + action, + resource: "credentials"); + + public void SetupAccess( + string action, + string resourceId, + AccessContext result) + { + AccessContextFactory + .Setup(x => x.CreateAsync( + Flow, + action, + "credentials", + resourceId, + null, + default)) + .ReturnsAsync(result); + } + + public void SetJsonBody(T value) + { + var json = JsonSerializer.Serialize( + value, + new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var bytes = Encoding.UTF8.GetBytes(json); + + HttpContext.Request.Body = + new MemoryStream(bytes); + + HttpContext.Request.ContentType = + "application/json"; + + HttpContext.Request.ContentLength = + bytes.Length; + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs new file mode 100644 index 00000000..601b268e --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/CredentialManagementServiceTests.cs @@ -0,0 +1,837 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.Credentials.Reference.Internal; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Users; +using FluentAssertions; +using Microsoft.Extensions.Options; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class CredentialManagementServiceTests +{ + private static readonly DateTimeOffset Now = + new(2026, 9, 21, 12, 0, 0, TimeSpan.Zero); + + // --------------------------------------------------------- + // GetAll + // --------------------------------------------------------- + + [Fact] + public async Task GetAllAsync_ReturnsCredentialsForTargetUser() + { + var f = CreateFixture(); + var user = UserKey.New(); + + var context = TestAccessContext.ForUser( + user, + UAuthActions.Credentials.ListSelf); + + var credential = CreateCredential( + user, + f.OldPasswordHash, + version: 7); + + f.CredentialStore + .Setup(x => x.GetByUserAsync( + user, + It.IsAny())) + .ReturnsAsync(new[] { credential }); + + var result = await f.Sut.GetAllAsync(context); + + result.Credentials.Should().ContainSingle(); + + var dto = result.Credentials.Single(); + dto.Id.Should().Be(credential.Id); + dto.Type.Should().Be(CredentialType.Password); + dto.Status.Should().Be(CredentialSecurityStatus.Active); + dto.Version.Should().Be(7); + } + + // --------------------------------------------------------- + // Add + // --------------------------------------------------------- + + [Fact] + public async Task AddAsync_HashesSecretAndPersistsCredentialForTargetUser() + { + var f = CreateFixture(); + var user = UserKey.New(); + + var context = TestAccessContext.ForUser( + user, + UAuthActions.Credentials.AddSelf); + + f.Hasher + .Setup(x => x.Hash("new-password")) + .Returns(f.OldPasswordHash); + + PasswordCredential? persisted = null; + + f.CredentialStore + .Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (credential, _) => persisted = credential) + .Returns(Task.CompletedTask); + + var result = await f.Sut.AddAsync( + context, + new AddCredentialRequest + { + Type = CredentialType.Password, + Secret = "new-password" + }); + + result.Succeeded.Should().BeTrue(); + + persisted.Should().NotBeNull(); + persisted!.UserKey.Should().Be(user); + persisted.Tenant.Should().Be(context.ResourceTenant); + persisted.SecretHash.Should().Be(f.OldPasswordHash); + + result.Id.Should().Be(persisted.Id); + result.Type.Should().Be(CredentialType.Password); + } + + // --------------------------------------------------------- + // ChangeSecret + // --------------------------------------------------------- + + [Fact] + public async Task ChangeSecretAsync_WhenCredentialDoesNotExist_ThrowsNotFound() + { + var f = CreateFixture(); + var user = UserKey.New(); + + var context = TestAccessContext.ForUser( + user, + UAuthActions.Credentials.ChangeSelf); + + f.CredentialStore + .Setup(x => x.GetByUserAsync( + user, + It.IsAny())) + .ReturnsAsync(Array.Empty()); + + var act = () => f.Sut.ChangeSecretAsync( + context, + new ChangeCredentialRequest + { + CurrentSecret = "old", + NewSecret = "new" + }); + + await act.Should() + .ThrowAsync() + .WithMessage("*credential_not_found*"); + } + + [Fact] + public async Task ChangeSecretAsync_Self_WhenCurrentSecretMissing_ThrowsNotFound() + { + var f = CreateFixture(); + var user = UserKey.New(); + + var context = TestAccessContext.ForUser( + user, + UAuthActions.Credentials.ChangeSelf); + + var credential = CreateCredential(user, f.OldPasswordHash); + + f.CredentialStore + .Setup(x => x.GetByUserAsync( + user, + It.IsAny())) + .ReturnsAsync(new[] { credential }); + + var act = () => f.Sut.ChangeSecretAsync( + context, + new ChangeCredentialRequest + { + CurrentSecret = null, + NewSecret = "new-password" + }); + + await act.Should() + .ThrowAsync() + .WithMessage("*current_secret_required*"); + + f.Hasher.Verify( + x => x.Hash(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ChangeSecretAsync_Self_WhenCurrentSecretInvalid_ThrowsConflict() + { + var f = CreateFixture(); + var user = UserKey.New(); + + var context = TestAccessContext.ForUser( + user, + UAuthActions.Credentials.ChangeSelf); + + var credential = CreateCredential(user, f.OldPasswordHash); + + f.CredentialStore + .Setup(x => x.GetByUserAsync( + user, + It.IsAny())) + .ReturnsAsync(new[] { credential }); + + f.Hasher + .Setup(x => x.Verify( + f.OldPasswordHash, + "wrong-password")) + .Returns(false); + + var act = () => f.Sut.ChangeSecretAsync( + context, + new ChangeCredentialRequest + { + CurrentSecret = "wrong-password", + NewSecret = "new-password" + }); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should().Be("invalid_credentials"); + } + + [Fact] + public async Task ChangeSecretAsync_WhenNewSecretMatchesCurrent_ThrowsValidation() + { + var f = CreateFixture(); + var user = UserKey.New(); + + var context = TestAccessContext.ForUser( + user, + UAuthActions.Credentials.ChangeSelf); + + var credential = CreateCredential(user, f.OldPasswordHash); + + f.CredentialStore + .Setup(x => x.GetByUserAsync( + user, + It.IsAny())) + .ReturnsAsync(new[] { credential }); + + f.Hasher + .Setup(x => x.Verify( + f.OldPasswordHash, + "current-password")) + .Returns(true); + + f.Hasher + .Setup(x => x.Verify( + f.OldPasswordHash, + "same-password")) + .Returns(true); + + var act = () => f.Sut.ChangeSecretAsync( + context, + new ChangeCredentialRequest + { + CurrentSecret = "current-password", + NewSecret = "same-password" + }); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should().Be("credential_secret_same"); + + f.CredentialStore.Verify( + x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ChangeSecretAsync_Self_WhenValid_SavesAndRevokesOtherChains() + { + var f = CreateFixture(); + var user = UserKey.New(); + var chainId = SessionChainId.New(); + + var context = TestAccessContext.ForUser( + user, + UAuthActions.Credentials.ChangeSelf, + actorChainId: chainId); + + var credential = CreateCredential( + user, + f.OldPasswordHash, + version: 12); + + f.CredentialStore + .Setup(x => x.GetByUserAsync( + user, + It.IsAny())) + .ReturnsAsync(new[] { credential }); + + f.Hasher + .Setup(x => x.Verify( + f.OldPasswordHash, + "current-password")) + .Returns(true); + + f.Hasher + .Setup(x => x.Verify( + f.OldPasswordHash, + "new-password")) + .Returns(false); + + f.Hasher + .Setup(x => x.Hash("new-password")) + .Returns(f.NewPasswordHash); + + f.CredentialStore + .Setup(x => x.SaveAsync( + It.Is(c => + c.UserKey == user && + c.SecretHash == f.NewPasswordHash), + 12, + It.IsAny())) + .Returns(Task.CompletedTask); + + f.SessionStore + .Setup(x => x.RevokeOtherChainsAsync( + user, + chainId, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + var result = await f.Sut.ChangeSecretAsync( + context, + new ChangeCredentialRequest + { + CurrentSecret = "current-password", + NewSecret = "new-password" + }); + + result.IsSuccess.Should().BeTrue(); + + f.SessionStore.Verify(x => x.RevokeOtherChainsAsync( + user, + chainId, + Now, + It.IsAny()), + Times.Once); + + f.SessionStore.Verify(x => x.RevokeAllChainsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ChangeSecretAsync_SelfWithoutActorChain_RevokesAllChains() + { + var f = CreateFixture(); + var user = UserKey.New(); + + var context = TestAccessContext.ForUser( + user, + UAuthActions.Credentials.ChangeSelf, + actorChainId: null); + + SetupSuccessfulChange(f, user); + + f.SessionStore + .Setup(x => x.RevokeAllChainsAsync( + user, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + var result = await f.Sut.ChangeSecretAsync( + context, + new ChangeCredentialRequest + { + CurrentSecret = "current-password", + NewSecret = "new-password" + }); + + result.IsSuccess.Should().BeTrue(); + + f.SessionStore.Verify(x => x.RevokeAllChainsAsync( + user, + Now, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ChangeSecretAsync_Admin_WhenValid_DoesNotRequireCurrentSecret_AndRevokesAllChains() + { + var f = CreateFixture(); + + var actor = UserKey.New(); + var target = UserKey.New(); + + var context = TestAccessContext.ForTargetUser( + actor, + target, + UAuthActions.Credentials.ChangeAdmin); + + var credential = CreateCredential( + target, + f.OldPasswordHash, + version: 4); + + f.CredentialStore + .Setup(x => x.GetByUserAsync( + target, + It.IsAny())) + .ReturnsAsync(new[] { credential }); + + f.Hasher + .Setup(x => x.Verify( + f.OldPasswordHash, + "new-password")) + .Returns(false); + + f.Hasher + .Setup(x => x.Hash("new-password")) + .Returns(f.NewPasswordHash); + + f.CredentialStore + .Setup(x => x.SaveAsync( + It.Is(c => + c.SecretHash == f.NewPasswordHash), + 4, + It.IsAny())) + .Returns(Task.CompletedTask); + + f.SessionStore + .Setup(x => x.RevokeAllChainsAsync( + target, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + var result = await f.Sut.ChangeSecretAsync( + context, + new ChangeCredentialRequest + { + CurrentSecret = null, + NewSecret = "new-password" + }); + + result.IsSuccess.Should().BeTrue(); + + f.Hasher.Verify(x => x.Verify( + f.OldPasswordHash, + It.IsAny()), + Times.Once); + + f.SessionStore.Verify(x => x.RevokeAllChainsAsync( + target, + Now, + It.IsAny()), + Times.Once); + } + + // --------------------------------------------------------- + // Revoke + // --------------------------------------------------------- + + [Fact] + public async Task RevokeAsync_WhenCredentialBelongsToDifferentUser_ReturnsNotFound() + { + var f = CreateFixture(); + + var target = UserKey.New(); + var other = UserKey.New(); + var id = Guid.NewGuid(); + + var context = TestAccessContext.ForUser( + target, + UAuthActions.Credentials.RevokeSelf); + + var credential = CreateCredential( + other, + f.OldPasswordHash, + id: id); + + f.CredentialStore + .Setup(x => x.GetAsync( + new CredentialKey(context.ResourceTenant, id), + It.IsAny())) + .ReturnsAsync(credential); + + var result = await f.Sut.RevokeAsync( + context, + new RevokeCredentialRequest + { + Id = id + }); + + result.Succeeded.Should().BeFalse(); + result.Error.Should().Be("credential_not_found"); + + f.CredentialStore.Verify(x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RevokeAsync_WhenValid_RevokesAndSavesExpectedVersion() + { + var f = CreateFixture(); + var user = UserKey.New(); + var id = Guid.NewGuid(); + + var context = TestAccessContext.ForUser( + user, + UAuthActions.Credentials.RevokeSelf); + + var credential = CreateCredential( + user, + f.OldPasswordHash, + id: id, + version: 9); + + f.CredentialStore + .Setup(x => x.GetAsync( + new CredentialKey(context.ResourceTenant, id), + It.IsAny())) + .ReturnsAsync(credential); + + f.CredentialStore + .Setup(x => x.SaveAsync( + It.Is(c => + c.Id == id && + c.IsRevoked && + c.Security.RevokedAt == Now), + 9, + It.IsAny())) + .Returns(Task.CompletedTask); + + var result = await f.Sut.RevokeAsync( + context, + new RevokeCredentialRequest + { + Id = id + }); + + result.Succeeded.Should().BeTrue(); + } + + // --------------------------------------------------------- + // Delete + // --------------------------------------------------------- + + [Theory] + [InlineData(DeleteMode.Soft)] + [InlineData(DeleteMode.Hard)] + public async Task DeleteAsync_WhenValid_UsesRequestedDeleteMode(DeleteMode mode) + { + var f = CreateFixture(); + + var actor = UserKey.New(); + var target = UserKey.New(); + var id = Guid.NewGuid(); + + var context = TestAccessContext.ForTargetUser( + actorUserKey: actor, + targetUserKey: target, + action: UAuthActions.Credentials.DeleteAdmin); + + var credential = CreateCredential( + target, + f.OldPasswordHash, + id: id, + version: 5); + + var key = new CredentialKey( + context.ResourceTenant, + id); + + f.CredentialStore + .Setup(x => x.GetAsync( + key, + It.IsAny())) + .ReturnsAsync(credential); + + f.CredentialStore + .Setup(x => x.DeleteAsync( + key, + 5, + mode, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + var result = await f.Sut.DeleteAsync( + context, + new DeleteCredentialRequest + { + Id = id, + Mode = mode + }); + + result.Succeeded.Should().BeTrue( + $"delete should succeed but returned '{result.Error}'"); + + result.Error.Should().BeNull(); + + f.CredentialStore.Verify(x => x.DeleteAsync( + key, + 5, + mode, + Now, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task DeleteAsync_WhenCredentialBelongsToDifferentUser_ReturnsNotFound() + { + var f = CreateFixture(); + + var target = UserKey.New(); + var other = UserKey.New(); + var id = Guid.NewGuid(); + + var actor = UserKey.New(); + + var context = TestAccessContext.ForTargetUser( + actor, + target, + UAuthActions.Credentials.DeleteAdmin); + + f.CredentialStore + .Setup(x => x.GetAsync( + new CredentialKey(context.ResourceTenant, id), + It.IsAny())) + .ReturnsAsync(CreateCredential( + other, + f.OldPasswordHash, + id: id)); + + var result = await f.Sut.DeleteAsync( + context, + new DeleteCredentialRequest + { + Id = id, + Mode = DeleteMode.Hard + }); + + result.Succeeded.Should().BeFalse(); + result.Error.Should().Be("credential_not_found"); + + f.CredentialStore.Verify(x => x.DeleteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + // --------------------------------------------------------- + // Helpers + // --------------------------------------------------------- + + private static PasswordCredential CreateCredential( + UserKey user, + PasswordHash hash, + Guid? id = null, + long version = 0) + { + return PasswordCredential.FromProjection( + id: id ?? Guid.NewGuid(), + tenant: TenantKey.Single, + userKey: user, + secretHash: hash, + security: CredentialSecurityState.Active(), + metadata: new CredentialMetadata(), + createdAt: Now.AddDays(-10), + updatedAt: null, + deletedAt: null, + version: version); + } + + private static void SetupSuccessfulChange( + Fixture f, + UserKey user) + { + var credential = CreateCredential( + user, + f.OldPasswordHash, + version: 3); + + f.CredentialStore + .Setup(x => x.GetByUserAsync( + user, + It.IsAny())) + .ReturnsAsync(new[] { credential }); + + f.Hasher + .Setup(x => x.Verify( + f.OldPasswordHash, + "current-password")) + .Returns(true); + + f.Hasher + .Setup(x => x.Verify( + f.OldPasswordHash, + "new-password")) + .Returns(false); + + f.Hasher + .Setup(x => x.Hash("new-password")) + .Returns(f.NewPasswordHash); + + f.CredentialStore + .Setup(x => x.SaveAsync( + It.Is(c => + c.SecretHash == f.NewPasswordHash), + 3, + It.IsAny())) + .Returns(Task.CompletedTask); + } + + private static Fixture CreateFixture() + => new(); + + private sealed class Fixture + { + public Mock AccessOrchestrator { get; } + = new(MockBehavior.Strict); + + public Mock CredentialStoreFactory { get; } + = new(MockBehavior.Strict); + + public Mock CredentialStore { get; } + = new(MockBehavior.Strict); + + public Mock SecurityManager { get; } + = new(MockBehavior.Strict); + + public Mock TokenGenerator { get; } + = new(MockBehavior.Strict); + + public Mock NumericCodeGenerator { get; } + = new(MockBehavior.Strict); + + public Mock Hasher { get; } + = new(MockBehavior.Strict); + + public Mock TokenHasher { get; } + = new(MockBehavior.Strict); + + public Mock IdentifierResolver { get; } + = new(MockBehavior.Strict); + + public Mock SessionStoreFactory { get; } + = new(MockBehavior.Strict); + + public Mock SessionStore { get; } + = new(MockBehavior.Strict); + + public Mock Clock { get; } + = new(MockBehavior.Strict); + + public PasswordHash OldPasswordHash { get; } + = PasswordHash.Create("test", "old-hash"); + + public PasswordHash NewPasswordHash { get; } + = PasswordHash.Create("test", "new-hash"); + + public CredentialManagementService Sut { get; } + + public Fixture() + { + Clock + .SetupGet(x => x.UtcNow) + .Returns(Now); + + AccessOrchestrator + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, + CancellationToken>( + (_, command, ct) => command.ExecuteAsync(ct)); + + AccessOrchestrator + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, + CancellationToken>( + (_, command, ct) => command.ExecuteAsync(ct)); + + AccessOrchestrator + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, + CancellationToken>( + (_, command, ct) => command.ExecuteAsync(ct)); + + AccessOrchestrator + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, + CancellationToken>( + (_, command, ct) => command.ExecuteAsync(ct)); + + CredentialStoreFactory + .Setup(x => x.Create(It.IsAny())) + .Returns(CredentialStore.Object); + + SessionStoreFactory + .Setup(x => x.Create(It.IsAny())) + .Returns(SessionStore.Object); + + var options = Options.Create( + TestServerOptions.Default()); + + Sut = new CredentialManagementService( + AccessOrchestrator.Object, + CredentialStoreFactory.Object, + SecurityManager.Object, + TokenGenerator.Object, + NumericCodeGenerator.Object, + Hasher.Object, + TokenHasher.Object, + IdentifierResolver.Object, + SessionStoreFactory.Object, + options, + Clock.Object); + } + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/EfCorePasswordCredentialStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/EfCorePasswordCredentialStoreContractTests.cs new file mode 100644 index 00000000..e058f593 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/EfCorePasswordCredentialStoreContractTests.cs @@ -0,0 +1,60 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Credentials.Reference; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials.Contracts; + +public sealed class EfCorePasswordCredentialStoreContractTests + : PasswordCredentialStoreContractTests +{ + protected override async Task + CreateDatabaseAsync() + { + var database = new Database(); + await database.InitializeAsync(); + return database; + } + + private sealed class Database + : IPasswordCredentialStoreTestDatabase + { + private readonly SqliteConnection _connection; + private readonly UAuthCredentialDbContext _db; + + public Database() + { + _connection = + new SqliteConnection("Data Source=:memory:"); + + var options = + new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _db = new UAuthCredentialDbContext(options); + } + + public async Task InitializeAsync() + { + await _connection.OpenAsync(); + await _db.Database.EnsureCreatedAsync(); + } + + public IPasswordCredentialStore CreateStore( + TenantKey tenant) + { + return new EfCorePasswordCredentialStore( + _db, + new TenantExecutionContext(tenant)); + } + + public async ValueTask DisposeAsync() + { + await _db.DisposeAsync(); + await _connection.DisposeAsync(); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/IPasswordCredentialStoreTestDatabase.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/IPasswordCredentialStoreTestDatabase.cs new file mode 100644 index 00000000..536d27d6 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/IPasswordCredentialStoreTestDatabase.cs @@ -0,0 +1,9 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Reference; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials.Contracts; + +public interface IPasswordCredentialStoreTestDatabase : IAsyncDisposable +{ + IPasswordCredentialStore CreateStore(TenantKey tenant); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/InMemoryPasswordCredentialStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/InMemoryPasswordCredentialStoreContractTests.cs new file mode 100644 index 00000000..6f37cae2 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/InMemoryPasswordCredentialStoreContractTests.cs @@ -0,0 +1,30 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.InMemory; +using CodeBeam.UltimateAuth.Credentials.Reference; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials.Contracts; + +public sealed class InMemoryPasswordCredentialStoreContractTests + : PasswordCredentialStoreContractTests +{ + protected override Task + CreateDatabaseAsync() + { + return Task.FromResult( + new Database()); + } + + private sealed class Database + : IPasswordCredentialStoreTestDatabase + { + private readonly InMemoryPasswordCredentialStoreFactory _factory = + new(); + + public IPasswordCredentialStore CreateStore(TenantKey tenant) + => _factory.Create(tenant); + + public ValueTask DisposeAsync() + => ValueTask.CompletedTask; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/PasswordCredentialStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/PasswordCredentialStoreContractTests.cs new file mode 100644 index 00000000..6afdaf5a --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/Store/PasswordCredentialStoreContractTests.cs @@ -0,0 +1,651 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Credentials.Contracts; + +public abstract class PasswordCredentialStoreContractTests +{ + protected abstract Task + CreateDatabaseAsync(); + + protected static PasswordCredential CreateCredential( + TenantKey tenant, + UserKey userKey, + string discriminator) + { + return PasswordCredential.Create( + id: Guid.NewGuid(), + tenant: tenant, + userKey: userKey, + secretHash: CreatePasswordHash(discriminator), + security: CredentialSecurityState.Active(), + metadata: new CredentialMetadata(), + now: Now); + } + + protected static PasswordHash CreatePasswordHash(string discriminator) + { + return PasswordHash.Create(algorithm: "test", hash: $"hashed-password-{discriminator}"); + } + + protected static readonly TenantKey TenantA = + TenantKey.FromExternal("tenant-a"); + + protected static readonly TenantKey TenantB = + TenantKey.FromExternal("tenant-b"); + + protected static readonly DateTimeOffset Now = + new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + // ============================================================ + // ADD / GET / EXISTS + // ============================================================ + + [Fact] + public async Task AddAsync_PersistsCredential() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + var credential = CreateCredential(TenantA, user, "credential-1"); + + await store.AddAsync(credential); + + var key = new CredentialKey( + credential.Tenant, + credential.Id); + + var persisted = await store.GetAsync(key); + + persisted.Should().NotBeNull(); + persisted!.Id.Should().Be(credential.Id); + persisted.Tenant.Should().Be(TenantA); + persisted.UserKey.Should().Be(user); + persisted.Version.Should().Be(0); + } + + [Fact] + public async Task ExistsAsync_WhenCredentialExists_ReturnsTrue() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var credential = CreateCredential( + TenantA, + UserKey.New(), + "credential-1"); + + await store.AddAsync(credential); + + var key = new CredentialKey(TenantA, credential.Id); + + (await store.ExistsAsync(key)).Should().BeTrue(); + } + + [Fact] + public async Task ExistsAsync_WhenCredentialDoesNotExist_ReturnsFalse() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var credential = CreateCredential( + TenantA, + UserKey.New(), + "missing"); + + var key = new CredentialKey(TenantA, credential.Id); + + (await store.ExistsAsync(key)).Should().BeFalse(); + } + + [Fact] + public async Task GetAsync_WhenCredentialDoesNotExist_ReturnsNull() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var credential = CreateCredential( + TenantA, + UserKey.New(), + "missing"); + + var result = await store.GetAsync( + new CredentialKey(TenantA, credential.Id)); + + result.Should().BeNull(); + } + + // ============================================================ + // UNIQUENESS + // ============================================================ + + [Fact] + public async Task AddAsync_WhenUserAlreadyHasActivePasswordCredential_ThrowsConflict() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + + var first = CreateCredential( + TenantA, user, "first"); + + var second = CreateCredential( + TenantA, user, "second"); + + await store.AddAsync(first); + + var act = () => store.AddAsync(second); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task AddAsync_AfterSoftDeletingExistingCredential_AllowsReplacement() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + + var first = CreateCredential( + TenantA, user, "first"); + + await store.AddAsync(first); + + await store.DeleteAsync( + new CredentialKey(TenantA, first.Id), + first.Version, + DeleteMode.Soft, + Now); + + var replacement = CreateCredential( + TenantA, user, "replacement"); + + var act = () => store.AddAsync(replacement); + + await act.Should().NotThrowAsync(); + + var active = await store.GetByUserAsync(user); + + active.Should().ContainSingle(); + active.Single().Id.Should().Be(replacement.Id); + } + + // ============================================================ + // SAVE / CONCURRENCY + // ============================================================ + + [Fact] + public async Task SaveAsync_WhenExpectedVersionMatches_PersistsChanges() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var credential = CreateCredential( + TenantA, + UserKey.New(), + "original"); + + await store.AddAsync(credential); + + var changedAt = Now.AddMinutes(10); + + credential.ChangeSecret( + CreatePasswordHash("changed"), + changedAt); + + await store.SaveAsync( + credential, + expectedVersion: 0); + + var persisted = await store.GetAsync( + new CredentialKey(TenantA, credential.Id)); + + persisted.Should().NotBeNull(); + + persisted!.SecretHash + .Should().Be(credential.SecretHash); + + persisted.UpdatedAt + .Should().Be(changedAt); + + persisted.Version.Should().Be(1); + } + + [Fact] + public async Task SaveAsync_WhenCredentialDoesNotExist_ThrowsNotFound() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var credential = CreateCredential( + TenantA, + UserKey.New(), + "missing"); + + var act = () => store.SaveAsync( + credential, + expectedVersion: 0); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task SaveAsync_WhenExpectedVersionIsStale_ThrowsConcurrency() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var credential = CreateCredential( + TenantA, + UserKey.New(), + "credential"); + + await store.AddAsync(credential); + + var changed = credential.Revoke( + Now.AddMinutes(5)); + + await store.SaveAsync( + changed, + credential.Version); + + var act = () => store.SaveAsync( + changed, + expectedVersion: credential.Version); + + await act.Should() + .ThrowAsync(); + } + + // ============================================================ + // REVOKE + // ============================================================ + + [Fact] + public async Task RevokeAsync_RevokesCredential() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var credential = CreateCredential( + TenantA, + UserKey.New(), + "credential"); + + await store.AddAsync(credential); + + var revokedAt = Now.AddMinutes(10); + + await store.RevokeAsync( + new CredentialKey(TenantA, credential.Id), + revokedAt, + credential.Version); + + var persisted = await store.GetAsync( + new CredentialKey(TenantA, credential.Id)); + + persisted.Should().NotBeNull(); + persisted!.Security.RevokedAt.Should().Be(revokedAt); + persisted.Version.Should().Be(1); + } + + [Fact] + public async Task RevokeAsync_WhenCredentialDoesNotExist_ThrowsNotFound() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var credential = CreateCredential( + TenantA, + UserKey.New(), + "missing"); + + var act = () => store.RevokeAsync( + new CredentialKey(TenantA, credential.Id), + Now, + 0); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task RevokeAsync_WhenExpectedVersionIsStale_ThrowsConcurrency() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var credential = CreateCredential( + TenantA, + UserKey.New(), + "credential"); + + await store.AddAsync(credential); + + await store.RevokeAsync( + new CredentialKey(TenantA, credential.Id), + Now, + 0); + + var act = () => store.RevokeAsync( + new CredentialKey(TenantA, credential.Id), + Now.AddMinutes(1), + 0); + + await act.Should() + .ThrowAsync(); + } + + // ============================================================ + // DELETE + // ============================================================ + + [Fact] + public async Task DeleteAsync_SoftDelete_HidesCredentialFromGetByUser() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + var credential = CreateCredential( + TenantA, user, "credential"); + + await store.AddAsync(credential); + + await store.DeleteAsync( + new CredentialKey(TenantA, credential.Id), + credential.Version, + DeleteMode.Soft, + Now); + + var credentials = await store.GetByUserAsync(user); + + credentials.Should().BeEmpty(); + } + + [Fact] + public async Task DeleteAsync_SoftDelete_PreservesCredentialRecord() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var credential = CreateCredential( + TenantA, + UserKey.New(), + "credential"); + + await store.AddAsync(credential); + + await store.DeleteAsync( + new CredentialKey(TenantA, credential.Id), + 0, + DeleteMode.Soft, + Now); + + var persisted = await store.GetAsync( + new CredentialKey(TenantA, credential.Id)); + + persisted.Should().NotBeNull(); + persisted!.IsDeleted.Should().BeTrue(); + persisted.DeletedAt.Should().Be(Now); + persisted.Version.Should().Be(1); + } + + [Fact] + public async Task DeleteAsync_HardDelete_RemovesCredential() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var credential = CreateCredential( + TenantA, + UserKey.New(), + "credential"); + + await store.AddAsync(credential); + + var key = new CredentialKey( + TenantA, + credential.Id); + + await store.DeleteAsync( + key, + credential.Version, + DeleteMode.Hard, + Now); + + (await store.GetAsync(key)).Should().BeNull(); + (await store.ExistsAsync(key)).Should().BeFalse(); + } + + [Fact] + public async Task DeleteAsync_WhenExpectedVersionIsStale_ThrowsConcurrency() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var credential = CreateCredential( + TenantA, + UserKey.New(), + "credential"); + + await store.AddAsync(credential); + + await store.RevokeAsync( + new CredentialKey(TenantA, credential.Id), + Now, + 0); + + var act = () => store.DeleteAsync( + new CredentialKey(TenantA, credential.Id), + expectedVersion: 0, + DeleteMode.Soft, + Now.AddMinutes(1)); + + await act.Should() + .ThrowAsync(); + } + + // ============================================================ + // GET BY USER + // ============================================================ + + [Fact] + public async Task GetByUserAsync_ReturnsOnlyActiveNonDeletedCredentialsForUser() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var userA = UserKey.New(); + var userB = UserKey.New(); + + var credentialA = CreateCredential( + TenantA, userA, "a"); + + var credentialB = CreateCredential( + TenantA, userB, "b"); + + await store.AddAsync(credentialA); + await store.AddAsync(credentialB); + + var result = await store.GetByUserAsync(userA); + + result.Should().ContainSingle(); + result.Single().Id.Should().Be(credentialA.Id); + } + + // ============================================================ + // DELETE BY USER + // ============================================================ + + [Fact] + public async Task DeleteByUserAsync_SoftDelete_DeletesUsersCredentials() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + + var credential = CreateCredential( + TenantA, user, "credential"); + + await store.AddAsync(credential); + + await store.DeleteByUserAsync( + user, + DeleteMode.Soft, + Now); + + var active = await store.GetByUserAsync(user); + active.Should().BeEmpty(); + + var persisted = await store.GetAsync( + new CredentialKey(TenantA, credential.Id)); + + persisted.Should().NotBeNull(); + persisted!.IsDeleted.Should().BeTrue(); + persisted.DeletedAt.Should().Be(Now); + persisted.Version.Should().Be(1); + } + + [Fact] + public async Task DeleteByUserAsync_HardDelete_RemovesUsersCredentials() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + + var credential = CreateCredential( + TenantA, user, "credential"); + + await store.AddAsync(credential); + + await store.DeleteByUserAsync( + user, + DeleteMode.Hard, + Now); + + var persisted = await store.GetAsync( + new CredentialKey(TenantA, credential.Id)); + + persisted.Should().BeNull(); + } + + [Fact] + public async Task DeleteByUserAsync_DoesNotAffectOtherUsers() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var targetUser = UserKey.New(); + var otherUser = UserKey.New(); + + var target = CreateCredential( + TenantA, targetUser, "target"); + + var other = CreateCredential( + TenantA, otherUser, "other"); + + await store.AddAsync(target); + await store.AddAsync(other); + + await store.DeleteByUserAsync( + targetUser, + DeleteMode.Hard, + Now); + + (await store.GetAsync( + new CredentialKey(TenantA, target.Id))) + .Should().BeNull(); + + (await store.GetAsync( + new CredentialKey(TenantA, other.Id))) + .Should().NotBeNull(); + } + + // ============================================================ + // TENANT ISOLATION + // ============================================================ + + [Fact] + public async Task GetByUserAsync_IsTenantIsolated() + { + await using var db = await CreateDatabaseAsync(); + + var storeA = db.CreateStore(TenantA); + var storeB = db.CreateStore(TenantB); + + var user = UserKey.New(); + + var credentialA = CreateCredential( + TenantA, user, "a"); + + var credentialB = CreateCredential( + TenantB, user, "b"); + + await storeA.AddAsync(credentialA); + await storeB.AddAsync(credentialB); + + var fromA = await storeA.GetByUserAsync(user); + var fromB = await storeB.GetByUserAsync(user); + + fromA.Should().ContainSingle(); + fromA.Single().Id.Should().Be(credentialA.Id); + + fromB.Should().ContainSingle(); + fromB.Single().Id.Should().Be(credentialB.Id); + } + + [Fact] + public async Task AddAsync_WhenCredentialBelongsToDifferentTenant_IsRejected() + { + await using var db = await CreateDatabaseAsync(); + + var store = db.CreateStore(TenantA); + + var credential = CreateCredential( + TenantB, + UserKey.New(), + "cross-tenant"); + + var act = () => store.AddAsync(credential); + + var exception = await act.Should() + .ThrowAsync(); + + exception.Which.Code.Should().Be("tenant_mismatch"); + } + + // ============================================================ + // CANCELLATION + // ============================================================ + + [Fact] + public async Task GetByUserAsync_WhenCancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => store.GetByUserAsync( + UserKey.New(), + cts.Token); + + await act.Should() + .ThrowAsync(); + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs index 46de8a34..a273a589 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs @@ -11,24 +11,42 @@ namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; internal static class AuthFlowTestFactory { - public static AuthFlowContext LoginSuccess(ReturnUrlInfo? returnUrlInfo = null, EffectiveRedirectResponse? redirect = null) + public static AuthFlowContext LoginSuccess( + ReturnUrlInfo? returnUrlInfo = null, + EffectiveRedirectResponse? redirect = null) + => New( + returnUrlInfo: returnUrlInfo, + redirect: redirect); + + public static AuthFlowContext New( + ReturnUrlInfo? returnUrlInfo = null, + EffectiveRedirectResponse? redirect = null, + TenantKey? tenant = null, + UserKey? userKey = null, + SessionSecurityContext? session = null, + bool isAuthenticated = true, + EffectiveAuthResponse? response = null) { return new AuthFlowContext( flowType: AuthFlowType.Login, clientProfile: UAuthClientProfile.BlazorServer, effectiveMode: UAuthMode.PureOpaque, device: TestDevice.Default(), - tenantKey: TenantKey.Single, - isAuthenticated: true, - userKey: UserKey.New(), - session: null, + tenantKey: tenant ?? TenantKey.Single, + isAuthenticated: isAuthenticated, + userKey: userKey ?? (isAuthenticated ? UserKey.New() : null), + session: session, originalOptions: TestServerOptions.Default(), effectiveOptions: TestServerOptions.Effective(), - response: new EffectiveAuthResponse( - sessionIdDelivery: CredentialResponseOptions.Disabled(GrantKind.Session), - accessTokenDelivery: CredentialResponseOptions.Disabled(GrantKind.AccessToken), - refreshTokenDelivery: CredentialResponseOptions.Disabled(GrantKind.RefreshToken), - redirect: redirect ?? EffectiveRedirectResponse.Disabled + response: response ?? new EffectiveAuthResponse( + sessionIdDelivery: + CredentialResponseOptions.Disabled(GrantKind.Session), + accessTokenDelivery: + CredentialResponseOptions.Disabled(GrantKind.AccessToken), + refreshTokenDelivery: + CredentialResponseOptions.Disabled(GrantKind.RefreshToken), + redirect: + redirect ?? EffectiveRedirectResponse.Disabled ), primaryTokenKind: PrimaryTokenKind.Session, returnUrlInfo: returnUrlInfo ?? ReturnUrlInfo.None() diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs index def622e2..0d90850d 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs @@ -22,7 +22,12 @@ public static AccessContext WithAction(string action) ); } - public static AccessContext ForUser(UserKey userKey, string action, TenantKey? tenant = null) + public static AccessContext ForUser( + UserKey userKey, + string action, + TenantKey? tenant = null, + SessionChainId? actorChainId = null, + string resource = "identifier") { var t = tenant ?? TenantKey.Single; @@ -31,12 +36,36 @@ public static AccessContext ForUser(UserKey userKey, string action, TenantKey? t actorTenant: t, isAuthenticated: true, isSystemActor: false, - actorChainId: null, - resource: "identifier", + actorChainId: actorChainId, + resource: resource, targetUserKey: userKey, resourceTenant: t, action: action, attributes: EmptyAttributes.Instance ); } + + public static AccessContext ForTargetUser( + UserKey actorUserKey, + UserKey targetUserKey, + string action, + TenantKey? tenant = null, + SessionChainId? actorChainId = null, + string resource = "identifier") + { + var t = tenant ?? TenantKey.Single; + + return new AccessContext( + actorUserKey: actorUserKey, + actorTenant: t, + isAuthenticated: true, + isSystemActor: false, + actorChainId: actorChainId, + resource: resource, + targetUserKey: targetUserKey, + resourceTenant: t, + action: action, + attributes: EmptyAttributes.Instance + ); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthState.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthState.cs index 8c087d34..c8559f1f 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthState.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthState.cs @@ -68,10 +68,7 @@ public static UAuthState WithSession(SessionState sessionState) return state; } - public static UAuthState Full( - string userId, - string[] roles, - string[] permissions) + public static UAuthState Full(string userId, string[] roles, string[] permissions) { var claims = new List<(string, string)>(); @@ -80,4 +77,39 @@ public static UAuthState Full( return Authenticated(userId, claims.ToArray()); } + + public static UAuthState Create(string userId = "user-1", string[]? roles = null, string[]? permissions = null, SessionState sessionState = SessionState.Active, UserStatus userStatus = UserStatus.Active) + { + var claims = new List<(string Type, string Value)>(); + + if (roles is not null) + { + claims.AddRange( + roles.Select(x => (ClaimTypes.Role, x))); + } + + if (permissions is not null) + { + claims.AddRange( + permissions.Select(x => ("uauth:permission", x))); + } + + var state = Authenticated(userId, claims.ToArray()); + + var identity = state.Identity! with + { + SessionState = sessionState, + UserStatus = userStatus + }; + + var snapshot = new AuthStateSnapshot + { + Identity = identity, + Claims = state.Claims + }; + + state.ApplySnapshot(snapshot, DateTimeOffset.UtcNow); + + return state; + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs index 5e4c9238..b5a46a06 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs @@ -1,4 +1,5 @@ -ο»Ώusing CodeBeam.UltimateAuth.Core.Domain; +ο»Ώusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; @@ -6,4 +7,6 @@ internal static class TestDevice { public static DeviceContext Default() => DeviceContext.Create(DeviceId.Create("test-device-000-000-000-000-01"), null, null, null, null, null); public static DeviceContext Alternative() => DeviceContext.Create(DeviceId.Create("test-device-000-000-000-000-alternative"), null, null, null, null, null); + public static DeviceInfo DefaultDeviceInfo() => new DeviceInfo() { DeviceId = DeviceId.Create("test-device-info-000-000-000-000-01") }; + } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContextExtensions.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContextExtensions.cs index fa45dccd..0328b990 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContextExtensions.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContextExtensions.cs @@ -1,4 +1,5 @@ -ο»Ώusing Microsoft.AspNetCore.Http; +ο»Ώusing CodeBeam.UltimateAuth.Core.Defaults; +using Microsoft.AspNetCore.Http; namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; @@ -32,7 +33,7 @@ public static HttpContext WithReturnUrl(this HttpContext ctx, string returnUrl) { return ctx.WithForm(new Dictionary { - ["return_url"] = returnUrl + [UAuthConstants.Form.ReturnUrl] = returnUrl }); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs index 1e4ad3d5..304269ed 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs @@ -1,4 +1,5 @@ ο»Ώusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; @@ -16,4 +17,7 @@ public static AuthSessionId Session(string raw) return id; } + + public static TenantKey Tenant(string value) + => TenantKey.FromExternal(value); } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginEndpointHandlerTests.cs new file mode 100644 index 00000000..9aa2dda2 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginEndpointHandlerTests.cs @@ -0,0 +1,644 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Server.Stores; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Users; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.Extensions.Options; +using Moq; +using System.Text; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server; + +public sealed class LoginEndpointHandlerTests +{ + private static readonly DateTimeOffset Now = + new(2026, 9, 20, 12, 0, 0, TimeSpan.Zero); + + // ===================================================================== + // TryLoginAsync - request validation + // ===================================================================== + + [Fact] + public async Task TryLoginAsync_WhenContentTypeIsUnsupported_ReturnsBadRequest() + { + var fixture = CreateFixture(); + + var result = await fixture.Sut.TryLoginAsync( + fixture.HttpContext); + + var badRequest = result.Should() + .BeOfType>() + .Subject; + + badRequest.Value.Should().Be("Invalid content type."); + + fixture.InternalFlow.VerifyNoOtherCalls(); + fixture.AuthStore.VerifyNoOtherCalls(); + fixture.IdentifierResolver.VerifyNoOtherCalls(); + } + + [Theory] + [InlineData("", "password")] + [InlineData("alice", "")] + [InlineData(" ", "password")] + [InlineData("alice", " ")] + public async Task TryLoginAsync_WhenCredentialsAreMissing_ReturnsInvalidCredentials( + string identifier, + string secret) + { + var fixture = CreateFixture(); + + SetLoginJson( + fixture.HttpContext, + identifier, + secret); + + var result = await fixture.Sut.TryLoginAsync( + fixture.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().NotBeNull(); + ok.Value!.Success.Should().BeFalse(); + ok.Value.Reason.Should() + .Be(AuthFailureReason.InvalidCredentials); + + fixture.InternalFlow.VerifyNoOtherCalls(); + fixture.AuthStore.VerifyNoOtherCalls(); + fixture.IdentifierResolver.VerifyNoOtherCalls(); + } + + // ===================================================================== + // TryLoginAsync - preview execution + // ===================================================================== + + [Fact] + public async Task TryLoginAsync_ExecutesPreviewWithExpectedRequestAndOptions() + { + var fixture = CreateFixture(); + + SetLoginJson( + fixture.HttpContext, + identifier: "alice", + secret: "password"); + + LoginRequest? capturedRequest = null; + LoginExecutionOptions? capturedOptions = null; + + fixture.InternalFlow + .Setup(x => x.LoginAsync( + fixture.Flow, + It.IsAny(), + It.IsAny(), + fixture.HttpContext.RequestAborted)) + .Callback( + (_, request, options, _) => + { + capturedRequest = request; + capturedOptions = options; + }) + .ReturnsAsync( + LoginResult.Failed( + AuthFailureReason.InvalidCredentials)); + + var result = await fixture.Sut.TryLoginAsync( + fixture.HttpContext); + + result.Should() + .BeOfType>(); + + capturedRequest.Should().NotBeNull(); + + capturedRequest!.Identifier.Should().Be("alice"); + capturedRequest.Secret.Should().Be("password"); + capturedRequest.Factor.Should() + .Be(CredentialType.Password); + + capturedRequest.RequestTokens.Should() + .Be(fixture.Flow.AllowsTokenIssuance); + + capturedOptions.Should().NotBeNull(); + + capturedOptions!.Mode.Should() + .Be(LoginExecutionMode.Preview); + + capturedOptions.SuppressFailureAttempt.Should() + .BeFalse(); + + capturedOptions.SuppressSuccessReset.Should() + .BeTrue(); + } + + [Fact] + public async Task TryLoginAsync_WhenPreviewFails_ForwardsFailureInformation() + { + var fixture = CreateFixture(); + + var lockoutUntil = Now.AddMinutes(5); + + SetLoginJson( + fixture.HttpContext, + identifier: "alice", + secret: "wrong-password"); + + var failure = LoginResult.Failed( + AuthFailureReason.InvalidCredentials, + lockoutUntilUtc: lockoutUntil, + remainingAttempts: 2); + + fixture.InternalFlow + .Setup(x => x.LoginAsync( + fixture.Flow, + It.IsAny(), + It.Is( + o => o.Mode == LoginExecutionMode.Preview), + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(failure); + + var result = await fixture.Sut.TryLoginAsync( + fixture.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().NotBeNull(); + + ok.Value!.Success.Should().BeFalse(); + + ok.Value.Reason.Should() + .Be(AuthFailureReason.InvalidCredentials); + + ok.Value.RemainingAttempts.Should() + .Be(2); + + ok.Value.LockoutUntilUtc.Should() + .Be(lockoutUntil); + + ok.Value.RequiresMfa.Should() + .BeFalse(); + + ok.Value.PreviewReceipt.Should() + .BeNull(); + + fixture.AuthStore.VerifyNoOtherCalls(); + fixture.IdentifierResolver.VerifyNoOtherCalls(); + } + + // ===================================================================== + // TryLoginAsync - preview receipt + // ===================================================================== + + [Fact] + public async Task TryLoginAsync_WhenPreviewSucceedsAndIdentifierResolves_StoresPreviewArtifact() + { + var fixture = CreateFixture(); + + var userKey = UserKey.New(); + + SetLoginJson( + fixture.HttpContext, + identifier: "alice", + secret: "password"); + + fixture.InternalFlow + .Setup(x => x.LoginAsync( + fixture.Flow, + It.IsAny(), + It.Is( + o => + o.Mode == LoginExecutionMode.Preview && + !o.SuppressFailureAttempt && + o.SuppressSuccessReset), + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(LoginResult.SuccessPreview()); + + fixture.IdentifierResolver + .Setup(x => x.ResolveAsync( + fixture.Flow.Tenant, + "alice", + fixture.HttpContext.RequestAborted)) + .ReturnsAsync( + CreateIdentifierResolution( + fixture.Flow.Tenant, + userKey, + "alice")); + + AuthArtifactKey? storedKey = null; + LoginPreviewArtifact? storedArtifact = null; + + fixture.AuthStore + .Setup(x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + fixture.HttpContext.RequestAborted)) + .Callback( + (key, artifact, _) => + { + storedKey = key; + storedArtifact = artifact.Should() + .BeOfType() + .Subject; + }) + .Returns(Task.CompletedTask); + + var result = await fixture.Sut.TryLoginAsync( + fixture.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().NotBeNull(); + ok.Value!.Success.Should().BeTrue(); + + ok.Value.PreviewReceipt.Should() + .NotBeNullOrWhiteSpace(); + + storedKey.Should().NotBeNull(); + + storedKey!.Value.Should() + .Be(ok.Value.PreviewReceipt); + + storedArtifact.Should().NotBeNull(); + + fixture.AuthStore.Verify(x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + fixture.HttpContext.RequestAborted), + Times.Once); + } + + [Fact] + public async Task TryLoginAsync_WhenPreviewSucceedsButIdentifierDoesNotResolve_DoesNotStoreArtifact() + { + var fixture = CreateFixture(); + + SetLoginJson( + fixture.HttpContext, + identifier: "alice", + secret: "password"); + + fixture.InternalFlow + .Setup(x => x.LoginAsync( + fixture.Flow, + It.IsAny(), + It.IsAny(), + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(LoginResult.SuccessPreview()); + + fixture.IdentifierResolver + .Setup(x => x.ResolveAsync( + fixture.Flow.Tenant, + "alice", + fixture.HttpContext.RequestAborted)) + .ReturnsAsync((LoginIdentifierResolution?)null); + + var result = await fixture.Sut.TryLoginAsync( + fixture.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().NotBeNull(); + ok.Value!.Success.Should().BeTrue(); + + fixture.AuthStore.Verify( + x => x.StoreAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + // ===================================================================== + // LoginAsync - preview receipt consumption + // ===================================================================== + + [Fact] + public async Task LoginAsync_WhenPreviewReceiptMatches_ConsumesReceiptAndSuppressesFailureAttempt() + { + var fixture = CreateFixture(); + + const string receiptValue = "preview-receipt"; + const string identifier = "alice"; + const string secret = "password"; + + SetLoginJson( + fixture.HttpContext, + identifier, + secret, + previewReceipt: receiptValue); + + fixture.Flow.Device.DeviceId.Should().NotBeNull(); + + var deviceId = fixture.Flow.Device.DeviceId!.Value; + var userKey = UserKey.New(); + + var fingerprint = LoginPreviewFingerprint.Create( + fixture.Flow.Tenant, + identifier, + CredentialType.Password, + secret, + deviceId); + + var artifact = new LoginPreviewArtifact( + fixture.Flow.Tenant, + userKey, + CredentialType.Password, + deviceId.Value, + identifier, + fixture.Flow.ClientProfile, + fingerprint, + Now.AddMinutes(5)); + + var key = new AuthArtifactKey(receiptValue); + + fixture.AuthStore + .Setup(x => x.GetAsync( + key, + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(artifact); + + fixture.AuthStore + .Setup(x => x.ConsumeAsync( + key, + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(artifact); + + LoginExecutionOptions? capturedOptions = null; + + fixture.InternalFlow + .Setup(x => x.LoginAsync( + fixture.Flow, + It.IsAny(), + It.IsAny(), + fixture.HttpContext.RequestAborted)) + .Callback( + (_, _, options, _) => + { + capturedOptions = options; + }) + .ReturnsAsync(LoginResult.SuccessPreview()); + + fixture.RedirectResolver + .Setup(x => x.ResolveSuccess( + fixture.Flow, + fixture.HttpContext)) + .Returns(RedirectDecision.None()); + + var result = await fixture.Sut.LoginAsync( + fixture.HttpContext); + + result.Should().BeOfType(); + + capturedOptions.Should().NotBeNull(); + + capturedOptions!.Mode.Should() + .Be(LoginExecutionMode.Commit); + + capturedOptions.SuppressFailureAttempt.Should() + .BeTrue(); + + capturedOptions.SuppressSuccessReset.Should() + .BeFalse(); + + fixture.AuthStore.Verify(x => x.ConsumeAsync( + key, + fixture.HttpContext.RequestAborted), + Times.Once); + } + + [Fact] + public async Task LoginAsync_WhenPreviewReceiptDoesNotMatch_DoesNotConsumeAndDoesNotSuppress() + { + var fixture = CreateFixture(); + + const string receiptValue = "preview-receipt"; + const string identifier = "alice"; + + SetLoginJson( + fixture.HttpContext, + identifier, + secret: "different-password", + previewReceipt: receiptValue); + + fixture.Flow.Device.DeviceId.Should().NotBeNull(); + + var deviceId = fixture.Flow.Device.DeviceId!.Value; + + var fingerprint = LoginPreviewFingerprint.Create( + fixture.Flow.Tenant, + identifier, + CredentialType.Password, + "original-password", + deviceId); + + var artifact = new LoginPreviewArtifact( + fixture.Flow.Tenant, + UserKey.New(), + CredentialType.Password, + deviceId.Value, + identifier, + fixture.Flow.ClientProfile, + fingerprint, + Now.AddMinutes(5)); + + var key = new AuthArtifactKey(receiptValue); + + fixture.AuthStore + .Setup(x => x.GetAsync( + key, + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(artifact); + + LoginExecutionOptions? capturedOptions = null; + + fixture.InternalFlow + .Setup(x => x.LoginAsync( + fixture.Flow, + It.IsAny(), + It.IsAny(), + fixture.HttpContext.RequestAborted)) + .Callback( + (_, _, options, _) => + { + capturedOptions = options; + }) + .ReturnsAsync(LoginResult.SuccessPreview()); + + fixture.RedirectResolver + .Setup(x => x.ResolveSuccess( + fixture.Flow, + fixture.HttpContext)) + .Returns(RedirectDecision.None()); + + var result = await fixture.Sut.LoginAsync( + fixture.HttpContext); + + result.Should().BeOfType(); + + capturedOptions.Should().NotBeNull(); + + capturedOptions!.Mode.Should() + .Be(LoginExecutionMode.Commit); + + capturedOptions.SuppressFailureAttempt.Should() + .BeFalse(); + + capturedOptions.SuppressSuccessReset.Should() + .BeFalse(); + + fixture.AuthStore.Verify( + x => x.ConsumeAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + // ===================================================================== + // Helpers + // ===================================================================== + + private static Fixture CreateFixture(AuthFlowContext? flow = null) + { + flow ??= AuthFlowTestFactory.LoginSuccess(); + + var authFlow = new Mock(MockBehavior.Strict); + + var internalFlow = new Mock(MockBehavior.Strict); + + var credentialWriter = new Mock(MockBehavior.Strict); + + var redirectResolver = new Mock(MockBehavior.Loose); + + var authStore = new Mock(MockBehavior.Strict); + + var identifierResolver = new Mock(MockBehavior.Strict); + + var clock = new Mock(MockBehavior.Strict); + + authFlow + .SetupGet(x => x.Current) + .Returns(flow); + + clock + .SetupGet(x => x.UtcNow) + .Returns(Now); + + var options = Options.Create( + TestServerOptions.Default()); + + var httpContext = + new DefaultHttpContext(); + + var sut = new LoginEndpointHandler( + authFlow.Object, + internalFlow.Object, + credentialWriter.Object, + redirectResolver.Object, + authStore.Object, + identifierResolver.Object, + options, + clock.Object); + + return new Fixture( + sut, + flow, + internalFlow, + credentialWriter, + redirectResolver, + authStore, + identifierResolver, + httpContext); + } + + private static void SetLoginJson( + HttpContext context, + string identifier, + string secret, + string? previewReceipt = null) + { + SetJsonBody( + context, + new LoginRequest + { + Identifier = identifier, + Secret = secret, + Factor = CredentialType.Password, + PreviewReceipt = previewReceipt + }); + } + + private static void SetJsonBody( + HttpContext context, + object value) + { + var json = + JsonSerializer.Serialize(value); + + var bytes = + Encoding.UTF8.GetBytes(json); + + context.Request.ContentType = + "application/json"; + + context.Request.ContentLength = + bytes.Length; + + context.Request.Body = + new MemoryStream(bytes); + } + + private static LoginIdentifierResolution CreateIdentifierResolution( + TenantKey tenant, + UserKey userKey, + string identifier) + { + return new LoginIdentifierResolution + { + Tenant = tenant, + UserKey = userKey, + RawIdentifier = identifier, + NormalizedIdentifier = identifier, + IsVerified = true + }; + } + + private sealed record Fixture( + LoginEndpointHandler Sut, + AuthFlowContext Flow, + Mock InternalFlow, + Mock CredentialWriter, + Mock RedirectResolver, + Mock AuthStore, + Mock IdentifierResolver, + DefaultHttpContext HttpContext); +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LogoutEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LogoutEndpointHandlerTests.cs new file mode 100644 index 00000000..b21576b2 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LogoutEndpointHandlerTests.cs @@ -0,0 +1,719 @@ +ο»Ώusing System.Text; +using System.Text.Json; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Users.Contracts; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server; + +public sealed class LogoutEndpointHandlerTests +{ + // ===================================================================== + // LogoutAsync + // ===================================================================== + + [Fact] + public async Task LogoutAsync_WhenSessionIsMissing_DoesNotCallFlowLogout() + { + var fixture = CreateFixture(); + + SetupNoRedirect(fixture); + + var result = await fixture.Sut.LogoutAsync( + fixture.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().NotBeNull(); + ok.Value!.Success.Should().BeTrue(); + + fixture.FlowService.Verify( + x => x.LogoutAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task LogoutAsync_WhenSessionExists_ForwardsCurrentSessionId() + { + var sessionId = TestIds.Session("logout-current-session"); + + var session = new SessionSecurityContext + { + UserKey = UserKey.New(), + SessionId = sessionId, + State = SessionState.Active, + ChainId = SessionChainId.New(), + BoundDeviceId = null + }; + + var flow = CreateFlowWithSession(session); + var fixture = CreateFixture(flow); + + fixture.FlowService + .Setup(x => x.LogoutAsync( + It.Is( + request => request.SessionId == sessionId), + fixture.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + SetupNoRedirect(fixture); + + var result = await fixture.Sut.LogoutAsync( + fixture.HttpContext); + + result.Should().BeOfType>(); + + fixture.FlowService.Verify(x => x.LogoutAsync( + It.Is( + request => request.SessionId == sessionId), + fixture.HttpContext.RequestAborted), + Times.Once); + } + + [Fact] + public async Task LogoutAsync_WhenRedirectIsEnabled_ReturnsRedirect() + { + var fixture = CreateFixture(); + + fixture.RedirectResolver + .Setup(x => x.ResolveSuccess( + fixture.Flow, + fixture.HttpContext)) + .Returns(new RedirectDecision( + enabled: true, + targetUrl: "/after-logout")); + + var result = await fixture.Sut.LogoutAsync( + fixture.HttpContext); + + var redirect = result.Should() + .BeOfType() + .Subject; + + redirect.Url.Should().Be("/after-logout"); + } + + [Fact] + public async Task LogoutAsync_WhenRedirectIsDisabled_ReturnsSuccessfulResponse() + { + var fixture = CreateFixture(); + + SetupNoRedirect(fixture); + + var result = await fixture.Sut.LogoutAsync( + fixture.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().NotBeNull(); + ok.Value!.Success.Should().BeTrue(); + } + + // ===================================================================== + // LogoutDeviceSelfAsync + // ===================================================================== + + [Fact] + public async Task LogoutDeviceSelfAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var fixture = CreateFixture( + CreateUnauthenticatedFlow()); + + var result = await fixture.Sut.LogoutDeviceSelfAsync( + fixture.HttpContext); + + result.Should().BeOfType(); + + fixture.AccessFactory.VerifyNoOtherCalls(); + fixture.Sessions.VerifyNoOtherCalls(); + } + + [Fact] + public async Task LogoutDeviceSelfAsync_UsesSelfActionAndForwardsChain() + { + var fixture = CreateFixture(); + + var userKey = fixture.Flow.UserKey!.Value; + var chainId = SessionChainId.New(); + + SetJsonBody( + fixture.HttpContext, + new LogoutDeviceRequest + { + ChainId = chainId + }); + + var access = TestAccessContext.ForUser( + userKey, + UAuthActions.Flows.LogoutDeviceSelf); + + fixture.AccessFactory + .Setup(x => x.CreateAsync( + fixture.Flow, + UAuthActions.Flows.LogoutDeviceSelf, + "flows", + userKey.Value, + It.IsAny?>())) + .ReturnsAsync(access); + + var expected = new RevokeResult + { + CurrentChain = false, + RootRevoked = false + }; + + fixture.Sessions + .Setup(x => x.LogoutDeviceAsync( + access, + chainId, + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(expected); + + var result = await fixture.Sut.LogoutDeviceSelfAsync( + fixture.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().BeSameAs(expected); + + fixture.AccessFactory.Verify(x => x.CreateAsync( + fixture.Flow, + UAuthActions.Flows.LogoutDeviceSelf, + "flows", + userKey.Value, + It.IsAny?>()), + Times.Once); + + fixture.Sessions.Verify(x => x.LogoutDeviceAsync( + access, + chainId, + fixture.HttpContext.RequestAborted), + Times.Once); + } + + // ===================================================================== + // LogoutDeviceAdminAsync + // ===================================================================== + + [Fact] + public async Task LogoutDeviceAdminAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var fixture = CreateFixture( + CreateUnauthenticatedFlow()); + + var targetUser = UserKey.New(); + + var result = await fixture.Sut.LogoutDeviceAdminAsync( + fixture.HttpContext, + targetUser); + + result.Should().BeOfType(); + + fixture.AccessFactory.VerifyNoOtherCalls(); + fixture.Sessions.VerifyNoOtherCalls(); + } + + [Fact] + public async Task LogoutDeviceAdminAsync_UsesAdminActionAndTargetUser() + { + var fixture = CreateFixture(); + + var targetUser = UserKey.New(); + var chainId = SessionChainId.New(); + + SetJsonBody( + fixture.HttpContext, + new LogoutDeviceRequest + { + ChainId = chainId + }); + + var access = TestAccessContext.ForUser( + fixture.Flow.UserKey!.Value, + UAuthActions.Flows.LogoutDeviceAdmin); + + fixture.AccessFactory + .Setup(x => x.CreateAsync( + fixture.Flow, + UAuthActions.Flows.LogoutDeviceAdmin, + "flows", + targetUser.Value, + It.IsAny?>())) + .ReturnsAsync(access); + + var expected = new RevokeResult + { + CurrentChain = false, + RootRevoked = false + }; + + fixture.Sessions + .Setup(x => x.LogoutDeviceAsync( + access, + chainId, + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(expected); + + var result = await fixture.Sut.LogoutDeviceAdminAsync( + fixture.HttpContext, + targetUser); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().BeSameAs(expected); + + fixture.AccessFactory.Verify(x => x.CreateAsync( + fixture.Flow, + UAuthActions.Flows.LogoutDeviceAdmin, + "flows", + targetUser.Value, + It.IsAny?>()), + Times.Once); + + fixture.Sessions.Verify(x => x.LogoutDeviceAsync( + access, + chainId, + fixture.HttpContext.RequestAborted), + Times.Once); + } + + // ===================================================================== + // LogoutOthersSelfAsync + // ===================================================================== + + [Fact] + public async Task LogoutOthersSelfAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var fixture = CreateFixture( + CreateUnauthenticatedFlow()); + + var result = await fixture.Sut.LogoutOthersSelfAsync( + fixture.HttpContext); + + result.Should().BeOfType(); + + fixture.AccessFactory.VerifyNoOtherCalls(); + fixture.Sessions.VerifyNoOtherCalls(); + } + + [Fact] + public async Task LogoutOthersSelfAsync_WhenActorChainIsMissing_ReturnsUnauthorized() + { + var fixture = CreateFixture(); + + var userKey = fixture.Flow.UserKey!.Value; + + var access = TestAccessContext.ForUser( + userKey, + UAuthActions.Flows.LogoutOthersSelf); + + fixture.AccessFactory + .Setup(x => x.CreateAsync( + fixture.Flow, + UAuthActions.Flows.LogoutOthersSelf, + "flows", + userKey.Value, + It.IsAny?>())) + .ReturnsAsync(access); + + var result = await fixture.Sut.LogoutOthersSelfAsync( + fixture.HttpContext); + + result.Should().BeOfType(); + + fixture.Sessions.Verify( + x => x.LogoutOtherDevicesAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task LogoutOthersSelfAsync_UsesActorChainAsCurrentChain() + { + var fixture = CreateFixture(); + + var userKey = fixture.Flow.UserKey!.Value; + var currentChainId = SessionChainId.New(); + + var access = TestAccessContext.ForUser( + userKey, + UAuthActions.Flows.LogoutOthersSelf, + actorChainId: currentChainId); + + fixture.AccessFactory + .Setup(x => x.CreateAsync( + fixture.Flow, + UAuthActions.Flows.LogoutOthersSelf, + "flows", + userKey.Value, + It.IsAny?>())) + .ReturnsAsync(access); + + fixture.Sessions + .Setup(x => x.LogoutOtherDevicesAsync( + access, + userKey, + currentChainId, + fixture.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = await fixture.Sut.LogoutOthersSelfAsync( + fixture.HttpContext); + + result.Should().BeOfType(); + + fixture.Sessions.Verify(x => x.LogoutOtherDevicesAsync( + access, + userKey, + currentChainId, + fixture.HttpContext.RequestAborted), + Times.Once); + } + + // ===================================================================== + // LogoutOthersAdminAsync + // ===================================================================== + + [Fact] + public async Task LogoutOthersAdminAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var fixture = CreateFixture( + CreateUnauthenticatedFlow()); + + var targetUser = UserKey.New(); + + var result = await fixture.Sut.LogoutOthersAdminAsync( + fixture.HttpContext, + targetUser); + + result.Should().BeOfType(); + + fixture.AccessFactory.VerifyNoOtherCalls(); + fixture.Sessions.VerifyNoOtherCalls(); + } + + [Fact] + public async Task LogoutOthersAdminAsync_UsesRequestedCurrentChain() + { + var fixture = CreateFixture(); + + var targetUser = UserKey.New(); + var currentChainId = SessionChainId.New(); + + SetJsonBody( + fixture.HttpContext, + new LogoutOtherDevicesRequest + { + CurrentChainId = currentChainId + }); + + var access = TestAccessContext.ForUser( + fixture.Flow.UserKey!.Value, + UAuthActions.Flows.LogoutOthersAdmin); + + fixture.AccessFactory + .Setup(x => x.CreateAsync( + fixture.Flow, + UAuthActions.Flows.LogoutOthersAdmin, + "flows", + targetUser.Value, + It.IsAny?>())) + .ReturnsAsync(access); + + fixture.Sessions + .Setup(x => x.LogoutOtherDevicesAsync( + access, + targetUser, + currentChainId, + fixture.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = await fixture.Sut.LogoutOthersAdminAsync( + fixture.HttpContext, + targetUser); + + result.Should().BeOfType(); + + fixture.Sessions.Verify(x => x.LogoutOtherDevicesAsync( + access, + targetUser, + currentChainId, + fixture.HttpContext.RequestAborted), + Times.Once); + } + + // ===================================================================== + // LogoutAllSelfAsync + // ===================================================================== + + [Fact] + public async Task LogoutAllSelfAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var fixture = CreateFixture( + CreateUnauthenticatedFlow()); + + var result = await fixture.Sut.LogoutAllSelfAsync( + fixture.HttpContext); + + result.Should().BeOfType(); + + fixture.AccessFactory.VerifyNoOtherCalls(); + fixture.Sessions.VerifyNoOtherCalls(); + } + + [Fact] + public async Task LogoutAllSelfAsync_UsesSelfActionAndCurrentUser() + { + var fixture = CreateFixture(); + + var userKey = fixture.Flow.UserKey!.Value; + + var access = TestAccessContext.ForUser( + userKey, + UAuthActions.Flows.LogoutAllSelf); + + fixture.AccessFactory + .Setup(x => x.CreateAsync( + fixture.Flow, + UAuthActions.Flows.LogoutAllSelf, + "flows", + userKey.Value, + It.IsAny?>())) + .ReturnsAsync(access); + + fixture.Sessions + .Setup(x => x.LogoutAllDevicesAsync( + access, + userKey, + fixture.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = await fixture.Sut.LogoutAllSelfAsync( + fixture.HttpContext); + + result.Should().BeOfType(); + + fixture.Sessions.Verify(x => x.LogoutAllDevicesAsync( + access, + userKey, + fixture.HttpContext.RequestAborted), + Times.Once); + } + + // ===================================================================== + // LogoutAllAdminAsync + // ===================================================================== + + [Fact] + public async Task LogoutAllAdminAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var fixture = CreateFixture( + CreateUnauthenticatedFlow()); + + var result = await fixture.Sut.LogoutAllAdminAsync( + fixture.HttpContext, + UserKey.New()); + + result.Should().BeOfType(); + + fixture.AccessFactory.VerifyNoOtherCalls(); + fixture.Sessions.VerifyNoOtherCalls(); + } + + [Fact] + public async Task LogoutAllAdminAsync_UsesAdminActionAndTargetUser() + { + var fixture = CreateFixture(); + + var targetUser = UserKey.New(); + + var access = TestAccessContext.ForUser( + fixture.Flow.UserKey!.Value, + UAuthActions.Flows.LogoutAllAdmin); + + fixture.AccessFactory + .Setup(x => x.CreateAsync( + fixture.Flow, + UAuthActions.Flows.LogoutAllAdmin, + "flows", + targetUser.Value, + It.IsAny?>())) + .ReturnsAsync(access); + + fixture.Sessions + .Setup(x => x.LogoutAllDevicesAsync( + access, + targetUser, + fixture.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = await fixture.Sut.LogoutAllAdminAsync( + fixture.HttpContext, + targetUser); + + result.Should().BeOfType(); + + fixture.Sessions.Verify(x => x.LogoutAllDevicesAsync( + access, + targetUser, + fixture.HttpContext.RequestAborted), + Times.Once); + } + + // ===================================================================== + // Helpers + // ===================================================================== + + private static Fixture CreateFixture( + AuthFlowContext? flow = null) + { + flow ??= AuthFlowTestFactory.LoginSuccess(); + + var authContext = + new Mock(MockBehavior.Strict); + + var flowService = + new Mock(MockBehavior.Strict); + + var accessFactory = + new Mock(MockBehavior.Strict); + + var sessions = + new Mock(MockBehavior.Strict); + + var clock = + new Mock(MockBehavior.Strict); + + var cookieManager = + new Mock(MockBehavior.Strict); + + var redirectResolver = + new Mock(MockBehavior.Strict); + + authContext + .SetupGet(x => x.Current) + .Returns(flow); + + var httpContext = new DefaultHttpContext(); + + var sut = new LogoutEndpointHandler( + authContext.Object, + flowService.Object, + accessFactory.Object, + sessions.Object, + cookieManager.Object, + redirectResolver.Object); + + return new Fixture( + sut, + flow, + flowService, + accessFactory, + sessions, + cookieManager, + redirectResolver, + httpContext); + } + + private static AuthFlowContext CreateFlowWithSession( + SessionSecurityContext session) + { + var source = AuthFlowTestFactory.LoginSuccess(); + + return new AuthFlowContext( + flowType: source.FlowType, + clientProfile: source.ClientProfile, + effectiveMode: source.EffectiveMode, + device: source.Device, + tenantKey: source.Tenant, + isAuthenticated: true, + userKey: session.UserKey, + session: session, + originalOptions: source.OriginalOptions, + effectiveOptions: source.EffectiveOptions, + response: source.Response, + primaryTokenKind: source.PrimaryTokenKind, + returnUrlInfo: source.ReturnUrlInfo + ); + } + + private static AuthFlowContext CreateUnauthenticatedFlow() + { + var source = AuthFlowTestFactory.LoginSuccess(); + + return new AuthFlowContext( + flowType: source.FlowType, + clientProfile: source.ClientProfile, + effectiveMode: source.EffectiveMode, + device: source.Device, + tenantKey: source.Tenant, + isAuthenticated: false, + userKey: null, + session: null, + originalOptions: source.OriginalOptions, + effectiveOptions: source.EffectiveOptions, + response: source.Response, + primaryTokenKind: source.PrimaryTokenKind, + returnUrlInfo: source.ReturnUrlInfo + ); + } + + private static void SetupNoRedirect(Fixture fixture) + { + fixture.RedirectResolver + .Setup(x => x.ResolveSuccess( + fixture.Flow, + fixture.HttpContext)) + .Returns(new RedirectDecision( + enabled: false, + targetUrl: null)); + } + + private static void SetJsonBody( + HttpContext context, + object value) + { + var json = JsonSerializer.Serialize(value); + var bytes = Encoding.UTF8.GetBytes(json); + + context.Request.ContentType = "application/json"; + context.Request.ContentLength = bytes.Length; + context.Request.Body = new MemoryStream(bytes); + } + + private sealed record Fixture( + LogoutEndpointHandler Sut, + AuthFlowContext Flow, + Mock FlowService, + Mock AccessFactory, + Mock Sessions, + Mock CookieManager, + Mock RedirectResolver, + DefaultHttpContext HttpContext); +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceEndpointHandlerTests.cs new file mode 100644 index 00000000..19837618 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceEndpointHandlerTests.cs @@ -0,0 +1,836 @@ +ο»Ώusing System.Text; +using System.Text.Json; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Server.Stores; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server; + +public sealed class PkceEndpointHandlerTests +{ + private static readonly DateTimeOffset Now = + new(2026, 9, 20, 12, 0, 0, TimeSpan.Zero); + + // --------------------------------------------------------------------- + // Authorize + // --------------------------------------------------------------------- + + [Fact] + public async Task AuthorizeAsync_WhenContentTypeIsInvalid_ReturnsBadRequest() + { + var fixture = CreateFixture(); + + var result = await fixture.Sut.AuthorizeAsync(fixture.HttpContext); + + var badRequest = result.Should() + .BeOfType>() + .Subject; + + badRequest.Value.Should().Be("Invalid content type."); + + fixture.PkceService.VerifyNoOtherCalls(); + } + + [Fact] + public async Task AuthorizeAsync_WithJsonRequest_ForwardsCommandAndReturnsAuthorizationCode() + { + var fixture = CreateFixture(); + + var device = TestDevice.Default(); + + SetJsonBody(fixture.HttpContext, new + { + codeChallenge = "challenge-123", + challengeMethod = "S256", + redirectUri = "/after-login", + device + }); + + PkceAuthorizeCommand? captured = null; + + fixture.PkceService + .Setup(x => x.AuthorizeAsync( + It.IsAny(), + fixture.HttpContext.RequestAborted)) + .Callback( + (command, _) => captured = command) + .ReturnsAsync(new PkceAuthorizeResponse + { + AuthorizationCode = "authorization-code", + ExpiresIn = 300 + }); + + var result = await fixture.Sut.AuthorizeAsync( + fixture.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().NotBeNull(); + ok.Value!.AuthorizationCode.Should().Be("authorization-code"); + ok.Value.ExpiresIn.Should().Be(300); + + captured.Should().NotBeNull(); + captured!.CodeChallenge.Should().Be("challenge-123"); + captured.ChallengeMethod.Should().Be("S256"); + captured.RedirectUri.Should().Be("/after-login"); + captured.Device.Should().BeEquivalentTo(device); + captured.ClientProfile.Should().Be(fixture.Flow.ClientProfile); + captured.Tenant.Should().Be(fixture.Flow.Tenant); + } + + // --------------------------------------------------------------------- + // TryComplete + // --------------------------------------------------------------------- + + [Fact] + public async Task TryCompleteAsync_WhenFlowIsNotLogin_ReturnsBadRequest() + { + var fixture = CreateFixture(CreateNonLoginFlow()); + + var result = await fixture.Sut.TryCompleteAsync( + fixture.HttpContext); + + var badRequest = result.Should() + .BeOfType>() + .Subject; + + badRequest.Value.Should() + .Be("PKCE is only supported for login flow."); + + fixture.AuthStore.VerifyNoOtherCalls(); + fixture.Validator.VerifyNoOtherCalls(); + fixture.InternalFlow.VerifyNoOtherCalls(); + } + + [Fact] + public async Task TryCompleteAsync_WhenPayloadContentTypeIsInvalid_ReturnsBadRequest() + { + var fixture = CreateFixture(); + + var result = await fixture.Sut.TryCompleteAsync( + fixture.HttpContext); + + var badRequest = result.Should() + .BeOfType>() + .Subject; + + badRequest.Value.Should().Be("Invalid PKCE payload."); + + fixture.AuthStore.VerifyNoOtherCalls(); + fixture.Validator.VerifyNoOtherCalls(); + fixture.InternalFlow.VerifyNoOtherCalls(); + } + + [Fact] + public async Task TryCompleteAsync_WhenAuthorizationCodeIsMissing_ReturnsBadRequest() + { + var fixture = CreateFixture(); + + SetJsonBody(fixture.HttpContext, new + { + authorization_code = "", + code_verifier = "verifier", + identifier = "user", + secret = "password" + }); + + var result = await fixture.Sut.TryCompleteAsync(fixture.HttpContext); + + var badRequest = result.Should() + .BeOfType>() + .Subject; + + badRequest.Value.Should() + .Be("authorization_code and code_verifier are required."); + + fixture.AuthStore.VerifyNoOtherCalls(); + } + + [Fact] + public async Task TryCompleteAsync_WhenCodeVerifierIsMissing_ReturnsBadRequest() + { + var fixture = CreateFixture(); + + SetJsonBody(fixture.HttpContext, new + { + authorization_code = "code", + code_verifier = "", + identifier = "user", + secret = "password" + }); + + var result = await fixture.Sut.TryCompleteAsync(fixture.HttpContext); + + result.Should().BeOfType>(); + + fixture.AuthStore.VerifyNoOtherCalls(); + } + + [Fact] + public async Task TryCompleteAsync_WhenArtifactDoesNotExist_RequestsNewPkce() + { + var fixture = CreateFixture(); + + SetCompleteJson(fixture, "missing-code", "verifier"); + + fixture.AuthStore + .Setup(x => x.GetAsync( + new AuthArtifactKey("missing-code"), + fixture.HttpContext.RequestAborted)) + .ReturnsAsync((AuthArtifact?)null); + + var result = await fixture.Sut.TryCompleteAsync( + fixture.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().NotBeNull(); + ok.Value!.Success.Should().BeFalse(); + ok.Value.RetryWithNewPkce.Should().BeTrue(); + + fixture.Validator.VerifyNoOtherCalls(); + fixture.InternalFlow.VerifyNoOtherCalls(); + } + + [Fact] + public async Task TryCompleteAsync_WhenArtifactIsNotPkceArtifact_RequestsNewPkce() + { + var fixture = CreateFixture(); + var hub = TestHubFactory.Create(TestPkceFactory.Create().Artifact); + + SetCompleteJson(fixture, "code", "verifier"); + + fixture.AuthStore + .Setup(x => x.GetAsync( + new AuthArtifactKey("code"), + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(hub); + + var result = await fixture.Sut.TryCompleteAsync( + fixture.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value!.Success.Should().BeFalse(); + ok.Value.RetryWithNewPkce.Should().BeTrue(); + + fixture.Validator.VerifyNoOtherCalls(); + fixture.InternalFlow.VerifyNoOtherCalls(); + } + + [Fact] + public async Task TryCompleteAsync_WhenPkceValidationFails_RequestsNewPkce() + { + var fixture = CreateFixture(); + var (artifact, _) = TestPkceFactory.Create(); + + SetCompleteJson( + fixture, + artifact.AuthorizationCode.Value, + "wrong-verifier"); + + fixture.AuthStore + .Setup(x => x.GetAsync( + artifact.AuthorizationCode, + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(artifact); + + fixture.Validator + .Setup(x => x.Validate( + artifact, + "wrong-verifier", + It.IsAny(), + Now)) + .Returns(PkceValidationResult.Fail( + PkceValidationFailureReason.InvalidVerifier)); + + var result = await fixture.Sut.TryCompleteAsync( + fixture.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value!.Success.Should().BeFalse(); + ok.Value.RetryWithNewPkce.Should().BeTrue(); + + fixture.InternalFlow.VerifyNoOtherCalls(); + } + + [Fact] + public async Task TryCompleteAsync_WhenPkceIsValid_UsesArtifactContextAndPreviewLogin() + { + var fixture = CreateFixture(); + var (artifact, verifier) = TestPkceFactory.Create(); + + SetCompleteJson( + fixture, + artifact.AuthorizationCode.Value, + verifier, + identifier: "alice", + secret: "secret"); + + fixture.AuthStore + .Setup(x => x.GetAsync( + artifact.AuthorizationCode, + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(artifact); + + PkceContextSnapshot? capturedSnapshot = null; + + fixture.Validator + .Setup(x => x.Validate( + artifact, + verifier, + It.IsAny(), + Now)) + .Callback( + (_, _, snapshot, _) => + capturedSnapshot = snapshot) + .Returns(PkceValidationResult.Ok()); + + AuthExecutionContext? capturedExecution = null; + LoginRequest? capturedLogin = null; + LoginExecutionOptions? capturedOptions = null; + + fixture.InternalFlow + .Setup(x => x.LoginAsync( + fixture.Flow, + It.IsAny(), + It.IsAny(), + It.IsAny(), + fixture.HttpContext.RequestAborted)) + .Callback( + (_, execution, request, options, _) => + { + capturedExecution = execution; + capturedLogin = request; + capturedOptions = options; + }) + .ReturnsAsync(LoginResult.SuccessPreview()); + + var result = await fixture.Sut.TryCompleteAsync( + fixture.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value!.Success.Should().BeTrue(); + ok.Value.RetryWithNewPkce.Should().BeFalse(); + + capturedSnapshot.Should().NotBeNull(); + capturedSnapshot!.ClientProfile + .Should().Be(artifact.Context.ClientProfile); + capturedSnapshot.Tenant + .Should().Be(artifact.Context.Tenant); + capturedSnapshot.RedirectUri + .Should().Be(artifact.Context.RedirectUri); + capturedSnapshot.Device + .Should().BeEquivalentTo(artifact.Context.Device); + + capturedExecution.Should().NotBeNull(); + capturedExecution!.EffectiveClientProfile + .Should().Be(artifact.Context.ClientProfile); + capturedExecution.Device + .Should().BeEquivalentTo(artifact.Context.Device); + + capturedLogin.Should().NotBeNull(); + capturedLogin!.Identifier.Should().Be("alice"); + capturedLogin.Secret.Should().Be("secret"); + capturedLogin.RequestTokens + .Should().Be(fixture.Flow.AllowsTokenIssuance); + + capturedOptions.Should().NotBeNull(); + capturedOptions!.Mode.Should().Be(LoginExecutionMode.Preview); + capturedOptions.SuppressFailureAttempt.Should().BeFalse(); + capturedOptions.SuppressSuccessReset.Should().BeTrue(); + } + + [Fact] + public async Task TryCompleteAsync_WhenPreviewFails_ForwardsFailureInformation() + { + var fixture = CreateFixture(); + var (artifact, verifier) = TestPkceFactory.Create(); + + SetCompleteJson( + fixture, + artifact.AuthorizationCode.Value, + verifier); + + SetupValidPkce(fixture, artifact, verifier); + + var lockoutUntil = Now.AddMinutes(10); + + fixture.InternalFlow + .Setup(x => x.LoginAsync( + fixture.Flow, + It.IsAny(), + It.IsAny(), + It.IsAny(), + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(LoginResult.Failed( + AuthFailureReason.InvalidCredentials, + lockoutUntil, + remainingAttempts: 2)); + + var result = await fixture.Sut.TryCompleteAsync( + fixture.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value!.Success.Should().BeFalse(); + ok.Value.Reason.Should().Be(AuthFailureReason.InvalidCredentials); + ok.Value.RemainingAttempts.Should().Be(2); + ok.Value.LockoutUntilUtc.Should().Be(lockoutUntil); + ok.Value.RequiresMfa.Should().BeFalse(); + ok.Value.RetryWithNewPkce.Should().BeFalse(); + } + + [Fact] + public async Task TryCompleteAsync_WhenPreviewRequiresMfa_SetsRequiresMfa() + { + var fixture = CreateFixture(); + var (artifact, verifier) = TestPkceFactory.Create(); + + SetCompleteJson( + fixture, + artifact.AuthorizationCode.Value, + verifier); + + SetupValidPkce(fixture, artifact, verifier); + + fixture.InternalFlow + .Setup(x => x.LoginAsync( + fixture.Flow, + It.IsAny(), + It.IsAny(), + It.IsAny(), + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(LoginResult.Failed( + AuthFailureReason.RequiresMfa)); + + var result = await fixture.Sut.TryCompleteAsync( + fixture.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value!.Success.Should().BeFalse(); + ok.Value.RequiresMfa.Should().BeTrue(); + ok.Value.RetryWithNewPkce.Should().BeFalse(); + } + + // --------------------------------------------------------------------- + // Complete + // --------------------------------------------------------------------- + + [Fact] + public async Task CompleteAsync_WhenPayloadContentTypeIsInvalid_ReturnsBadRequest() + { + var fixture = CreateFixture(); + + var result = await fixture.Sut.CompleteAsync( + fixture.HttpContext); + + result.Should().BeOfType>(); + + fixture.PkceService.VerifyNoOtherCalls(); + } + + [Fact] + public async Task CompleteAsync_WhenRequiredPkceValuesAreMissing_ReturnsBadRequest() + { + var fixture = CreateFixture(); + + SetJsonBody(fixture.HttpContext, new + { + authorization_code = "", + code_verifier = "", + identifier = "alice", + secret = "secret" + }); + + var result = await fixture.Sut.CompleteAsync(fixture.HttpContext); + + result.Should().BeOfType>(); + + fixture.PkceService.VerifyNoOtherCalls(); + } + + [Fact] + public async Task CompleteAsync_WhenPkceIsInvalid_ReturnsUnauthorized() + { + var fixture = CreateFixture(); + + SetCompleteJson(fixture, "code", "verifier"); + + fixture.PkceService + .Setup(x => x.CompleteAsync( + fixture.Flow, + It.IsAny(), + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(new PkceCompleteResult + { + InvalidPkce = true + }); + + var result = await fixture.Sut.CompleteAsync( + fixture.HttpContext); + + result.Should().BeOfType(); + + fixture.CredentialWriter.VerifyNoOtherCalls(); + fixture.RedirectResolver.VerifyNoOtherCalls(); + } + + [Fact] + public async Task CompleteAsync_WhenLoginFailsWithoutHub_RedirectsToLogin() + { + var fixture = CreateFixture(); + + SetCompleteJson(fixture, "code", "verifier"); + + fixture.PkceService + .Setup(x => x.CompleteAsync( + fixture.Flow, + It.IsAny(), + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(new PkceCompleteResult + { + Success = false, + InvalidPkce = false + }); + + var result = await fixture.Sut.CompleteAsync( + fixture.HttpContext); + + var redirect = result.Should() + .BeOfType() + .Subject; + + redirect.Url.Should().Be( + fixture.Flow.OriginalOptions.Hub.LoginPath ?? "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/login"); + + fixture.CredentialWriter.VerifyNoOtherCalls(); + fixture.RedirectResolver.VerifyNoOtherCalls(); + } + + [Fact] + public async Task CompleteAsync_WhenLoginFailsWithHub_SetsHubErrorAndPreservesHubKey() + { + var fixture = CreateFixture(); + var (pkceArtifact, _) = TestPkceFactory.Create(); + var hub = TestHubFactory.Create(pkceArtifact); + var hubKey = hub.HubSessionId.Value; + + fixture.HttpContext.Request.QueryString = + new QueryString($"?uauth_hub={Uri.EscapeDataString(hubKey)}"); + + SetCompleteJson(fixture, "code", "verifier"); + + fixture.PkceService + .Setup(x => x.CompleteAsync( + fixture.Flow, + It.IsAny(), + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(new PkceCompleteResult + { + Success = false + }); + + fixture.AuthStore + .Setup(x => x.GetAsync( + new AuthArtifactKey(hubKey), + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(hub); + + fixture.AuthStore + .Setup(x => x.StoreAsync( + new AuthArtifactKey(hubKey), + hub, + It.IsAny())) + .Returns(Task.CompletedTask); + + var result = await fixture.Sut.CompleteAsync( + fixture.HttpContext); + + hub.Error.Should().Be(HubErrorCode.InvalidCredentials); + + var redirect = result.Should() + .BeOfType() + .Subject; + + redirect.Url.Should().Contain("hub="); + redirect.Url.Should().Contain( + Uri.EscapeDataString(hubKey)); + + fixture.AuthStore.Verify(x => x.StoreAsync( + new AuthArtifactKey(hubKey), + hub, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task CompleteAsync_WhenSuccessfulAndRedirectDisabled_ReturnsOk() + { + var fixture = CreateFixture(); + + SetCompleteJson(fixture, "code", "verifier"); + + var sessionId = TestIds.Session("test-session"); + + fixture.PkceService + .Setup(x => x.CompleteAsync( + fixture.Flow, + It.IsAny(), + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(new PkceCompleteResult + { + Success = true, + LoginResult = LoginResult.Success(sessionId) + }); + + fixture.CredentialWriter + .Setup(x => x.Write( + fixture.HttpContext, + GrantKind.Session, + sessionId)); + + fixture.RedirectResolver + .Setup(x => x.ResolveSuccess( + fixture.Flow, + fixture.HttpContext)) + .Returns(RedirectDecision.None()); + + var result = await fixture.Sut.CompleteAsync( + fixture.HttpContext); + + result.Should().BeOfType(); + + fixture.CredentialWriter.Verify(x => x.Write( + fixture.HttpContext, + GrantKind.Session, + sessionId), + Times.Once); + } + + [Fact] + public async Task CompleteAsync_WhenSuccessfulAndRedirectEnabled_ReturnsRedirect() + { + var fixture = CreateFixture(); + + SetCompleteJson(fixture, "code", "verifier"); + + var sessionId = TestIds.Session("test-session"); + + fixture.PkceService + .Setup(x => x.CompleteAsync( + fixture.Flow, + It.IsAny(), + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(new PkceCompleteResult + { + Success = true, + LoginResult = LoginResult.Success(sessionId) + }); + + fixture.CredentialWriter + .Setup(x => x.Write( + fixture.HttpContext, + GrantKind.Session, + sessionId)); + + fixture.RedirectResolver + .Setup(x => x.ResolveSuccess( + fixture.Flow, + fixture.HttpContext)) + .Returns(RedirectDecision.To("/app")); + + var result = await fixture.Sut.CompleteAsync( + fixture.HttpContext); + + var redirect = result.Should() + .BeOfType() + .Subject; + + redirect.Url.Should().Be("/app"); + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + private static Fixture CreateFixture( + AuthFlowContext? flow = null) + { + flow ??= AuthFlowTestFactory.LoginSuccess(); + + var authContext = + new Mock(MockBehavior.Strict); + + var publicFlow = + new Mock(MockBehavior.Strict); + + var pkceService = + new Mock(MockBehavior.Strict); + + var internalFlow = + new Mock(MockBehavior.Strict); + + var authStore = + new Mock(MockBehavior.Strict); + + var validator = + new Mock(MockBehavior.Strict); + + var clock = + new Mock(MockBehavior.Strict); + + var credentialWriter = + new Mock(MockBehavior.Strict); + + var redirectResolver = + new Mock(MockBehavior.Strict); + + authContext + .SetupGet(x => x.Current) + .Returns(flow); + + clock + .SetupGet(x => x.UtcNow) + .Returns(Now); + + var httpContext = new DefaultHttpContext(); + + var sut = new PkceEndpointHandler( + authContext.Object, + publicFlow.Object, + pkceService.Object, + internalFlow.Object, + authStore.Object, + validator.Object, + clock.Object, + credentialWriter.Object, + redirectResolver.Object); + + return new Fixture( + sut, + flow, + pkceService, + internalFlow, + authStore, + validator, + credentialWriter, + redirectResolver, + httpContext); + } + + private static void SetupValidPkce( + Fixture fixture, + PkceAuthorizationArtifact artifact, + string verifier) + { + fixture.AuthStore + .Setup(x => x.GetAsync( + artifact.AuthorizationCode, + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(artifact); + + fixture.Validator + .Setup(x => x.Validate( + artifact, + verifier, + It.IsAny(), + Now)) + .Returns(PkceValidationResult.Ok()); + } + + private static void SetCompleteJson( + Fixture fixture, + string authorizationCode, + string codeVerifier, + string identifier = "alice", + string secret = "secret") + { + SetJsonBody(fixture.HttpContext, new + { + authorization_code = authorizationCode, + code_verifier = codeVerifier, + identifier, + secret + }); + } + + private static void SetJsonBody( + HttpContext context, + object value) + { + var json = JsonSerializer.Serialize(value); + var bytes = Encoding.UTF8.GetBytes(json); + + context.Request.ContentType = "application/json"; + context.Request.ContentLength = bytes.Length; + context.Request.Body = new MemoryStream(bytes); + } + + private static AuthFlowContext CreateNonLoginFlow() + { + var source = AuthFlowTestFactory.LoginSuccess(); + + return new AuthFlowContext( + flowType: AuthFlowType.RefreshSession, + clientProfile: source.ClientProfile, + effectiveMode: source.EffectiveMode, + device: source.Device, + tenantKey: source.Tenant, + isAuthenticated: source.IsAuthenticated, + userKey: source.UserKey, + session: source.Session, + originalOptions: source.OriginalOptions, + effectiveOptions: source.EffectiveOptions, + response: source.Response, + primaryTokenKind: source.PrimaryTokenKind, + returnUrlInfo: source.ReturnUrlInfo); + } + + private sealed record Fixture( + PkceEndpointHandler Sut, + AuthFlowContext Flow, + Mock PkceService, + Mock InternalFlow, + Mock AuthStore, + Mock Validator, + Mock CredentialWriter, + Mock RedirectResolver, + DefaultHttpContext HttpContext); +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs new file mode 100644 index 00000000..7b63d957 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshFlowServiceTests.cs @@ -0,0 +1,518 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server; + +public sealed class RefreshFlowServiceTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 20, 12, 0, 0, TimeSpan.Zero); + private static readonly AuthSessionId SessionId = TestIds.Session("refresh-flow-session"); + private static readonly DeviceContext Device = TestDevice.Default(); + + [Fact] + public async Task RefreshAsync_PureOpaque_WhenSessionIdMissing_ReturnsReauthWithoutCallingDependencies() + { + var (sut, validator, touch, rotation) = CreateSut(); + + var result = await sut.RefreshAsync( + CreateFlow(UAuthMode.PureOpaque), + CreateRequest(sessionId: null)); + + AssertReauth(result); + validator.VerifyNoOtherCalls(); + touch.VerifyNoOtherCalls(); + rotation.VerifyNoOtherCalls(); + } + + [Fact] + public async Task RefreshAsync_PureOpaque_WhenSessionInvalid_ReturnsReauthWithoutTouching() + { + var (sut, validator, touch, rotation) = CreateSut(); + + validator + .Setup(x => x.ValidateSessionAsync( + It.Is(c => + c.Tenant == TenantKey.Single && + c.SessionId == SessionId && + c.Now == Now && + c.Device == Device), + It.IsAny())) + .ReturnsAsync(SessionValidationResult.Invalid(SessionState.Revoked)); + + var result = await sut.RefreshAsync( + CreateFlow(UAuthMode.PureOpaque), + CreateRequest(SessionId)); + + AssertReauth(result); + touch.VerifyNoOtherCalls(); + rotation.VerifyNoOtherCalls(); + } + + [Theory] + [InlineData(false, RefreshOutcome.NoOp)] + [InlineData(true, RefreshOutcome.Touched)] + public async Task RefreshAsync_PureOpaque_WhenSessionRefreshSucceeds_ReturnsExpectedOutcome( + bool didTouch, + RefreshOutcome expectedOutcome) + { + var (sut, validator, touch, rotation) = CreateSut(); + var validation = CreateActiveValidation(); + + validator + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(validation); + + touch + .Setup(x => x.RefreshAsync( + validation, + It.Is(p => p.TouchInterval == TimeSpan.FromMinutes(5)), + SessionTouchMode.IfNeeded, + Now, + It.IsAny())) + .ReturnsAsync(SessionRefreshResult.Success(SessionId, didTouch)); + + var result = await sut.RefreshAsync( + CreateFlow(UAuthMode.PureOpaque), + CreateRequest(SessionId)); + + result.Succeeded.Should().BeTrue(); + result.Outcome.Should().Be(expectedOutcome); + result.SessionId.Should().Be(SessionId); + rotation.VerifyNoOtherCalls(); + } + + [Fact] + public async Task RefreshAsync_PureOpaque_WhenTouchFails_ReturnsReauth() + { + var (sut, validator, touch, rotation) = CreateSut(); + var validation = CreateActiveValidation(); + + validator + .Setup(x => x.ValidateSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(validation); + + touch + .Setup(x => x.RefreshAsync( + validation, + It.IsAny(), + It.IsAny(), + Now, + It.IsAny())) + .ReturnsAsync(SessionRefreshResult.Failed()); + + var result = await sut.RefreshAsync( + CreateFlow(UAuthMode.PureOpaque), + CreateRequest(SessionId)); + + AssertReauth(result); + rotation.VerifyNoOtherCalls(); + } + + [Fact] + public async Task RefreshAsync_PureJwt_WhenRefreshTokenMissing_ReturnsReauthWithoutCallingDependencies() + { + var (sut, validator, touch, rotation) = CreateSut(); + + var result = await sut.RefreshAsync( + CreateFlow(UAuthMode.PureJwt), + CreateRequest(refreshToken: " ")); + + AssertReauth(result); + validator.VerifyNoOtherCalls(); + touch.VerifyNoOtherCalls(); + rotation.VerifyNoOtherCalls(); + } + + [Fact] + public async Task RefreshAsync_PureJwt_WhenRotationSucceeds_ReturnsRotatedTokensWithoutSessionValidation() + { + var (sut, validator, touch, rotation) = CreateSut(); + var execution = CreateSuccessfulRotation(); + + rotation + .Setup(x => x.RotateAsync( + It.IsAny(), + It.Is(c => + c.RefreshToken == "refresh-token" && + c.Now == Now && + c.Device == Device && + c.ExpectedSessionId == null), + It.IsAny())) + .ReturnsAsync(execution); + + var result = await sut.RefreshAsync( + CreateFlow(UAuthMode.PureJwt), + CreateRequest(refreshToken: "refresh-token")); + + result.Succeeded.Should().BeTrue(); + result.Outcome.Should().Be(RefreshOutcome.Rotated); + result.SessionId.Should().BeNull(); + result.AccessToken.Should().BeSameAs(execution.Result.AccessToken); + result.RefreshToken.Should().BeSameAs(execution.Result.RefreshToken); + validator.VerifyNoOtherCalls(); + touch.VerifyNoOtherCalls(); + } + + [Fact] + public async Task RefreshAsync_PureJwt_WhenRotationFails_ReturnsReauth() + { + var (sut, validator, touch, rotation) = CreateSut(); + + rotation + .Setup(x => x.RotateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new RefreshTokenRotationExecution + { + Result = RefreshTokenRotationResult.Failed() + }); + + var result = await sut.RefreshAsync( + CreateFlow(UAuthMode.PureJwt), + CreateRequest(refreshToken: "refresh-token")); + + AssertReauth(result); + validator.VerifyNoOtherCalls(); + touch.VerifyNoOtherCalls(); + } + + [Theory] + [InlineData(null, "refresh-token")] + [InlineData("session", null)] + [InlineData("session", " ")] + public async Task RefreshAsync_Hybrid_WhenRequiredCredentialMissing_ReturnsReauthWithoutCallingDependencies( + string? sessionMarker, + string? refreshToken) + { + var (sut, validator, touch, rotation) = CreateSut(); + + var result = await sut.RefreshAsync( + CreateFlow(UAuthMode.Hybrid), + CreateRequest(sessionMarker is null ? null : SessionId, refreshToken)); + + AssertReauth(result); + validator.VerifyNoOtherCalls(); + touch.VerifyNoOtherCalls(); + rotation.VerifyNoOtherCalls(); + } + + [Fact] + public async Task RefreshAsync_Hybrid_WhenSessionInvalid_ReturnsReauthWithoutRotationOrTouch() + { + var (sut, validator, touch, rotation) = CreateSut(); + + validator + .Setup(x => x.ValidateSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SessionValidationResult.Invalid(SessionState.DeviceMismatch)); + + var result = await sut.RefreshAsync( + CreateFlow(UAuthMode.Hybrid), + CreateRequest(SessionId, "refresh-token")); + + AssertReauth(result); + rotation.VerifyNoOtherCalls(); + touch.VerifyNoOtherCalls(); + } + + [Fact] + public async Task RefreshAsync_Hybrid_WhenRotationFails_ReturnsReauthWithoutTouch() + { + var (sut, validator, touch, rotation) = CreateSut(); + var validation = CreateActiveValidation(); + + validator + .Setup(x => x.ValidateSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(validation); + + rotation + .Setup(x => x.RotateAsync( + It.IsAny(), + It.Is(c => + c.ExpectedSessionId == SessionId && + c.RefreshToken == "refresh-token" && + c.Now == Now), + It.IsAny())) + .ReturnsAsync(new RefreshTokenRotationExecution + { + Result = RefreshTokenRotationResult.Failed() + }); + + var result = await sut.RefreshAsync( + CreateFlow(UAuthMode.Hybrid), + CreateRequest(SessionId, "refresh-token")); + + AssertReauth(result); + touch.VerifyNoOtherCalls(); + } + + [Fact] + public async Task RefreshAsync_Hybrid_WhenAllStepsSucceed_ReturnsRotatedResultAndTouchesSession() + { + var (sut, validator, touch, rotation) = CreateSut(); + var validation = CreateActiveValidation(); + var execution = CreateSuccessfulRotation(); + + validator + .Setup(x => x.ValidateSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(validation); + + rotation + .Setup(x => x.RotateAsync( + It.IsAny(), + It.Is(c => c.ExpectedSessionId == SessionId), + It.IsAny())) + .ReturnsAsync(execution); + + touch + .Setup(x => x.RefreshAsync( + validation, + It.Is(p => p.TouchInterval == TimeSpan.FromMinutes(5)), + SessionTouchMode.IfNeeded, + Now, + It.IsAny())) + .ReturnsAsync(SessionRefreshResult.Success(SessionId, didTouch: true)); + + var result = await sut.RefreshAsync( + CreateFlow(UAuthMode.Hybrid), + CreateRequest(SessionId, "refresh-token")); + + result.Succeeded.Should().BeTrue(); + result.Outcome.Should().Be(RefreshOutcome.Rotated); + result.SessionId.Should().Be(SessionId); + result.AccessToken.Should().BeSameAs(execution.Result.AccessToken); + result.RefreshToken.Should().BeSameAs(execution.Result.RefreshToken); + } + + [Fact] + public async Task RefreshAsync_Hybrid_WhenTouchFailsAfterRotation_ReturnsReauth() + { + var (sut, validator, touch, rotation) = CreateSut(); + var validation = CreateActiveValidation(); + + validator + .Setup(x => x.ValidateSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(validation); + + rotation + .Setup(x => x.RotateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(CreateSuccessfulRotation()); + + touch + .Setup(x => x.RefreshAsync( + validation, + It.IsAny(), + It.IsAny(), + Now, + It.IsAny())) + .ReturnsAsync(SessionRefreshResult.Failed()); + + var result = await sut.RefreshAsync( + CreateFlow(UAuthMode.Hybrid), + CreateRequest(SessionId, "refresh-token")); + + AssertReauth(result); + rotation.Verify(x => x.RotateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task RefreshAsync_SemiHybrid_WhenSessionInvalid_ReturnsReauthWithoutRotation() + { + var (sut, validator, touch, rotation) = CreateSut(); + + validator + .Setup(x => x.ValidateSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SessionValidationResult.Invalid(SessionState.SecurityMismatch)); + + var result = await sut.RefreshAsync( + CreateFlow(UAuthMode.SemiHybrid), + CreateRequest(SessionId, "refresh-token")); + + AssertReauth(result); + rotation.VerifyNoOtherCalls(); + touch.VerifyNoOtherCalls(); + } + + [Fact] + public async Task RefreshAsync_SemiHybrid_WhenRotationSucceeds_ReturnsRotatedWithoutTouchingSession() + { + var (sut, validator, touch, rotation) = CreateSut(); + var validation = CreateActiveValidation(); + var execution = CreateSuccessfulRotation(); + + validator + .Setup(x => x.ValidateSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(validation); + + rotation + .Setup(x => x.RotateAsync( + It.IsAny(), + It.Is(c => + c.ExpectedSessionId == SessionId && + c.RefreshToken == "refresh-token"), + It.IsAny())) + .ReturnsAsync(execution); + + var result = await sut.RefreshAsync( + CreateFlow(UAuthMode.SemiHybrid), + CreateRequest(SessionId, "refresh-token")); + + result.Succeeded.Should().BeTrue(); + result.Outcome.Should().Be(RefreshOutcome.Rotated); + result.SessionId.Should().Be(SessionId); + result.AccessToken.Should().BeSameAs(execution.Result.AccessToken); + result.RefreshToken.Should().BeSameAs(execution.Result.RefreshToken); + touch.VerifyNoOtherCalls(); + } + + [Fact] + public async Task RefreshAsync_SemiHybrid_WhenRotationFails_ReturnsReauthWithoutTouch() + { + var (sut, validator, touch, rotation) = CreateSut(); + var validation = CreateActiveValidation(); + + validator + .Setup(x => x.ValidateSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(validation); + + rotation + .Setup(x => x.RotateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new RefreshTokenRotationExecution + { + Result = RefreshTokenRotationResult.Failed() + }); + + var result = await sut.RefreshAsync( + CreateFlow(UAuthMode.SemiHybrid), + CreateRequest(SessionId, "refresh-token")); + + AssertReauth(result); + touch.VerifyNoOtherCalls(); + } + + private static ( + RefreshFlowService Sut, + Mock Validator, + Mock Touch, + Mock Rotation) CreateSut() + { + var validator = new Mock(MockBehavior.Strict); + var touch = new Mock(MockBehavior.Strict); + var rotation = new Mock(MockBehavior.Strict); + + var sut = new RefreshFlowService( + validator.Object, + touch.Object, + rotation.Object, + new TestClock(Now)); + + return (sut, validator, touch, rotation); + } + + private static RefreshFlowRequest CreateRequest( + AuthSessionId? sessionId = null, + string? refreshToken = null) + => new() + { + SessionId = sessionId, + RefreshToken = refreshToken, + Device = Device, + TouchMode = SessionTouchMode.IfNeeded + }; + + private static SessionValidationResult CreateActiveValidation() + => SessionValidationResult.Active( + TenantKey.Single, + UserKey.New(), + SessionId, + SessionChainId.New(), + SessionRootId.New(), + ClaimsSnapshot.Empty, + Now.AddHours(-1), + Device.DeviceId); + + private static RefreshTokenRotationExecution CreateSuccessfulRotation() + { + var access = new AccessToken + { + Token = "new-access-token", + Format = TokenFormat.Jwt, + ExpiresAt = Now.AddMinutes(15) + }; + + var refresh = new RefreshTokenInfo + { + Token = "new-refresh-token", + TokenHash = "new-refresh-token-hash", + ExpiresAt = Now.AddDays(7) + }; + + return new RefreshTokenRotationExecution + { + Tenant = TenantKey.Single, + UserKey = UserKey.New(), + SessionId = SessionId, + ChainId = SessionChainId.New(), + Result = RefreshTokenRotationResult.Success(access, refresh) + }; + } + + private static AuthFlowContext CreateFlow(UAuthMode mode) + { + var original = TestServerOptions.Default(); + var effective = TestServerOptions.Effective(mode); + effective.Options.Session.TouchInterval = TimeSpan.FromMinutes(5); + + return new AuthFlowContext( + flowType: AuthFlowType.RefreshSession, + clientProfile: UAuthClientProfile.Api, + effectiveMode: mode, + device: Device, + tenantKey: TenantKey.Single, + isAuthenticated: true, + userKey: UserKey.New(), + session: null, + originalOptions: original, + effectiveOptions: effective, + response: new EffectiveAuthResponse( + sessionIdDelivery: CredentialResponseOptions.Disabled(GrantKind.Session), + accessTokenDelivery: CredentialResponseOptions.Disabled(GrantKind.AccessToken), + refreshTokenDelivery: CredentialResponseOptions.Disabled(GrantKind.RefreshToken), + redirect: EffectiveRedirectResponse.Disabled), + primaryTokenKind: PrimaryTokenKind.AccessToken, + returnUrlInfo: ReturnUrlInfo.None()); + } + + private static void AssertReauth(RefreshFlowResult result) + { + result.Succeeded.Should().BeFalse(); + result.Outcome.Should().Be(RefreshOutcome.ReauthRequired); + result.SessionId.Should().BeNull(); + result.AccessToken.Should().BeNull(); + result.RefreshToken.Should().BeNull(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshResponseWriterTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshResponseWriterTests.cs new file mode 100644 index 00000000..ef647dbb --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshResponseWriterTests.cs @@ -0,0 +1,72 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class RefreshResponseWriterTests +{ + [Theory] + [InlineData(RefreshOutcome.NoOp, "no-op")] + [InlineData(RefreshOutcome.Touched, "touched")] + [InlineData(RefreshOutcome.Rotated, "rotated")] + [InlineData(RefreshOutcome.ReauthRequired, "reauth-required")] + [InlineData(RefreshOutcome.Success, "success")] + public void Write_WhenRefreshDetailsEnabled_WritesExpectedOutcome(RefreshOutcome outcome, string expected) + { + var options = Options.Create(new UAuthServerOptions + { + Diagnostics = { EnableRefreshDetails = true } + }); + + var writer = new RefreshResponseWriter(options); + var context = new DefaultHttpContext(); + + writer.Write(context, outcome); + + Assert.Equal( + expected, + context.Response.Headers[UAuthConstants.Headers.Refresh]); + } + + [Fact] + public void Write_WhenRefreshDetailsDisabled_DoesNotWriteHeader() + { + var options = Options.Create(new UAuthServerOptions + { + Diagnostics = + { + EnableRefreshDetails = false + } + }); + + var writer = new RefreshResponseWriter(options); + var context = new DefaultHttpContext(); + + writer.Write(context, RefreshOutcome.Rotated); + + Assert.False(context.Response.Headers.ContainsKey(UAuthConstants.Headers.Refresh)); + } + + [Fact] + public void Write_WhenOutcomeIsUnknown_WritesUnknown() + { + var options = Options.Create(new UAuthServerOptions + { + Diagnostics = + { + EnableRefreshDetails = true + } + }); + + var writer = new RefreshResponseWriter(options); + var context = new DefaultHttpContext(); + + writer.Write(context, (RefreshOutcome)int.MaxValue); + + Assert.Equal("unknown", context.Response.Headers[UAuthConstants.Headers.Refresh]); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenResolverTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenResolverTests.cs new file mode 100644 index 00000000..01fd279b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenResolverTests.cs @@ -0,0 +1,133 @@ +ο»Ώusing CodeBeam.UltimateAuth.Server.Flows; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class RefreshTokenResolverTests +{ + private readonly RefreshTokenResolver _sut = new(); + + [Fact] + public void Resolve_WhenCookieContainsToken_ReturnsCookieToken() + { + var context = CreateContext(); + context.Request.Headers.Cookie = "uar=cookie-token"; + + var result = _sut.Resolve(context); + + Assert.Equal("cookie-token", result); + } + + [Fact] + public void Resolve_WhenAuthorizationContainsBearerToken_ReturnsBearerToken() + { + var context = CreateContext(); + context.Request.Headers.Authorization = "Bearer bearer-token"; + + var result = _sut.Resolve(context); + + Assert.Equal("bearer-token", result); + } + + [Fact] + public void Resolve_WhenBearerSchemeUsesDifferentCasing_ReturnsBearerToken() + { + var context = CreateContext(); + context.Request.Headers.Authorization = "bEaReR bearer-token"; + + var result = _sut.Resolve(context); + + Assert.Equal("bearer-token", result); + } + + [Fact] + public void Resolve_WhenRefreshHeaderContainsToken_ReturnsHeaderToken() + { + var context = CreateContext(); + context.Request.Headers["X-Refresh-Token"] = "header-token"; + + var result = _sut.Resolve(context); + + Assert.Equal("header-token", result); + } + + [Fact] + public void Resolve_WhenNoTokenExists_ReturnsNull() + { + var context = CreateContext(); + + var result = _sut.Resolve(context); + + Assert.Null(result); + } + + [Fact] + public void Resolve_WhenCookieAndBearerExist_PrefersCookie() + { + var context = CreateContext(); + context.Request.Headers.Cookie = "uar=cookie-token"; + context.Request.Headers.Authorization = "Bearer bearer-token"; + + var result = _sut.Resolve(context); + + Assert.Equal("cookie-token", result); + } + + [Fact] + public void Resolve_WhenBearerAndRefreshHeaderExist_PrefersBearer() + { + var context = CreateContext(); + context.Request.Headers.Authorization = "Bearer bearer-token"; + context.Request.Headers["X-Refresh-Token"] = "header-token"; + + var result = _sut.Resolve(context); + + Assert.Equal("bearer-token", result); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("Basic abc")] + [InlineData("Token abc")] + [InlineData("Bearer")] + [InlineData("Bearer ")] + [InlineData("Bearer ")] + public void Resolve_WhenAuthorizationDoesNotContainValidBearerToken_ReturnsNull( + string authorization) + { + var context = CreateContext(); + context.Request.Headers.Authorization = authorization; + + var result = _sut.Resolve(context); + + Assert.Null(result); + } + + [Fact] + public void Resolve_WhenBearerTokenIsEmpty_FallsBackToRefreshHeader() + { + var context = CreateContext(); + context.Request.Headers.Authorization = "Bearer "; + context.Request.Headers["X-Refresh-Token"] = "header-token"; + + var result = _sut.Resolve(context); + + Assert.Equal("header-token", result); + } + + [Fact] + public void Resolve_WhenCookieIsEmpty_FallsBackToBearerToken() + { + var context = CreateContext(); + context.Request.Headers.Cookie = "uar="; + context.Request.Headers.Authorization = "Bearer bearer-token"; + + var result = _sut.Resolve(context); + + Assert.Equal("bearer-token", result); + } + + private static DefaultHttpContext CreateContext() + => new(); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs new file mode 100644 index 00000000..688b979e --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RefreshTokenRotationServiceTests.cs @@ -0,0 +1,424 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Abstactions; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server; + +public sealed class RefreshTokenRotationServiceTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 20, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task RotateAsync_WhenValidationIsInvalid_ReturnsFailedWithoutCreatingStoreOrIssuingTokens() + { + var validator = new Mock(); + var storeFactory = new Mock(); + var issuer = new Mock(); + var clock = new TestClock(Now); + + validator + .Setup(x => x.ValidateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(RefreshTokenValidationResult.Invalid()); + + var sut = new RefreshTokenRotationService(validator.Object, storeFactory.Object, issuer.Object); + + var result = await sut.RotateAsync(CreateFlow(), CreateContext()); + + result.Result.IsSuccess.Should().BeFalse(); + result.Result.ReauthRequired.Should().BeTrue(); + + storeFactory.Verify(x => x.Create(It.IsAny()), Times.Never); + issuer.Verify(x => x.IssueAccessTokenAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + issuer.Verify(x => x.IssueRefreshTokenAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task RotateAsync_WhenReuseIsDetectedForChain_RevokesChainAndReturnsFailed() + { + var tenant = TenantKey.FromExternal("tenant-a"); + var sessionId = TestIds.Session("session-reuse-chain"); + var chainId = SessionChainId.New(); + + var validator = new Mock(); + var storeFactory = new Mock(); + var store = new Mock(); + var issuer = new Mock(); + + validator + .Setup(x => x.ValidateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(RefreshTokenValidationResult.ReuseDetected( + tenant, + sessionId: sessionId, + tokenHash: "old-hash", + chainId: chainId, + userKey: UserKey.New())); + + storeFactory.Setup(x => x.Create(tenant)).Returns(store.Object); + + var sut = new RefreshTokenRotationService( + validator.Object, + storeFactory.Object, + issuer.Object); + + var result = await sut.RotateAsync(CreateFlow(tenant, multiTenant: true), CreateContext(sessionId)); + + result.Result.IsSuccess.Should().BeFalse(); + result.Result.ReauthRequired.Should().BeTrue(); + + store.Verify(x => x.RevokeByChainAsync(chainId, Now, It.IsAny()), Times.Once); + store.Verify(x => x.RevokeBySessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task RotateAsync_WhenReuseIsDetectedWithoutChain_RevokesSessionAndReturnsFailed() + { + var tenant = TenantKey.FromExternal("tenant-a"); + var sessionId = TestIds.Session("session-reuse-session"); + + var validator = new Mock(); + var storeFactory = new Mock(); + var store = new Mock(); + + validator + .Setup(x => x.ValidateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(RefreshTokenValidationResult.ReuseDetected( + tenant, + sessionId: sessionId, + tokenHash: "old-hash", + userKey: UserKey.New())); + + storeFactory.Setup(x => x.Create(tenant)).Returns(store.Object); + + var sut = new RefreshTokenRotationService( + validator.Object, + storeFactory.Object, + Mock.Of()); + + var result = await sut.RotateAsync(CreateFlow(tenant, multiTenant: true), CreateContext(sessionId)); + + result.Result.IsSuccess.Should().BeFalse(); + + store.Verify(x => x.RevokeBySessionAsync(sessionId, Now, It.IsAny()), Times.Once); + store.Verify(x => x.RevokeByChainAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task RotateAsync_WhenValidatedTokenHashIsMissing_ThrowsValidationException() + { + var tenant = TenantKey.FromExternal("tenant-a"); + var validation = RefreshTokenValidationResult.Valid( + tenant, + UserKey.New(), + TestIds.Session("session-missing-hash"), + tokenHash: null, + chainId: SessionChainId.New()); + + var validator = new Mock(); + var storeFactory = new Mock(); + + validator + .Setup(x => x.ValidateAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(validation); + + storeFactory + .Setup(x => x.Create(tenant)) + .Returns(Mock.Of()); + + var sut = new RefreshTokenRotationService( + validator.Object, + storeFactory.Object, + Mock.Of()); + + var act = () => sut.RotateAsync( + CreateFlow(tenant, multiTenant: true), + CreateContext(validation.SessionId)); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task RotateAsync_WhenRefreshTokenCannotBeIssued_ReturnsFailedWithoutRevokingOldToken() + { + var tenant = TenantKey.FromExternal("tenant-a"); + var validation = CreateValidValidation(tenant); + var validator = CreateValidator(validation); + var store = new Mock(); + var storeFactory = CreateStoreFactory(tenant, store); + var issuer = new Mock(); + + issuer + .Setup(x => x.IssueAccessTokenAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(CreateAccessToken()); + + issuer + .Setup(x => x.IssueRefreshTokenAsync( + It.IsAny(), + It.IsAny(), + RefreshTokenPersistence.DoNotPersist, + It.IsAny())) + .ReturnsAsync((RefreshTokenInfo?)null); + + var sut = new RefreshTokenRotationService(validator.Object, storeFactory.Object, issuer.Object); + + var result = await sut.RotateAsync(CreateFlow(tenant, multiTenant: true), CreateContext(validation.SessionId)); + + result.Result.IsSuccess.Should().BeFalse(); + + store.Verify(x => x.ExecuteAsync( + It.IsAny>(), + It.IsAny()), Times.Never); + store.Verify(x => x.RevokeAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + store.Verify(x => x.StoreAsync( + It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task RotateAsync_WhenValid_RotatesAndStoresReplacementInSingleStoreExecution() + { + var tenant = TenantKey.FromExternal("tenant-a"); + var validation = CreateValidValidation(tenant); + var validator = CreateValidator(validation); + var store = new Mock(); + var storeFactory = CreateStoreFactory(tenant, store); + var issuer = new Mock(); + var accessToken = CreateAccessToken(); + var refreshToken = CreateRefreshTokenInfo(); + + issuer + .Setup(x => x.IssueAccessTokenAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(accessToken); + + issuer + .Setup(x => x.IssueRefreshTokenAsync( + It.IsAny(), + It.IsAny(), + RefreshTokenPersistence.DoNotPersist, + It.IsAny())) + .ReturnsAsync(refreshToken); + + store + .Setup(x => x.ExecuteAsync( + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>((action, ct) => action(ct)); + + var sut = new RefreshTokenRotationService(validator.Object, storeFactory.Object, issuer.Object); + + var result = await sut.RotateAsync(CreateFlow(tenant, multiTenant: true), CreateContext(validation.SessionId)); + + result.Result.IsSuccess.Should().BeTrue(); + result.Result.AccessToken.Should().BeSameAs(accessToken); + result.Result.RefreshToken.Should().BeSameAs(refreshToken); + result.Tenant.Should().Be(tenant); + result.UserKey.Should().Be(validation.UserKey); + result.SessionId.Should().Be(validation.SessionId); + result.ChainId.Should().Be(validation.ChainId); + + store.Verify(x => x.RevokeAsync( + validation.TokenHash!, + Now, + refreshToken.TokenHash, + It.IsAny()), Times.Once); + + store.Verify(x => x.StoreAsync( + It.Is(token => + token.TokenHash == refreshToken.TokenHash && + token.Tenant == tenant && + token.UserKey == validation.UserKey!.Value && + token.SessionId == validation.SessionId!.Value && + token.ChainId == validation.ChainId && + token.CreatedAt == Now && + token.ExpiresAt == refreshToken.ExpiresAt), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task RotateAsync_WhenMultiTenantEnabled_UsesValidatedTenantForTokenIssuance() + { + var tenant = TenantKey.FromExternal("tenant-a"); + var validation = CreateValidValidation(tenant); + var issuer = new Mock(); + TokenIssuanceContext? captured = null; + + SetupSuccessfulIssuer(issuer, ctx => captured = ctx); + + var store = CreateExecutableStore(); + var sut = new RefreshTokenRotationService( + CreateValidator(validation).Object, + CreateStoreFactory(tenant, store).Object, + issuer.Object); + + await sut.RotateAsync(CreateFlow(tenant, multiTenant: true), CreateContext(validation.SessionId)); + + captured.Should().NotBeNull(); + captured!.Tenant.Should().Be(tenant); + } + + [Fact] + public async Task RotateAsync_WhenMultiTenantDisabled_UsesSingleTenantForTokenIssuance() + { + var validation = CreateValidValidation(TenantKey.Single); + var issuer = new Mock(); + TokenIssuanceContext? captured = null; + + SetupSuccessfulIssuer(issuer, ctx => captured = ctx); + + var store = CreateExecutableStore(); + var sut = new RefreshTokenRotationService( + CreateValidator(validation).Object, + CreateStoreFactory(TenantKey.Single, store).Object, + issuer.Object); + + await sut.RotateAsync(CreateFlow(TenantKey.Single, multiTenant: false), CreateContext(validation.SessionId)); + + captured.Should().NotBeNull(); + captured!.Tenant.Should().Be(TenantKey.Single); + } + + private static Mock CreateValidator(RefreshTokenValidationResult result) + { + var validator = new Mock(); + validator + .Setup(x => x.ValidateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(result); + return validator; + } + + private static Mock CreateStoreFactory( + TenantKey tenant, + Mock store) + { + var factory = new Mock(); + factory.Setup(x => x.Create(tenant)).Returns(store.Object); + return factory; + } + + private static Mock CreateExecutableStore() + { + var store = new Mock(); + store + .Setup(x => x.ExecuteAsync( + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>((action, ct) => action(ct)); + return store; + } + + private static RefreshTokenValidationResult CreateValidValidation(TenantKey tenant) + => RefreshTokenValidationResult.Valid( + tenant, + UserKey.New(), + TestIds.Session("rotation-session"), + "old-refresh-token-hash", + SessionChainId.New()); + + private static RefreshTokenRotationContext CreateContext(AuthSessionId? expectedSessionId = null) + => new() + { + RefreshToken = "old-refresh-token", + Now = Now, + Device = TestDevice.Default(), + ExpectedSessionId = expectedSessionId + }; + + private static AccessToken CreateAccessToken() + => new() + { + Token = "access-token", + Format = TokenFormat.Jwt, + ExpiresAt = Now.AddMinutes(15) + }; + + private static RefreshTokenInfo CreateRefreshTokenInfo() + => new() + { + Token = "new-refresh-token", + TokenHash = "new-refresh-token-hash", + ExpiresAt = Now.AddDays(7) + }; + + private static void SetupSuccessfulIssuer( + Mock issuer, + Action? capture = null) + { + issuer + .Setup(x => x.IssueAccessTokenAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((_, ctx, _) => capture?.Invoke(ctx)) + .ReturnsAsync(CreateAccessToken()); + + issuer + .Setup(x => x.IssueRefreshTokenAsync( + It.IsAny(), + It.IsAny(), + RefreshTokenPersistence.DoNotPersist, + It.IsAny())) + .ReturnsAsync(CreateRefreshTokenInfo()); + } + + private static AuthFlowContext CreateFlow(TenantKey? tenant = null, bool multiTenant = false) + { + var resolvedTenant = tenant ?? TenantKey.Single; + var options = TestServerOptions.Default(); + options.MultiTenant.Enabled = multiTenant; + + return new AuthFlowContext( + flowType: AuthFlowType.RefreshSession, + clientProfile: UAuthClientProfile.Api, + effectiveMode: UAuthMode.Hybrid, + device: TestDevice.Default(), + tenantKey: resolvedTenant, + isAuthenticated: true, + userKey: UserKey.New(), + session: null, + originalOptions: options, + effectiveOptions: TestServerOptions.Effective(UAuthMode.Hybrid), + response: new EffectiveAuthResponse( + sessionIdDelivery: CredentialResponseOptions.Disabled(GrantKind.Session), + accessTokenDelivery: CredentialResponseOptions.Disabled(GrantKind.AccessToken), + refreshTokenDelivery: CredentialResponseOptions.Disabled(GrantKind.RefreshToken), + redirect: EffectiveRedirectResponse.Disabled), + primaryTokenKind: PrimaryTokenKind.AccessToken, + returnUrlInfo: ReturnUrlInfo.None()); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs new file mode 100644 index 00000000..e5b926f7 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs @@ -0,0 +1,853 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Sessions.InMemory; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server; + +public sealed class SessionApplicationServiceTests +{ + private static readonly DateTimeOffset Now = + new(2026, 9, 20, 12, 0, 0, TimeSpan.Zero); + + // --------------------------------------------------------------------- + // GetUserChainsAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task GetUserChainsAsync_ReturnsRequestedPage() + { + var (sut, store) = CreateSut(); + var user = UserKey.New(); + + var root = await CreateRootAsync(store, user); + + await store.CreateChainAsync( + CreateChain(root, Now.AddMinutes(-30))); + + await store.CreateChainAsync( + CreateChain(root, Now.AddMinutes(-20))); + + await store.CreateChainAsync( + CreateChain(root, Now.AddMinutes(-10))); + + var result = await sut.GetUserChainsAsync( + Context(user), + user, + new PageRequest + { + PageNumber = 2, + PageSize = 2 + }); + + result.TotalCount.Should().Be(3); + result.PageNumber.Should().Be(2); + result.PageSize.Should().Be(2); + result.Items.Should().HaveCount(1); + } + + [Fact] + public async Task GetUserChainsAsync_MarksActorChainAsCurrentDevice() + { + var (sut, store) = CreateSut(); + var user = UserKey.New(); + + var root = await CreateRootAsync(store, user); + + var actorChain = CreateChain( + root, + Now.AddMinutes(-10)); + + var otherChain = CreateChain( + root, + Now.AddMinutes(-5)); + + await store.CreateChainAsync(actorChain); + await store.CreateChainAsync(otherChain); + + var result = await sut.GetUserChainsAsync( + Context(user, actorChain.ChainId), + user, + new PageRequest + { + PageNumber = 1, + PageSize = 10 + }); + + result.Items.Should().HaveCount(2); + + var actor = result.Items + .Should() + .ContainSingle(x => x.ChainId == actorChain.ChainId) + .Subject; + + actor.IsCurrentDevice.Should().BeTrue(); + + var other = result.Items + .Should() + .ContainSingle(x => x.ChainId == otherChain.ChainId) + .Subject; + + other.IsCurrentDevice.Should().BeFalse(); + } + + [Fact] + public async Task GetUserChainsAsync_SortByChainIdAscending_SortsByChainId() + { + var (sut, store) = CreateSut(); + var user = UserKey.New(); + + var root = await CreateRootAsync(store, user); + + var first = CreateChain(root, Now.AddMinutes(-30)); + var second = CreateChain(root, Now.AddMinutes(-20)); + var third = CreateChain(root, Now.AddMinutes(-10)); + + await store.CreateChainAsync(first); + await store.CreateChainAsync(second); + await store.CreateChainAsync(third); + + var result = await sut.GetUserChainsAsync( + Context(user), + user, + new PageRequest + { + PageNumber = 1, + PageSize = 10, + SortBy = nameof(SessionChainSummary.ChainId), + Descending = false + }); + + result.Items + .Select(x => x.ChainId.Value) + .Should() + .BeInAscendingOrder(); + } + + [Fact] + public async Task GetUserChainsAsync_SortByChainIdDescending_SortsByChainId() + { + var (sut, store) = CreateSut(); + var user = UserKey.New(); + + var root = await CreateRootAsync(store, user); + + await store.CreateChainAsync( + CreateChain(root, Now.AddMinutes(-30))); + + await store.CreateChainAsync( + CreateChain(root, Now.AddMinutes(-20))); + + await store.CreateChainAsync( + CreateChain(root, Now.AddMinutes(-10))); + + var result = await sut.GetUserChainsAsync( + Context(user), + user, + new PageRequest + { + PageNumber = 1, + PageSize = 10, + SortBy = nameof(SessionChainSummary.ChainId), + Descending = true + }); + + result.Items + .Select(x => x.ChainId.Value) + .Should() + .BeInDescendingOrder(); + } + + [Fact] + public async Task GetUserChainsAsync_SortByCreatedAtAscending_SortsByCreatedAt() + { + var (sut, store) = CreateSut(); + var user = UserKey.New(); + + var root = await CreateRootAsync(store, user); + + // Deliberately insert in non-chronological order. + await store.CreateChainAsync( + CreateChain(root, Now.AddMinutes(-5))); + + await store.CreateChainAsync( + CreateChain(root, Now.AddMinutes(-30))); + + await store.CreateChainAsync( + CreateChain(root, Now.AddMinutes(-15))); + + var result = await sut.GetUserChainsAsync( + Context(user), + user, + new PageRequest + { + PageNumber = 1, + PageSize = 10, + SortBy = nameof(SessionChainSummary.CreatedAt), + Descending = false + }); + + result.Items + .Select(x => x.CreatedAt) + .Should() + .BeInAscendingOrder(); + } + + [Fact] + public async Task GetUserChainsAsync_SortByCreatedAtDescending_SortsByCreatedAt() + { + var (sut, store) = CreateSut(); + var user = UserKey.New(); + + var root = await CreateRootAsync(store, user); + + await store.CreateChainAsync( + CreateChain(root, Now.AddMinutes(-5))); + + await store.CreateChainAsync( + CreateChain(root, Now.AddMinutes(-30))); + + await store.CreateChainAsync( + CreateChain(root, Now.AddMinutes(-15))); + + var result = await sut.GetUserChainsAsync( + Context(user), + user, + new PageRequest + { + PageNumber = 1, + PageSize = 10, + SortBy = nameof(SessionChainSummary.CreatedAt), + Descending = true + }); + + result.Items + .Select(x => x.CreatedAt) + .Should() + .BeInDescendingOrder(); + } + + // --------------------------------------------------------------------- + // GetUserChainDetailAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task GetUserChainDetailAsync_WhenChainDoesNotExist_ThrowsNotFound() + { + var (sut, _) = CreateSut(); + var user = UserKey.New(); + + var act = () => sut.GetUserChainDetailAsync( + Context(user), + user, + SessionChainId.New()); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task GetUserChainDetailAsync_WhenChainBelongsToDifferentUser_ThrowsValidation() + { + var (sut, store) = CreateSut(); + + var requestedUser = UserKey.New(); + var owner = UserKey.New(); + + var ownerRoot = await CreateRootAsync(store, owner); + + var chain = CreateChain( + ownerRoot, + Now.AddMinutes(-10)); + + await store.CreateChainAsync(chain); + + var act = () => sut.GetUserChainDetailAsync( + Context(requestedUser), + requestedUser, + chain.ChainId); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task GetUserChainDetailAsync_ReturnsSessionsNewestFirst() + { + var (sut, store) = CreateSut(); + var user = UserKey.New(); + + var root = await CreateRootAsync(store, user); + + var chain = CreateChain( + root, + Now.AddHours(-1)); + + await store.CreateChainAsync(chain); + + var older = CreateSession( + user, + chain.ChainId, + Now.AddMinutes(-40), + "older-session"); + + var newer = CreateSession( + user, + chain.ChainId, + Now.AddMinutes(-10), + "newer-session"); + + await store.CreateSessionAsync(older); + await store.CreateSessionAsync(newer); + + var result = await sut.GetUserChainDetailAsync( + Context(user), + user, + chain.ChainId); + + result.ChainId.Should().Be(chain.ChainId); + + result.Sessions + .Select(x => x.SessionId) + .Should() + .Equal( + newer.SessionId, + older.SessionId); + } + + // --------------------------------------------------------------------- + // RevokeUserSessionAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task RevokeUserSessionAsync_WhenSessionBelongsToUser_RevokesSession() + { + var (sut, store) = CreateSut(); + var user = UserKey.New(); + + var root = await CreateRootAsync(store, user); + + var chain = CreateChain( + root, + Now.AddHours(-1)); + + var session = CreateSession( + user, + chain.ChainId, + Now.AddMinutes(-30), + "session-to-revoke"); + + await store.CreateChainAsync(chain); + await store.CreateSessionAsync(session); + + await sut.RevokeUserSessionAsync( + Context(user), + user, + session.SessionId); + + var persisted = + await store.GetSessionAsync(session.SessionId); + + persisted.Should().NotBeNull(); + persisted!.IsRevoked.Should().BeTrue(); + persisted.RevokedAt.Should().Be(Now); + } + + [Fact] + public async Task RevokeUserSessionAsync_WhenSessionBelongsToDifferentUser_DoesNotRevoke() + { + var (sut, store) = CreateSut(); + + var caller = UserKey.New(); + var owner = UserKey.New(); + + var ownerRoot = await CreateRootAsync(store, owner); + + var chain = CreateChain( + ownerRoot, + Now.AddHours(-1)); + + var session = CreateSession( + owner, + chain.ChainId, + Now.AddMinutes(-30), + "foreign-session"); + + await store.CreateChainAsync(chain); + await store.CreateSessionAsync(session); + + var act = () => sut.RevokeUserSessionAsync( + Context(caller), + caller, + session.SessionId); + + await act.Should() + .ThrowAsync(); + + var persisted = + await store.GetSessionAsync(session.SessionId); + + persisted.Should().NotBeNull(); + persisted!.IsRevoked.Should().BeFalse(); + } + + // --------------------------------------------------------------------- + // RevokeUserChainAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task RevokeUserChainAsync_WhenChainBelongsToUser_RevokesChain() + { + var (sut, store) = CreateSut(); + var user = UserKey.New(); + + var root = await CreateRootAsync(store, user); + + var chain = CreateChain( + root, + Now.AddHours(-1)); + + await store.CreateChainAsync(chain); + + var result = await sut.RevokeUserChainAsync( + Context(user), + user, + chain.ChainId); + + result.CurrentChain.Should().BeFalse(); + result.RootRevoked.Should().BeFalse(); + + var persisted = + await store.GetChainAsync(chain.ChainId); + + persisted.Should().NotBeNull(); + persisted!.IsRevoked.Should().BeTrue(); + persisted.RevokedAt.Should().Be(Now); + } + + [Fact] + public async Task RevokeUserChainAsync_WhenRevokingActorChain_ReportsCurrentChain() + { + var (sut, store) = CreateSut(); + var user = UserKey.New(); + + var root = await CreateRootAsync(store, user); + + var chain = CreateChain( + root, + Now.AddHours(-1)); + + await store.CreateChainAsync(chain); + + var result = await sut.RevokeUserChainAsync( + Context(user, chain.ChainId), + user, + chain.ChainId); + + result.CurrentChain.Should().BeTrue(); + result.RootRevoked.Should().BeFalse(); + + var persisted = + await store.GetChainAsync(chain.ChainId); + + persisted.Should().NotBeNull(); + persisted!.IsRevoked.Should().BeTrue(); + } + + [Fact] + public async Task RevokeUserChainAsync_WhenChainDoesNotExist_ThrowsNotFound() + { + var (sut, _) = CreateSut(); + var user = UserKey.New(); + + var act = () => sut.RevokeUserChainAsync( + Context(user), + user, + SessionChainId.New()); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task RevokeUserChainAsync_WhenChainBelongsToDifferentUser_MustNotRevokeChain() + { + var (sut, store) = CreateSut(); + + var caller = UserKey.New(); + var owner = UserKey.New(); + + var ownerRoot = await CreateRootAsync(store, owner); + + var chain = CreateChain( + ownerRoot, + Now.AddHours(-1)); + + await store.CreateChainAsync(chain); + + var act = () => sut.RevokeUserChainAsync( + Context(caller), + caller, + chain.ChainId); + + await act.Should() + .ThrowAsync(); + + var persisted = + await store.GetChainAsync(chain.ChainId); + + persisted.Should().NotBeNull(); + persisted!.IsRevoked.Should().BeFalse(); + } + + // --------------------------------------------------------------------- + // RevokeAllChainsAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task RevokeAllChainsAsync_WithExceptChain_LeavesExceptedChainActive() + { + var (sut, store) = CreateSut(); + var user = UserKey.New(); + + var root = await CreateRootAsync(store, user); + + var keep = CreateChain( + root, + Now.AddMinutes(-30)); + + var revoke1 = CreateChain( + root, + Now.AddMinutes(-20)); + + var revoke2 = CreateChain( + root, + Now.AddMinutes(-10)); + + await store.CreateChainAsync(keep); + await store.CreateChainAsync(revoke1); + await store.CreateChainAsync(revoke2); + + await sut.RevokeAllChainsAsync( + Context(user), + user, + keep.ChainId); + + var persistedKeep = + await store.GetChainAsync(keep.ChainId); + + var persistedRevoke1 = + await store.GetChainAsync(revoke1.ChainId); + + var persistedRevoke2 = + await store.GetChainAsync(revoke2.ChainId); + + persistedKeep!.IsRevoked.Should().BeFalse(); + persistedRevoke1!.IsRevoked.Should().BeTrue(); + persistedRevoke2!.IsRevoked.Should().BeTrue(); + } + + [Fact] + public async Task RevokeAllChainsAsync_WithoutExceptChain_RevokesAllChains() + { + var (sut, store) = CreateSut(); + var user = UserKey.New(); + + var root = await CreateRootAsync(store, user); + + var first = CreateChain( + root, + Now.AddMinutes(-20)); + + var second = CreateChain( + root, + Now.AddMinutes(-10)); + + await store.CreateChainAsync(first); + await store.CreateChainAsync(second); + + await sut.RevokeAllChainsAsync( + Context(user), + user, + exceptChainId: null); + + (await store.GetChainAsync(first.ChainId))! + .IsRevoked.Should().BeTrue(); + + (await store.GetChainAsync(second.ChainId))! + .IsRevoked.Should().BeTrue(); + } + + // --------------------------------------------------------------------- + // Logout + // --------------------------------------------------------------------- + + [Fact] + public async Task LogoutDeviceAsync_WhenActorChainMatches_ReportsCurrentChain() + { + var (sut, store) = CreateSut(); + var user = UserKey.New(); + + var root = await CreateRootAsync(store, user); + + var chain = CreateChain( + root, + Now.AddHours(-1)); + + await store.CreateChainAsync(chain); + + var result = await sut.LogoutDeviceAsync( + Context(user, chain.ChainId), + chain.ChainId); + + result.CurrentChain.Should().BeTrue(); + result.RootRevoked.Should().BeFalse(); + } + + [Fact] + public async Task LogoutOtherDevicesAsync_RevokesSessionsOnOtherChains() + { + var (sut, store) = CreateSut(); + var user = UserKey.New(); + + var root = await CreateRootAsync(store, user); + + var currentChain = CreateChain( + root, + Now.AddHours(-2)); + + var otherChain = CreateChain( + root, + Now.AddHours(-1)); + + await store.CreateChainAsync(currentChain); + await store.CreateChainAsync(otherChain); + + var currentSession = CreateSession( + user, + currentChain.ChainId, + Now.AddMinutes(-30), + "current-session"); + + var otherSession = CreateSession( + user, + otherChain.ChainId, + Now.AddMinutes(-20), + "other-session"); + + await store.CreateSessionAsync(currentSession); + await store.CreateSessionAsync(otherSession); + + await sut.LogoutOtherDevicesAsync( + Context(user, currentChain.ChainId), + user, + currentChain.ChainId); + + var persistedCurrent = + await store.GetSessionAsync(currentSession.SessionId); + + var persistedOther = + await store.GetSessionAsync(otherSession.SessionId); + + persistedCurrent!.IsRevoked.Should().BeFalse(); + persistedOther!.IsRevoked.Should().BeTrue(); + } + + [Fact] + public async Task LogoutAllDevicesAsync_RevokesAllUserSessions() + { + var (sut, store) = CreateSut(); + var user = UserKey.New(); + + var root = await CreateRootAsync(store, user); + + var firstChain = CreateChain( + root, + Now.AddHours(-2)); + + var secondChain = CreateChain( + root, + Now.AddHours(-1)); + + await store.CreateChainAsync(firstChain); + await store.CreateChainAsync(secondChain); + + var firstSession = CreateSession( + user, + firstChain.ChainId, + Now.AddMinutes(-30), + "first-session"); + + var secondSession = CreateSession( + user, + secondChain.ChainId, + Now.AddMinutes(-20), + "second-session"); + + await store.CreateSessionAsync(firstSession); + await store.CreateSessionAsync(secondSession); + + await sut.LogoutAllDevicesAsync( + Context(user), + user); + + (await store.GetSessionAsync(firstSession.SessionId))! + .IsRevoked.Should().BeTrue(); + + (await store.GetSessionAsync(secondSession.SessionId))! + .IsRevoked.Should().BeTrue(); + } + + // --------------------------------------------------------------------- + // Root + // --------------------------------------------------------------------- + + [Fact] + public async Task RevokeRootAsync_RevokesRootAndItsChains() + { + var (sut, store) = CreateSut(); + var user = UserKey.New(); + + var root = await CreateRootAsync(store, user); + + var firstChain = CreateChain( + root, + Now.AddMinutes(-20)); + + var secondChain = CreateChain( + root, + Now.AddMinutes(-10)); + + await store.CreateChainAsync(firstChain); + await store.CreateChainAsync(secondChain); + + await sut.RevokeRootAsync( + Context(user), + user); + + var persistedRoot = + await store.GetRootByUserAsync(user); + + persistedRoot.Should().NotBeNull(); + persistedRoot!.IsRevoked.Should().BeTrue(); + persistedRoot.RevokedAt.Should().Be(Now); + + (await store.GetChainAsync(firstChain.ChainId))! + .IsRevoked.Should().BeTrue(); + + (await store.GetChainAsync(secondChain.ChainId))! + .IsRevoked.Should().BeTrue(); + } + + // --------------------------------------------------------------------- + // Infrastructure / Helpers + // --------------------------------------------------------------------- + + private static ( + SessionApplicationService Sut, + ISessionStore Store) + CreateSut() + { + var factory = new InMemorySessionStoreFactory(); + + var store = factory.Create(TenantKey.Single); + + var sut = new SessionApplicationService( + new PassThroughAccessOrchestrator(), + factory, + new TestClock(Now)); + + return (sut, store); + } + + private static async Task CreateRootAsync( + ISessionStore store, + UserKey user) + { + var root = UAuthSessionRoot.Create( + TenantKey.Single, + user, + Now.AddHours(-2)); + + await store.CreateRootAsync(root); + + return root; + } + + private static UAuthSessionChain CreateChain( + UAuthSessionRoot root, + DateTimeOffset createdAt) + { + return UAuthSessionChain.Create( + SessionChainId.New(), + root.RootId, + root.Tenant, + root.UserKey, + createdAt, + expiresAt: Now.AddDays(30), + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: root.SecurityVersion); + } + + private static UAuthSession CreateSession( + UserKey user, + SessionChainId chainId, + DateTimeOffset createdAt, + string id) + { + return UAuthSession.Create( + TestIds.Session(id), + TenantKey.Single, + user, + chainId, + createdAt, + createdAt.AddHours(8), + securityVersion: 0, + TestDevice.Default(), + ClaimsSnapshot.Empty, + SessionMetadata.Empty); + } + + private static AccessContext Context( + UserKey user, + SessionChainId? actorChainId = null) + { + return new AccessContext( + actorUserKey: user, + actorTenant: TenantKey.Single, + isAuthenticated: true, + isSystemActor: false, + actorChainId: actorChainId, + resource: "session", + targetUserKey: user, + resourceTenant: TenantKey.Single, + action: "session.manage", + attributes: EmptyAttributes.Instance); + } + + private sealed class PassThroughAccessOrchestrator : IAccessOrchestrator + { + public Task ExecuteAsync( + AccessContext context, + IAccessCommand command, + CancellationToken ct = default) + { + return command.ExecuteAsync(ct); + } + + public Task ExecuteAsync( + AccessContext context, + IAccessCommand command, + CancellationToken ct = default) + { + return command.ExecuteAsync(ct); + } + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionEndpointHandlerTests.cs new file mode 100644 index 00000000..2c35608e --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionEndpointHandlerTests.cs @@ -0,0 +1,699 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Moq; +using System.Text; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server; + +public sealed class SessionEndpointHandlerTests +{ + // --------------------------------------------------------------------- + // Authentication boundary + // --------------------------------------------------------------------- + + [Fact] + public async Task GetMyChainsAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var flow = CreateUnauthenticatedFlow(); + var fixture = CreateFixture(flow); + + var result = await fixture.Sut.GetMyChainsAsync(fixture.HttpContext); + + result.Should().BeOfType(); + + fixture.AccessFactory.VerifyNoOtherCalls(); + fixture.Sessions.VerifyNoOtherCalls(); + } + + [Fact] + public async Task RevokeMyChainAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var flow = CreateUnauthenticatedFlow(); + var fixture = CreateFixture(flow); + + var result = await fixture.Sut.RevokeMyChainAsync( + SessionChainId.New(), + fixture.HttpContext); + + result.Should().BeOfType(); + + fixture.AccessFactory.VerifyNoOtherCalls(); + fixture.Sessions.VerifyNoOtherCalls(); + } + + [Fact] + public async Task GetUserChainsAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var flow = CreateUnauthenticatedFlow(); + var fixture = CreateFixture(flow); + + var result = await fixture.Sut.GetUserChainsAsync( + UserKey.New(), + fixture.HttpContext); + + result.Should().BeOfType(); + + fixture.AccessFactory.VerifyNoOtherCalls(); + fixture.Sessions.VerifyNoOtherCalls(); + } + + [Fact] + public async Task RevokeRootAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var flow = CreateUnauthenticatedFlow(); + var fixture = CreateFixture(flow); + + var result = await fixture.Sut.RevokeRootAsync( + UserKey.New(), + fixture.HttpContext); + + result.Should().BeOfType(); + + fixture.AccessFactory.VerifyNoOtherCalls(); + fixture.Sessions.VerifyNoOtherCalls(); + } + + [Fact] + public async Task RevokeOtherChainsAsync_WhenSessionIsMissing_ReturnsUnauthorized() + { + var flow = AuthFlowTestFactory.LoginSuccess(); + flow.Session.Should().BeNull(); + + var fixture = CreateFixture(flow); + + var result = await fixture.Sut.RevokeOtherChainsAsync( + fixture.HttpContext); + + result.Should().BeOfType(); + + fixture.AccessFactory.VerifyNoOtherCalls(); + fixture.Sessions.VerifyNoOtherCalls(); + } + + // --------------------------------------------------------------------- + // Self endpoints + // --------------------------------------------------------------------- + + [Fact] + public async Task GetMyChainsAsync_UsesSelfActionAndCurrentUser() + { + var flow = AuthFlowTestFactory.LoginSuccess(); + var user = flow.UserKey!.Value; + var fixture = CreateFixture(flow); + + SetJsonBody( + fixture.HttpContext, + """ + { + "pageNumber": 2, + "pageSize": 25 + } + """); + + var access = CreateAccess(flow, user, UAuthActions.Sessions.ListChainsSelf); + + PageRequest? capturedRequest = null; + + fixture.AccessFactory + .Setup(x => x.CreateAsync( + flow, + UAuthActions.Sessions.ListChainsSelf, + "sessions", + user.Value, + null, + It.IsAny())) + .ReturnsAsync(access); + + var expected = new PagedResult( + [], + 0, + 1, + 250, + null, + false); + + fixture.Sessions + .Setup(x => x.GetUserChainsAsync( + access, + user, + It.IsAny(), + fixture.HttpContext.RequestAborted)) + .Callback( + (_, _, request, _) => capturedRequest = request) + .ReturnsAsync(expected); + + var result = await fixture.Sut.GetMyChainsAsync( + fixture.HttpContext); + + var ok = result.Should() + .BeOfType>>() + .Subject; + + ok.Value.Should().BeSameAs(expected); + + capturedRequest.Should().NotBeNull(); + capturedRequest!.PageNumber.Should().Be(2); + capturedRequest.PageSize.Should().Be(25); + } + + [Fact] + public async Task GetMyChainDetailAsync_ForwardsChainIdAndUsesGetChainSelf() + { + var flow = AuthFlowTestFactory.LoginSuccess(); + var user = flow.UserKey!.Value; + var chainId = SessionChainId.New(); + var fixture = CreateFixture(flow); + + var access = CreateAccess( + flow, + user, + UAuthActions.Sessions.GetChainSelf); + + var expected = new SessionChainDetail + { + ChainId = chainId + }; + + fixture.AccessFactory + .Setup(x => x.CreateAsync( + flow, + UAuthActions.Sessions.GetChainSelf, + "sessions", + user.Value, + null, + It.IsAny())) + .ReturnsAsync(access); + + fixture.Sessions + .Setup(x => x.GetUserChainDetailAsync( + access, + user, + chainId, + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(expected); + + var result = await fixture.Sut.GetMyChainDetailAsync( + chainId, + fixture.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().BeSameAs(expected); + } + + [Fact] + public async Task RevokeMyChainAsync_ForwardsChainIdAndUsesRevokeChainSelf() + { + var flow = AuthFlowTestFactory.LoginSuccess(); + var user = flow.UserKey!.Value; + var chainId = SessionChainId.New(); + var fixture = CreateFixture(flow); + + var access = CreateAccess( + flow, + user, + UAuthActions.Sessions.RevokeChainSelf); + + var expected = new RevokeResult + { + CurrentChain = true, + RootRevoked = false + }; + + fixture.AccessFactory + .Setup(x => x.CreateAsync( + flow, + UAuthActions.Sessions.RevokeChainSelf, + "sessions", + user.Value, + null, + It.IsAny())) + .ReturnsAsync(access); + + fixture.Sessions + .Setup(x => x.RevokeUserChainAsync( + access, + user, + chainId, + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(expected); + + var result = await fixture.Sut.RevokeMyChainAsync( + chainId, + fixture.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().BeSameAs(expected); + } + + [Fact] + public async Task RevokeAllMyChainsAsync_UsesSelfActionAndDoesNotExcludeChain() + { + var flow = AuthFlowTestFactory.LoginSuccess(); + var user = flow.UserKey!.Value; + var fixture = CreateFixture(flow); + + var access = CreateAccess( + flow, + user, + UAuthActions.Sessions.RevokeAllChainsSelf); + + fixture.AccessFactory + .Setup(x => x.CreateAsync( + flow, + UAuthActions.Sessions.RevokeAllChainsSelf, + "sessions", + user.Value, + null, + It.IsAny())) + .ReturnsAsync(access); + + fixture.Sessions + .Setup(x => x.RevokeAllChainsAsync( + access, + user, + null, + fixture.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = await fixture.Sut.RevokeAllMyChainsAsync( + fixture.HttpContext); + + result.Should().BeOfType(); + + fixture.Sessions.Verify( + x => x.RevokeAllChainsAsync( + access, + user, + null, + fixture.HttpContext.RequestAborted), + Times.Once); + } + + // --------------------------------------------------------------------- + // Admin endpoints + // --------------------------------------------------------------------- + + [Fact] + public async Task GetUserChainsAsync_UsesAdminActionAndTargetUser() + { + var flow = AuthFlowTestFactory.LoginSuccess(); + var targetUser = UserKey.New(); + var fixture = CreateFixture(flow); + + SetJsonBody( + fixture.HttpContext, + """ + { + "pageNumber": 2, + "pageSize": 25 + } + """); + + var access = CreateAccess( + flow, + targetUser, + UAuthActions.Sessions.ListChainsAdmin); + + fixture.AccessFactory + .Setup(x => x.CreateAsync( + flow, + UAuthActions.Sessions.ListChainsAdmin, + "sessions", + targetUser.Value, + null, + It.IsAny())) + .ReturnsAsync(access); + + var expected = new PagedResult( + [], + 0, + 2, + 25, + null, + false); + + fixture.Sessions + .Setup(x => x.GetUserChainsAsync( + access, + targetUser, + It.IsAny(), + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(expected); + + var result = await fixture.Sut.GetUserChainsAsync( + targetUser, + fixture.HttpContext); + + var ok = result.Should() + .BeOfType>>() + .Subject; + + ok.Value.Should().BeSameAs(expected); + + fixture.AccessFactory.Verify( + x => x.CreateAsync( + flow, + UAuthActions.Sessions.ListChainsAdmin, + "sessions", + targetUser.Value, + null, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task GetUserChainDetailAsync_UsesAdminActionAndForwardsTargetIds() + { + var flow = AuthFlowTestFactory.LoginSuccess(); + var targetUser = UserKey.New(); + var chainId = SessionChainId.New(); + var fixture = CreateFixture(flow); + + var access = CreateAccess( + flow, + targetUser, + UAuthActions.Sessions.GetChainAdmin); + + var expected = new SessionChainDetail + { + ChainId = chainId + }; + + fixture.AccessFactory + .Setup(x => x.CreateAsync( + flow, + UAuthActions.Sessions.GetChainAdmin, + "sessions", + targetUser.Value, + null, + It.IsAny())) + .ReturnsAsync(access); + + fixture.Sessions + .Setup(x => x.GetUserChainDetailAsync( + access, + targetUser, + chainId, + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(expected); + + var result = await fixture.Sut.GetUserChainDetailAsync( + targetUser, + chainId, + fixture.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().BeSameAs(expected); + } + + [Fact] + public async Task RevokeUserSessionAsync_UsesAdminActionAndForwardsSessionId() + { + var flow = AuthFlowTestFactory.LoginSuccess(); + var targetUser = UserKey.New(); + var sessionId = TestIds.Session("test-session"); + var fixture = CreateFixture(flow); + + var access = CreateAccess( + flow, + targetUser, + UAuthActions.Sessions.RevokeSessionAdmin); + + fixture.AccessFactory + .Setup(x => x.CreateAsync( + flow, + UAuthActions.Sessions.RevokeSessionAdmin, + "sessions", + targetUser.Value, + null, + It.IsAny())) + .ReturnsAsync(access); + + fixture.Sessions + .Setup(x => x.RevokeUserSessionAsync( + access, + targetUser, + sessionId, + fixture.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = await fixture.Sut.RevokeUserSessionAsync( + targetUser, + sessionId, + fixture.HttpContext); + + result.Should().BeOfType(); + + fixture.Sessions.Verify( + x => x.RevokeUserSessionAsync( + access, + targetUser, + sessionId, + fixture.HttpContext.RequestAborted), + Times.Once); + } + + [Fact] + public async Task RevokeUserChainAsync_UsesAdminActionAndForwardsChainId() + { + var flow = AuthFlowTestFactory.LoginSuccess(); + var targetUser = UserKey.New(); + var chainId = SessionChainId.New(); + var fixture = CreateFixture(flow); + + var access = CreateAccess( + flow, + targetUser, + UAuthActions.Sessions.RevokeChainAdmin); + + var expected = new RevokeResult + { + CurrentChain = false, + RootRevoked = false + }; + + fixture.AccessFactory + .Setup(x => x.CreateAsync( + flow, + UAuthActions.Sessions.RevokeChainAdmin, + "sessions", + targetUser.Value, + null, + It.IsAny())) + .ReturnsAsync(access); + + fixture.Sessions + .Setup(x => x.RevokeUserChainAsync( + access, + targetUser, + chainId, + fixture.HttpContext.RequestAborted)) + .ReturnsAsync(expected); + + var result = await fixture.Sut.RevokeUserChainAsync( + targetUser, + chainId, + fixture.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().BeSameAs(expected); + } + + [Fact] + public async Task RevokeAllChainsAsync_UsesAdminActionAndForwardsExceptChain() + { + var flow = AuthFlowTestFactory.LoginSuccess(); + var targetUser = UserKey.New(); + var exceptChainId = SessionChainId.New(); + var fixture = CreateFixture(flow); + + var access = CreateAccess( + flow, + targetUser, + UAuthActions.Sessions.RevokeAllChainsAdmin); + + fixture.AccessFactory + .Setup(x => x.CreateAsync( + flow, + UAuthActions.Sessions.RevokeAllChainsAdmin, + "sessions", + targetUser.Value, + null, + It.IsAny())) + .ReturnsAsync(access); + + fixture.Sessions + .Setup(x => x.RevokeAllChainsAsync( + access, + targetUser, + exceptChainId, + fixture.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = await fixture.Sut.RevokeAllChainsAsync( + targetUser, + exceptChainId, + fixture.HttpContext); + + result.Should().BeOfType(); + + fixture.Sessions.Verify( + x => x.RevokeAllChainsAsync( + access, + targetUser, + exceptChainId, + fixture.HttpContext.RequestAborted), + Times.Once); + } + + [Fact] + public async Task RevokeRootAsync_UsesAdminActionAndForwardsTargetUser() + { + var flow = AuthFlowTestFactory.LoginSuccess(); + var targetUser = UserKey.New(); + var fixture = CreateFixture(flow); + + var access = CreateAccess( + flow, + targetUser, + UAuthActions.Sessions.RevokeRootAdmin); + + fixture.AccessFactory + .Setup(x => x.CreateAsync( + flow, + UAuthActions.Sessions.RevokeRootAdmin, + "sessions", + targetUser.Value, + null, + It.IsAny())) + .ReturnsAsync(access); + + fixture.Sessions + .Setup(x => x.RevokeRootAsync( + access, + targetUser, + fixture.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = await fixture.Sut.RevokeRootAsync( + targetUser, + fixture.HttpContext); + + result.Should().BeOfType(); + + fixture.Sessions.Verify( + x => x.RevokeRootAsync( + access, + targetUser, + fixture.HttpContext.RequestAborted), + Times.Once); + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + private static Fixture CreateFixture(AuthFlowContext flow) + { + var authFlow = + new Mock(MockBehavior.Strict); + + var accessFactory = + new Mock(MockBehavior.Strict); + + var sessions = + new Mock(MockBehavior.Strict); + + authFlow + .SetupGet(x => x.Current) + .Returns(flow); + + var httpContext = new DefaultHttpContext(); + + var sut = new SessionEndpointHandler( + authFlow.Object, + accessFactory.Object, + sessions.Object); + + return new Fixture( + sut, + accessFactory, + sessions, + httpContext); + } + + private static AccessContext CreateAccess( + AuthFlowContext flow, + UserKey targetUser, + string action) + { + return new AccessContext( + actorUserKey: flow.UserKey, + actorTenant: flow.Tenant, + isAuthenticated: flow.IsAuthenticated, + isSystemActor: false, + actorChainId: flow.Session?.ChainId, + resource: "sessions", + targetUserKey: targetUser, + resourceTenant: flow.Tenant, + action: action, + attributes: + new Dictionary()); + } + + private static AuthFlowContext CreateUnauthenticatedFlow() + { + var authenticated = AuthFlowTestFactory.LoginSuccess(); + + return new AuthFlowContext( + flowType: authenticated.FlowType, + clientProfile: authenticated.ClientProfile, + effectiveMode: authenticated.EffectiveMode, + device: authenticated.Device, + tenantKey: authenticated.Tenant, + isAuthenticated: false, + userKey: null, + session: null, + originalOptions: authenticated.OriginalOptions, + effectiveOptions: authenticated.EffectiveOptions, + response: authenticated.Response, + primaryTokenKind: authenticated.PrimaryTokenKind, + returnUrlInfo: authenticated.ReturnUrlInfo); + } + + private sealed record Fixture( + SessionEndpointHandler Sut, + Mock AccessFactory, + Mock Sessions, + DefaultHttpContext HttpContext); + + private static void SetJsonBody(HttpContext context, string json) + { + var bytes = Encoding.UTF8.GetBytes(json); + + context.Request.ContentType = "application/json"; + context.Request.ContentLength = bytes.Length; + context.Request.Body = new MemoryStream(bytes); + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionTouchServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionTouchServiceTests.cs new file mode 100644 index 00000000..73cbe389 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionTouchServiceTests.cs @@ -0,0 +1,230 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Sessions.InMemory; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class SessionTouchServiceTests +{ + private static readonly DateTimeOffset Now = + new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task RefreshAsync_WhenValidationIsInvalid_ReturnsReauthRequired() + { + var factory = new Mock(MockBehavior.Strict); + var sut = new SessionTouchService(factory.Object); + var validation = SessionValidationResult.Invalid(SessionState.Revoked); + + var result = await sut.RefreshAsync( + validation, + new SessionTouchPolicy { TouchInterval = TimeSpan.FromMinutes(5) }, + SessionTouchMode.IfNeeded, + Now); + + result.RequiresReauth.Should().BeTrue(); + result.IsSuccess.Should().BeFalse(); + factory.Verify(x => x.Create(It.IsAny()), Times.Never); + } + + [Fact] + public async Task RefreshAsync_WhenChainIdIsMissing_ReturnsReauthRequired() + { + var factory = new Mock(MockBehavior.Strict); + var sut = new SessionTouchService(factory.Object); + var validation = SessionValidationResult.Invalid( + SessionState.Active, + userId: TestUsers.User, + sessionId: TestIds.Session("active-session")); + + var result = await sut.RefreshAsync( + validation, + new SessionTouchPolicy { TouchInterval = TimeSpan.FromMinutes(5) }, + SessionTouchMode.IfNeeded, + Now); + + result.RequiresReauth.Should().BeTrue(); + result.IsSuccess.Should().BeFalse(); + factory.Verify(x => x.Create(It.IsAny()), Times.Never); + } + + [Fact] + public async Task RefreshAsync_WhenTouchIntervalIsDisabled_ReturnsSuccessWithoutTouchingStore() + { + var factory = new Mock(MockBehavior.Strict); + var sut = new SessionTouchService(factory.Object); + var validation = CreateActiveValidation(); + + var result = await sut.RefreshAsync( + validation, + new SessionTouchPolicy { TouchInterval = null }, + SessionTouchMode.IfNeeded, + Now); + + result.IsSuccess.Should().BeTrue(); + result.DidTouch.Should().BeFalse(); + result.SessionId.Should().Be(validation.SessionId); + factory.Verify(x => x.Create(It.IsAny()), Times.Never); + } + + [Fact] + public async Task RefreshAsync_WhenChainDoesNotExist_ReturnsSuccessWithoutTouch() + { + var factory = new InMemorySessionStoreFactory(); + var sut = new SessionTouchService(factory); + var validation = CreateActiveValidation(); + + var result = await sut.RefreshAsync( + validation, + new SessionTouchPolicy { TouchInterval = TimeSpan.FromMinutes(5) }, + SessionTouchMode.IfNeeded, + Now); + + result.IsSuccess.Should().BeTrue(); + result.DidTouch.Should().BeFalse(); + result.SessionId.Should().Be(validation.SessionId); + } + + [Fact] + public async Task RefreshAsync_WhenChainIsRevoked_ReturnsSuccessWithoutTouch() + { + var factory = new InMemorySessionStoreFactory(); + var validation = CreateActiveValidation(); + var store = factory.Create(validation.Tenant); + + var chain = CreateChain(validation, Now.AddMinutes(-10)); + await store.CreateChainAsync(chain); + + var revoked = chain.Revoke(Now.AddMinutes(-1)); + await store.SaveChainAsync(revoked, chain.Version); + + var sut = new SessionTouchService(factory); + + var result = await sut.RefreshAsync( + validation, + new SessionTouchPolicy { TouchInterval = TimeSpan.FromMinutes(5) }, + SessionTouchMode.IfNeeded, + Now); + + result.IsSuccess.Should().BeTrue(); + result.DidTouch.Should().BeFalse(); + + var persisted = await store.GetChainAsync(validation.ChainId!.Value); + + persisted.Should().NotBeNull(); + persisted!.IsRevoked.Should().BeTrue(); + persisted.LastSeenAt.Should().Be(revoked.LastSeenAt); + persisted.TouchCount.Should().Be(revoked.TouchCount); + persisted.Version.Should().Be(revoked.Version); + } + + [Fact] + public async Task RefreshAsync_WhenTouchIntervalHasNotElapsed_ReturnsSuccessWithoutTouch() + { + var factory = new InMemorySessionStoreFactory(); + var validation = CreateActiveValidation(); + var store = factory.Create(validation.Tenant); + var chain = CreateChain(validation, Now.AddMinutes(-4)); + await store.CreateChainAsync(chain); + + var sut = new SessionTouchService(factory); + + var result = await sut.RefreshAsync( + validation, + new SessionTouchPolicy { TouchInterval = TimeSpan.FromMinutes(5) }, + SessionTouchMode.IfNeeded, + Now); + + result.IsSuccess.Should().BeTrue(); + result.DidTouch.Should().BeFalse(); + + var persisted = await store.GetChainAsync(validation.ChainId!.Value); + persisted!.LastSeenAt.Should().Be(chain.LastSeenAt); + persisted.TouchCount.Should().Be(0); + } + + [Fact] + public async Task RefreshAsync_WhenTouchIntervalHasElapsed_TouchesAndSavesChain() + { + var factory = new InMemorySessionStoreFactory(); + var validation = CreateActiveValidation(); + var store = factory.Create(validation.Tenant); + var chain = CreateChain(validation, Now.AddMinutes(-6)); + await store.CreateChainAsync(chain); + + var sut = new SessionTouchService(factory); + + var result = await sut.RefreshAsync( + validation, + new SessionTouchPolicy { TouchInterval = TimeSpan.FromMinutes(5) }, + SessionTouchMode.IfNeeded, + Now); + + result.IsSuccess.Should().BeTrue(); + result.DidTouch.Should().BeTrue(); + result.SessionId.Should().Be(validation.SessionId); + + var persisted = await store.GetChainAsync(validation.ChainId!.Value); + persisted!.LastSeenAt.Should().Be(Now); + persisted.TouchCount.Should().Be(1); + persisted.Version.Should().Be(chain.Version + 1); + } + + [Fact] + public async Task RefreshAsync_WhenTouchIntervalExactlyElapsed_TouchesChain() + { + var factory = new InMemorySessionStoreFactory(); + var validation = CreateActiveValidation(); + var store = factory.Create(validation.Tenant); + var chain = CreateChain(validation, Now.AddMinutes(-5)); + await store.CreateChainAsync(chain); + + var sut = new SessionTouchService(factory); + + var result = await sut.RefreshAsync( + validation, + new SessionTouchPolicy { TouchInterval = TimeSpan.FromMinutes(5) }, + SessionTouchMode.IfNeeded, + Now); + + result.IsSuccess.Should().BeTrue(); + result.DidTouch.Should().BeTrue(); + + var persisted = await store.GetChainAsync(validation.ChainId!.Value); + persisted!.LastSeenAt.Should().Be(Now); + persisted.TouchCount.Should().Be(1); + } + + private static SessionValidationResult CreateActiveValidation() + { + return SessionValidationResult.Active( + tenant: TenantKey.Single, + userKey: TestUsers.User, + sessionId: TestIds.Session("session-touch-service"), + chainId: SessionChainId.New(), + rootId: SessionRootId.New(), + claims: ClaimsSnapshot.Empty, + authenticatedAt: Now.AddHours(-1), + boundDeviceId: TestDevice.Default().DeviceId); + } + + private static UAuthSessionChain CreateChain(SessionValidationResult validation, DateTimeOffset lastSeenAt) + { + return UAuthSessionChain.Create( + chainId: validation.ChainId!.Value, + rootId: validation.RootId!.Value, + tenant: validation.Tenant, + userKey: validation.UserKey!.Value, + createdAt: lastSeenAt, + expiresAt: Now.AddHours(1), + device: TestDevice.Default(), + claimsSnapshot: ClaimsSnapshot.Empty, + securityVersion: 0); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthFlowServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthFlowServiceTests.cs new file mode 100644 index 00000000..6b6c5e1d --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthFlowServiceTests.cs @@ -0,0 +1,766 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Events; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server; + +public sealed class UAuthFlowServiceTests +{ + private static readonly DateTimeOffset Now = + new(2026, 9, 20, 12, 0, 0, TimeSpan.Zero); + + // --------------------------------------------------------------------- + // LoginAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task LoginAsync_DelegatesToLoginOrchestrator() + { + var flow = AuthFlowTestFactory.LoginSuccess(); + + var request = new LoginRequest + { + Identifier = "user@example.com", + Secret = "password" + }; + + var expected = LoginResult.SuccessPreview(); + + var login = new Mock(MockBehavior.Strict); + + login + .Setup(x => x.LoginAsync( + flow, + request, + It.IsAny())) + .ReturnsAsync(expected); + + var sut = CreateSut( + loginOrchestrator: login.Object); + + var result = await sut.LoginAsync( + flow, + request); + + result.Should().BeSameAs(expected); + + login.Verify( + x => x.LoginAsync( + flow, + request, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task LoginAsync_WithClientProfileOverride_RecreatesFlowBeforeLogin() + { + var originalFlow = AuthFlowTestFactory.LoginSuccess(); + var recreatedFlow = AuthFlowTestFactory.LoginSuccess(); + + var request = new LoginRequest + { + Identifier = "user@example.com", + Secret = "password" + }; + + var execution = new AuthExecutionContext + { + EffectiveClientProfile = UAuthClientProfile.Api, + Device = null + }; + + var expected = LoginResult.SuccessPreview(); + + var factory = new Mock(MockBehavior.Strict); + var login = new Mock(MockBehavior.Strict); + + factory + .Setup(x => x.RecreateWithClientProfileAsync( + originalFlow, + UAuthClientProfile.Api, + It.IsAny())) + .Returns(new ValueTask(recreatedFlow)); + + login + .Setup(x => x.LoginAsync( + recreatedFlow, + request, + It.IsAny())) + .ReturnsAsync(expected); + + var sut = CreateSut( + authFlowContextFactory: factory.Object, + loginOrchestrator: login.Object); + + var result = await sut.LoginAsync( + originalFlow, + execution, + request); + + result.Should().BeSameAs(expected); + + factory.Verify( + x => x.RecreateWithClientProfileAsync( + originalFlow, + UAuthClientProfile.Api, + It.IsAny()), + Times.Once); + + factory.Verify( + x => x.RecreateWithDeviceAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task LoginAsync_WithDeviceOverride_RecreatesFlowBeforeLogin() + { + var originalFlow = AuthFlowTestFactory.LoginSuccess(); + var recreatedFlow = AuthFlowTestFactory.LoginSuccess(); + var device = TestDevice.Default(); + + var request = new LoginRequest + { + Identifier = "user@example.com", + Secret = "password" + }; + + var execution = new AuthExecutionContext + { + EffectiveClientProfile = null, + Device = device + }; + + var expected = LoginResult.SuccessPreview(); + + var factory = new Mock(MockBehavior.Strict); + var login = new Mock(MockBehavior.Strict); + + factory + .Setup(x => x.RecreateWithDeviceAsync( + originalFlow, + device, + It.IsAny())) + .Returns(new ValueTask(recreatedFlow)); + + login + .Setup(x => x.LoginAsync( + recreatedFlow, + request, + It.IsAny())) + .ReturnsAsync(expected); + + var sut = CreateSut( + authFlowContextFactory: factory.Object, + loginOrchestrator: login.Object); + + var result = await sut.LoginAsync( + originalFlow, + execution, + request); + + result.Should().BeSameAs(expected); + + factory.Verify( + x => x.RecreateWithClientProfileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task LoginAsync_WithClientProfileAndDeviceOverride_AppliesOverridesInOrder() + { + var originalFlow = AuthFlowTestFactory.LoginSuccess(); + var profileFlow = AuthFlowTestFactory.LoginSuccess(); + var finalFlow = AuthFlowTestFactory.LoginSuccess(); + + var device = TestDevice.Default(); + + var request = new LoginRequest + { + Identifier = "user@example.com", + Secret = "password" + }; + + var execution = new AuthExecutionContext + { + EffectiveClientProfile = UAuthClientProfile.Api, + Device = device + }; + + var expected = LoginResult.SuccessPreview(); + + var factory = new Mock(MockBehavior.Strict); + var login = new Mock(MockBehavior.Strict); + + factory + .Setup(x => x.RecreateWithClientProfileAsync( + originalFlow, + UAuthClientProfile.Api, + It.IsAny())) + .Returns(new ValueTask(profileFlow)); + + factory + .Setup(x => x.RecreateWithDeviceAsync( + profileFlow, + device, + It.IsAny())) + .Returns(new ValueTask(finalFlow)); + + login + .Setup(x => x.LoginAsync( + finalFlow, + request, + It.IsAny())) + .ReturnsAsync(expected); + + var sut = CreateSut( + authFlowContextFactory: factory.Object, + loginOrchestrator: login.Object); + + var result = await sut.LoginAsync( + originalFlow, + execution, + request); + + result.Should().BeSameAs(expected); + + factory.Verify( + x => x.RecreateWithDeviceAsync( + profileFlow, + device, + It.IsAny()), + Times.Once); + + login.Verify( + x => x.LoginAsync( + finalFlow, + request, + It.IsAny()), + Times.Once); + } + + // --------------------------------------------------------------------- + // Internal Login + // --------------------------------------------------------------------- + + [Fact] + public async Task InternalLoginAsync_DelegatesToInternalLoginOrchestrator() + { + var flow = AuthFlowTestFactory.LoginSuccess(); + + var request = new LoginRequest + { + Identifier = "user@example.com", + Secret = "password" + }; + + var options = new LoginExecutionOptions(); + + var expected = LoginResult.SuccessPreview(); + + var internalLogin = + new Mock(MockBehavior.Strict); + + internalLogin + .Setup(x => x.LoginAsync( + flow, + request, + options, + It.IsAny())) + .ReturnsAsync(expected); + + var sut = CreateSut( + internalLoginOrchestrator: internalLogin.Object); + + var result = await sut.LoginAsync( + flow, + request, + options); + + result.Should().BeSameAs(expected); + + internalLogin.Verify( + x => x.LoginAsync( + flow, + request, + options, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task InternalLoginAsync_WithExecutionOverrides_UsesRecreatedFlow() + { + var originalFlow = AuthFlowTestFactory.LoginSuccess(); + var profileFlow = AuthFlowTestFactory.LoginSuccess(); + var finalFlow = AuthFlowTestFactory.LoginSuccess(); + + var device = TestDevice.Default(); + + var execution = new AuthExecutionContext + { + EffectiveClientProfile = UAuthClientProfile.Api, + Device = device + }; + + var request = new LoginRequest + { + Identifier = "user@example.com", + Secret = "password" + }; + + var options = new LoginExecutionOptions(); + var expected = LoginResult.SuccessPreview(); + + var factory = new Mock(MockBehavior.Strict); + + var internalLogin = + new Mock(MockBehavior.Strict); + + factory + .Setup(x => x.RecreateWithClientProfileAsync( + originalFlow, + UAuthClientProfile.Api, + It.IsAny())) + .Returns(new ValueTask(profileFlow)); + + factory + .Setup(x => x.RecreateWithDeviceAsync( + profileFlow, + device, + It.IsAny())) + .Returns(new ValueTask(finalFlow)); + + internalLogin + .Setup(x => x.LoginAsync( + finalFlow, + request, + options, + It.IsAny())) + .ReturnsAsync(expected); + + var sut = CreateSut( + authFlowContextFactory: factory.Object, + internalLoginOrchestrator: internalLogin.Object); + + var result = await sut.LoginAsync( + originalFlow, + execution, + request, + options); + + result.Should().BeSameAs(expected); + } + + // --------------------------------------------------------------------- + // LogoutAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task LogoutAsync_WhenSessionCannotBeResolved_DoesNotDispatchLogoutEvent() + { + var flow = AuthFlowTestFactory.LoginSuccess(); + var sessionId = TestIds.Session("logout-session"); + + var accessor = CreateAccessor(flow); + + var orchestrator = + new Mock(MockBehavior.Strict); + + orchestrator + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(false); + + var eventRaised = false; + + var events = new UAuthEvents + { + OnUserLoggedOut = _ => + { + eventRaised = true; + return Task.CompletedTask; + } + }; + + var sut = CreateSut( + authFlow: accessor.Object, + orchestrator: orchestrator.Object, + events: new UAuthEventDispatcher(events)); + + await sut.LogoutAsync( + new LogoutRequest + { + SessionId = sessionId + }); + + eventRaised.Should().BeFalse(); + } + + [Fact] + public async Task LogoutAsync_WhenLogoutSucceeds_DispatchesUserLoggedOutEvent() + { + var flow = AuthFlowTestFactory.LoginSuccess(); + var sessionId = TestIds.Session("logout-session"); + + var accessor = CreateAccessor(flow); + + var orchestrator = + new Mock(MockBehavior.Strict); + + orchestrator + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(true); + + UserLoggedOutContext? captured = null; + + var events = new UAuthEvents + { + OnUserLoggedOut = context => + { + captured = context; + return Task.CompletedTask; + } + }; + + var sut = CreateSut( + authFlow: accessor.Object, + orchestrator: orchestrator.Object, + events: new UAuthEventDispatcher(events)); + + await sut.LogoutAsync( + new LogoutRequest + { + SessionId = sessionId + }); + + captured.Should().NotBeNull(); + + captured!.Tenant.Should().Be(flow.Tenant); + captured.UserKey.Should().Be(flow.UserKey!.Value); + captured.LoggedOutAt.Should().Be(Now); + captured.Reason.Should().Be(LogoutReason.Explicit); + captured.SessionId.Should().Be(sessionId); + } + + [Fact] + public async Task LogoutAsync_WhenLogoutSucceedsButUserKeyIsMissing_DoesNotDispatchEvent() + { + var flow = CreateFlow( + userKey: null, + session: null); + + var sessionId = TestIds.Session("logout-session"); + + var accessor = CreateAccessor(flow); + + var orchestrator = + new Mock(MockBehavior.Strict); + + orchestrator + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(true); + + var eventRaised = false; + + var events = new UAuthEvents + { + OnUserLoggedOut = _ => + { + eventRaised = true; + return Task.CompletedTask; + } + }; + + var sut = CreateSut( + authFlow: accessor.Object, + orchestrator: orchestrator.Object, + events: new UAuthEventDispatcher(events)); + + await sut.LogoutAsync( + new LogoutRequest + { + SessionId = sessionId + }); + + eventRaised.Should().BeFalse(); + } + + // --------------------------------------------------------------------- + // LogoutAllAsync + // --------------------------------------------------------------------- + + [Fact] + public async Task LogoutAllAsync_WhenActiveSessionIsMissing_Throws() + { + var flow = CreateFlow( + userKey: UserKey.New(), + session: null); + + var sut = CreateSut( + authFlow: CreateAccessor(flow).Object); + + var act = () => sut.LogoutAllAsync( + new LogoutAllRequest + { + ExceptCurrent = false + }); + + await act.Should() + .ThrowAsync() + .WithMessage("*active session*"); + } + + [Fact] + public async Task LogoutAllAsync_WhenExceptCurrentIsFalse_RevokesAllChains() + { + var user = UserKey.New(); + var chainId = SessionChainId.New(); + + var session = new SessionSecurityContext + { + UserKey = user, + SessionId = TestIds.Session("current-session"), + ChainId = chainId, + State = SessionState.Active + }; + + var flow = CreateFlow( + user, + session); + + var accessor = CreateAccessor(flow); + + RevokeAllChainsCommand? captured = null; + + var orchestrator = + new Mock(MockBehavior.Strict); + + orchestrator + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback, CancellationToken>( + (_, command, _) => + { + captured = command.Should() + .BeOfType() + .Subject; + }) + .ReturnsAsync(CodeBeam.UltimateAuth.Core.Contracts.Unit.Value); + + var sut = CreateSut( + authFlow: accessor.Object, + orchestrator: orchestrator.Object); + + await sut.LogoutAllAsync( + new LogoutAllRequest + { + ExceptCurrent = false + }); + + captured.Should().NotBeNull(); + captured!.UserKey.Should().Be(user); + captured.ExceptChainId.Should().BeNull(); + } + + [Fact] + public async Task LogoutAllAsync_WhenExceptCurrentIsTrue_ExcludesCurrentChain() + { + var user = UserKey.New(); + var chainId = SessionChainId.New(); + + var session = new SessionSecurityContext + { + UserKey = user, + SessionId = TestIds.Session("current-session"), + ChainId = chainId, + State = SessionState.Active + }; + + var flow = CreateFlow( + user, + session); + + var accessor = CreateAccessor(flow); + + RevokeAllChainsCommand? captured = null; + + var orchestrator = + new Mock(MockBehavior.Strict); + + orchestrator + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Callback, CancellationToken>( + (_, command, _) => + { + captured = command.Should() + .BeOfType() + .Subject; + }) + .ReturnsAsync(CodeBeam.UltimateAuth.Core.Contracts.Unit.Value); + + var sut = CreateSut( + authFlow: accessor.Object, + orchestrator: orchestrator.Object); + + await sut.LogoutAllAsync( + new LogoutAllRequest + { + ExceptCurrent = true + }); + + captured.Should().NotBeNull(); + captured!.UserKey.Should().Be(user); + captured.ExceptChainId.Should().Be(chainId); + } + + [Fact] + public async Task LogoutAllAsync_WhenExceptCurrentIsTrueButChainIdIsMissing_Throws() + { + var user = UserKey.New(); + + var session = new SessionSecurityContext + { + UserKey = user, + SessionId = TestIds.Session("current-session"), + ChainId = null, + State = SessionState.Active + }; + + var flow = CreateFlow( + user, + session); + + var orchestrator = + new Mock(MockBehavior.Strict); + + var sut = CreateSut( + authFlow: CreateAccessor(flow).Object, + orchestrator: orchestrator.Object); + + var act = () => sut.LogoutAllAsync( + new LogoutAllRequest + { + ExceptCurrent = true + }); + + await act.Should() + .ThrowAsync() + .WithMessage("*chain could not be resolved*"); + + orchestrator.Verify( + x => x.ExecuteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny()), + Times.Never); + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + private static UAuthFlowService CreateSut( + IAuthFlowContextAccessor? authFlow = null, + IAuthFlowContextFactory? authFlowContextFactory = null, + ILoginOrchestrator? loginOrchestrator = null, + IInternalLoginOrchestrator? internalLoginOrchestrator = null, + ISessionOrchestrator? orchestrator = null, + UAuthEventDispatcher? events = null) + { + return new UAuthFlowService( + authFlow ?? + Mock.Of(), + + authFlowContextFactory ?? + Mock.Of(), + + loginOrchestrator ?? + Mock.Of(), + + internalLoginOrchestrator ?? + Mock.Of(), + + orchestrator ?? + Mock.Of(), + + events ?? + new UAuthEventDispatcher(new UAuthEvents()), + + new TestClock(Now)); + } + + private static Mock CreateAccessor( + AuthFlowContext flow) + { + var accessor = + new Mock(MockBehavior.Strict); + + accessor + .SetupGet(x => x.Current) + .Returns(flow); + + return accessor; + } + + private static AuthFlowContext CreateFlow( + UserKey? userKey, + SessionSecurityContext? session) + { + return new AuthFlowContext( + flowType: AuthFlowType.Logout, + clientProfile: UAuthClientProfile.BlazorServer, + effectiveMode: UAuthMode.PureOpaque, + device: TestDevice.Default(), + tenantKey: TenantKey.Single, + isAuthenticated: userKey.HasValue, + userKey: userKey, + session: session, + originalOptions: TestServerOptions.Default(), + effectiveOptions: TestServerOptions.Effective(), + response: new EffectiveAuthResponse( + sessionIdDelivery: + CredentialResponseOptions.Disabled( + GrantKind.Session), + accessTokenDelivery: + CredentialResponseOptions.Disabled( + GrantKind.AccessToken), + refreshTokenDelivery: + CredentialResponseOptions.Disabled( + GrantKind.RefreshToken), + redirect: + EffectiveRedirectResponse.Disabled), + primaryTokenKind: PrimaryTokenKind.Session, + returnUrlInfo: ReturnUrlInfo.None()); + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthSessionQueryServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthSessionQueryServiceTests.cs new file mode 100644 index 00000000..9ed2252b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthSessionQueryServiceTests.cs @@ -0,0 +1,188 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server; + +public sealed class UAuthSessionQueryServiceTests +{ + [Fact] + public async Task GetSessionAsync_UsesCurrentTenant_AndDelegatesToStore() + { + var tenant = TenantKeys.Single; + var sessionId = TestIds.Session("test-session"); + var ct = new CancellationTokenSource().Token; + + var store = new Mock(MockBehavior.Strict); + var storeFactory = new Mock(MockBehavior.Strict); + var authFlow = new Mock(MockBehavior.Strict); + + var flow = AuthFlowTestFactory.New(tenant: tenant); + + authFlow + .SetupGet(x => x.Current) + .Returns(flow); + + storeFactory + .Setup(x => x.Create(tenant)) + .Returns(store.Object); + + store + .Setup(x => x.GetSessionAsync(sessionId, ct)) + .ReturnsAsync((UAuthSession?)null); + + var sut = new UAuthSessionQueryService( + storeFactory.Object, + authFlow.Object); + + var result = await sut.GetSessionAsync(sessionId, ct); + + result.Should().BeNull(); + + storeFactory.Verify( + x => x.Create(tenant), + Times.Once); + + store.Verify( + x => x.GetSessionAsync(sessionId, ct), + Times.Once); + } + + [Fact] + public async Task GetSessionsByChainAsync_UsesCurrentTenant_AndDelegatesToStore() + { + var tenant = TenantKeys.Single; + var chainId = SessionChainId.New(); + var ct = new CancellationTokenSource().Token; + + IReadOnlyList expected = + Array.Empty(); + + var store = new Mock(MockBehavior.Strict); + var storeFactory = new Mock(MockBehavior.Strict); + var authFlow = new Mock(MockBehavior.Strict); + + authFlow + .SetupGet(x => x.Current) + .Returns(AuthFlowTestFactory.New(tenant: tenant)); + + storeFactory + .Setup(x => x.Create(tenant)) + .Returns(store.Object); + + store + .Setup(x => x.GetSessionsByChainAsync(chainId, ct)) + .ReturnsAsync(expected); + + var sut = new UAuthSessionQueryService( + storeFactory.Object, + authFlow.Object); + + var result = await sut.GetSessionsByChainAsync(chainId, ct); + + result.Should().BeSameAs(expected); + + storeFactory.Verify( + x => x.Create(tenant), + Times.Once); + + store.Verify( + x => x.GetSessionsByChainAsync(chainId, ct), + Times.Once); + } + + [Fact] + public async Task GetChainsByUserAsync_UsesCurrentTenant_AndDelegatesToStore() + { + var tenant = TenantKeys.Single; + var userKey = UserKey.New(); + var ct = new CancellationTokenSource().Token; + + IReadOnlyList expected = + Array.Empty(); + + var store = new Mock(MockBehavior.Strict); + var storeFactory = new Mock(MockBehavior.Strict); + var authFlow = new Mock(MockBehavior.Strict); + + authFlow + .SetupGet(x => x.Current) + .Returns(AuthFlowTestFactory.New(tenant: tenant)); + + storeFactory + .Setup(x => x.Create(tenant)) + .Returns(store.Object); + + store + .Setup(x => x.GetChainsByUserAsync( + userKey, + false, + ct)) + .ReturnsAsync(expected); + + var sut = new UAuthSessionQueryService( + storeFactory.Object, + authFlow.Object); + + var result = await sut.GetChainsByUserAsync(userKey, ct); + + result.Should().BeSameAs(expected); + + storeFactory.Verify( + x => x.Create(tenant), + Times.Once); + + store.Verify( + x => x.GetChainsByUserAsync( + userKey, + false, + ct), + Times.Once); + } + + [Fact] + public async Task ResolveChainIdAsync_UsesCurrentTenant_AndDelegatesToStore() + { + var tenant = TenantKeys.Single; + var sessionId = TestIds.Session("test-session"); + var expectedChainId = SessionChainId.New(); + var ct = new CancellationTokenSource().Token; + + var store = new Mock(MockBehavior.Strict); + var storeFactory = new Mock(MockBehavior.Strict); + var authFlow = new Mock(MockBehavior.Strict); + + authFlow + .SetupGet(x => x.Current) + .Returns(AuthFlowTestFactory.New(tenant: tenant)); + + storeFactory + .Setup(x => x.Create(tenant)) + .Returns(store.Object); + + store + .Setup(x => x.GetChainIdBySessionAsync(sessionId, ct)) + .ReturnsAsync(expectedChainId); + + var sut = new UAuthSessionQueryService( + storeFactory.Object, + authFlow.Object); + + var result = await sut.ResolveChainIdAsync(sessionId, ct); + + result.Should().Be(expectedChainId); + + storeFactory.Verify( + x => x.Create(tenant), + Times.Once); + + store.Verify( + x => x.GetChainIdBySessionAsync(sessionId, ct), + Times.Once); + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthSessionValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthSessionValidatorTests.cs new file mode 100644 index 00000000..9a0ca867 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthSessionValidatorTests.cs @@ -0,0 +1,603 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.Extensions.Options; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server; + +public sealed class UAuthSessionValidatorTests +{ + private static readonly DateTimeOffset Now = + new(2026, 9, 20, 12, 0, 0, TimeSpan.Zero); + + // --------------------------------------------------------------------- + // Session + // --------------------------------------------------------------------- + + [Fact] + public async Task ValidateSessionAsync_WhenSessionDoesNotExist_ReturnsNotFound() + { + var fixture = CreateFixture(); + + fixture.Store + .Setup(x => x.GetSessionAsync( + fixture.Session.SessionId, + It.IsAny())) + .ReturnsAsync((UAuthSession?)null); + + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + + result.IsValid.Should().BeFalse(); + result.State.Should().Be(SessionState.NotFound); + result.SessionId.Should().Be(fixture.Session.SessionId); + + fixture.Store.Verify( + x => x.GetChainAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + + fixture.ClaimsProvider.Verify( + x => x.GetClaimsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ValidateSessionAsync_WhenSessionIsExpired_ReturnsExpired() + { + var fixture = CreateFixture( + sessionExpiresAt: Now); + + SetupSession(fixture); + + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + + result.IsValid.Should().BeFalse(); + result.State.Should().Be(SessionState.Expired); + result.UserKey.Should().Be(fixture.User); + result.SessionId.Should().Be(fixture.Session.SessionId); + result.ChainId.Should().Be(fixture.Chain.ChainId); + + fixture.Store.Verify( + x => x.GetChainAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ValidateSessionAsync_WhenSessionIsRevoked_ReturnsRevoked() + { + var fixture = CreateFixture(); + + var revoked = fixture.Session.Revoke(Now.AddMinutes(-1)); + + fixture.Store + .Setup(x => x.GetSessionAsync( + fixture.Session.SessionId, + It.IsAny())) + .ReturnsAsync(revoked); + + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + + result.IsValid.Should().BeFalse(); + result.State.Should().Be(SessionState.Revoked); + result.UserKey.Should().Be(fixture.User); + result.SessionId.Should().Be(fixture.Session.SessionId); + result.ChainId.Should().Be(fixture.Chain.ChainId); + + fixture.Store.Verify( + x => x.GetChainAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + // --------------------------------------------------------------------- + // Chain + // --------------------------------------------------------------------- + + [Fact] + public async Task ValidateSessionAsync_WhenChainDoesNotExist_ReturnsRevoked() + { + var fixture = CreateFixture(); + + SetupSession(fixture); + + fixture.Store + .Setup(x => x.GetChainAsync( + fixture.Chain.ChainId, + It.IsAny())) + .ReturnsAsync((UAuthSessionChain?)null); + + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + + result.IsValid.Should().BeFalse(); + result.State.Should().Be(SessionState.Revoked); + result.UserKey.Should().Be(fixture.User); + result.SessionId.Should().Be(fixture.Session.SessionId); + result.ChainId.Should().Be(fixture.Chain.ChainId); + + fixture.Store.Verify( + x => x.GetRootByUserAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ValidateSessionAsync_WhenChainIsRevoked_ReturnsRevoked() + { + var fixture = CreateFixture(); + + var revokedChain = fixture.Chain.Revoke(Now.AddMinutes(-1)); + + SetupSession(fixture); + + fixture.Store + .Setup(x => x.GetChainAsync( + fixture.Chain.ChainId, + It.IsAny())) + .ReturnsAsync(revokedChain); + + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + + result.IsValid.Should().BeFalse(); + result.State.Should().Be(SessionState.Revoked); + + fixture.Store.Verify( + x => x.GetRootByUserAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ValidateSessionAsync_WhenChainIsAbsolutelyExpired_ReturnsExpired() + { + var fixture = CreateFixture( + chainExpiresAt: Now); + + SetupSessionAndChain(fixture); + + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + + result.IsValid.Should().BeFalse(); + result.State.Should().Be(SessionState.Expired); + + fixture.Store.Verify( + x => x.GetRootByUserAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ValidateSessionAsync_WhenChainIdleTimeoutIsExceeded_ReturnsExpired() + { + var fixture = CreateFixture( + chainCreatedAt: Now.AddHours(-2), + idleTimeout: TimeSpan.FromHours(1)); + + SetupSessionAndChain(fixture); + + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + + result.IsValid.Should().BeFalse(); + result.State.Should().Be(SessionState.Expired); + + fixture.Store.Verify( + x => x.GetRootByUserAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + // --------------------------------------------------------------------- + // Root + // --------------------------------------------------------------------- + + [Fact] + public async Task ValidateSessionAsync_WhenRootDoesNotExist_ReturnsRevoked() + { + var fixture = CreateFixture(); + + SetupSessionAndChain(fixture); + + fixture.Store + .Setup(x => x.GetRootByUserAsync( + fixture.User, + It.IsAny())) + .ReturnsAsync((UAuthSessionRoot?)null); + + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + + result.IsValid.Should().BeFalse(); + result.State.Should().Be(SessionState.Revoked); + result.RootId.Should().BeNull(); + + fixture.ClaimsProvider.Verify( + x => x.GetClaimsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ValidateSessionAsync_WhenRootIsRevoked_ReturnsRevoked() + { + var fixture = CreateFixture(); + + var revokedRoot = fixture.Root.Revoke(Now.AddMinutes(-1)); + + SetupSessionAndChain(fixture); + + fixture.Store + .Setup(x => x.GetRootByUserAsync( + fixture.User, + It.IsAny())) + .ReturnsAsync(revokedRoot); + + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + + result.IsValid.Should().BeFalse(); + result.State.Should().Be(SessionState.Revoked); + result.RootId.Should().Be(revokedRoot.RootId); + + fixture.ClaimsProvider.Verify( + x => x.GetClaimsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ValidateSessionAsync_WhenChainBelongsToDifferentRoot_ReturnsSecurityMismatch() + { + var fixture = CreateFixture(); + + var differentRoot = UAuthSessionRoot.Create( + fixture.Tenant, + fixture.User, + Now.AddDays(-2)); + + SetupSessionAndChain(fixture); + + fixture.Store + .Setup(x => x.GetRootByUserAsync( + fixture.User, + It.IsAny())) + .ReturnsAsync(differentRoot); + + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + + result.IsValid.Should().BeFalse(); + result.State.Should().Be(SessionState.SecurityMismatch); + result.RootId.Should().Be(differentRoot.RootId); + + fixture.ClaimsProvider.Verify( + x => x.GetClaimsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ValidateSessionAsync_WhenSecurityVersionDoesNotMatch_ReturnsSecurityMismatch() + { + var fixture = CreateFixture(); + + var updatedRoot = fixture.Root.IncreaseSecurityVersion( + Now.AddMinutes(-1)); + + SetupSessionAndChain(fixture); + + fixture.Store + .Setup(x => x.GetRootByUserAsync( + fixture.User, + It.IsAny())) + .ReturnsAsync(updatedRoot); + + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + + result.IsValid.Should().BeFalse(); + result.State.Should().Be(SessionState.SecurityMismatch); + result.RootId.Should().Be(updatedRoot.RootId); + + fixture.ClaimsProvider.Verify( + x => x.GetClaimsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + // --------------------------------------------------------------------- + // Device + // --------------------------------------------------------------------- + + [Fact] + public async Task ValidateSessionAsync_WhenDeviceDoesNotMatchAndBehaviorIsReject_ReturnsDeviceMismatch() + { + var fixture = CreateFixture( + requestDevice: TestDevice.Alternative(), + deviceMismatchBehavior: DeviceMismatchBehavior.Reject); + + SetupValidAggregate(fixture); + + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + + result.IsValid.Should().BeFalse(); + result.State.Should().Be(SessionState.DeviceMismatch); + result.UserKey.Should().Be(fixture.User); + result.SessionId.Should().Be(fixture.Session.SessionId); + result.ChainId.Should().Be(fixture.Chain.ChainId); + result.RootId.Should().Be(fixture.Root.RootId); + + fixture.ClaimsProvider.Verify( + x => x.GetClaimsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ValidateSessionAsync_WhenRequestHasNoDeviceId_AllowsValidation() + { + var fixture = CreateFixture( + requestDevice: DeviceContext.Anonymous()); + + var claims = ClaimsSnapshot.From( + ("uauth:permission", "orders.read")); + + SetupValidAggregate(fixture); + + fixture.ClaimsProvider + .Setup(x => x.GetClaimsAsync( + fixture.Tenant, + fixture.User, + It.IsAny())) + .ReturnsAsync(claims); + + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + + result.IsValid.Should().BeTrue(); + result.State.Should().Be(SessionState.Active); + } + + // --------------------------------------------------------------------- + // Success + // --------------------------------------------------------------------- + + [Fact] + public async Task ValidateSessionAsync_WhenAggregateIsValid_ReturnsActiveResult() + { + var fixture = CreateFixture(); + + var claims = ClaimsSnapshot.From( + ("uauth:permission", "orders.read"), + ("uauth:permission", "orders.write")); + + SetupValidAggregate(fixture); + + fixture.ClaimsProvider + .Setup(x => x.GetClaimsAsync( + fixture.Tenant, + fixture.User, + It.IsAny())) + .ReturnsAsync(claims); + + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context); + + result.IsValid.Should().BeTrue(); + result.State.Should().Be(SessionState.Active); + + result.Tenant.Should().Be(fixture.Tenant); + result.UserKey.Should().Be(fixture.User); + result.SessionId.Should().Be(fixture.Session.SessionId); + result.ChainId.Should().Be(fixture.Chain.ChainId); + result.RootId.Should().Be(fixture.Root.RootId); + + result.Claims.Should().Be(claims); + result.AuthenticatedAt.Should().Be(fixture.Session.CreatedAt); + result.BoundDeviceId.Should().Be(fixture.Chain.Device.DeviceId); + } + + [Fact] + public async Task ValidateSessionAsync_PropagatesCancellationToken() + { + var fixture = CreateFixture(); + + using var cts = new CancellationTokenSource(); + var ct = cts.Token; + + fixture.Store + .Setup(x => x.GetSessionAsync( + fixture.Session.SessionId, + ct)) + .ReturnsAsync(fixture.Session); + + fixture.Store + .Setup(x => x.GetChainAsync( + fixture.Chain.ChainId, + ct)) + .ReturnsAsync(fixture.Chain); + + fixture.Store + .Setup(x => x.GetRootByUserAsync( + fixture.User, + ct)) + .ReturnsAsync(fixture.Root); + + fixture.ClaimsProvider + .Setup(x => x.GetClaimsAsync( + fixture.Tenant, + fixture.User, + ct)) + .ReturnsAsync(ClaimsSnapshot.Empty); + + var result = await fixture.Sut.ValidateSessionAsync( + fixture.Context, + ct); + + result.IsValid.Should().BeTrue(); + + fixture.Store.Verify( + x => x.GetSessionAsync(fixture.Session.SessionId, ct), + Times.Once); + + fixture.Store.Verify( + x => x.GetChainAsync(fixture.Chain.ChainId, ct), + Times.Once); + + fixture.Store.Verify( + x => x.GetRootByUserAsync(fixture.User, ct), + Times.Once); + + fixture.ClaimsProvider.Verify( + x => x.GetClaimsAsync(fixture.Tenant, fixture.User, ct), + Times.Once); + } + + // --------------------------------------------------------------------- + // Setup + // --------------------------------------------------------------------- + + private static void SetupSession(Fixture fixture) + { + fixture.Store + .Setup(x => x.GetSessionAsync( + fixture.Session.SessionId, + It.IsAny())) + .ReturnsAsync(fixture.Session); + } + + private static void SetupSessionAndChain(Fixture fixture) + { + SetupSession(fixture); + + fixture.Store + .Setup(x => x.GetChainAsync( + fixture.Chain.ChainId, + It.IsAny())) + .ReturnsAsync(fixture.Chain); + } + + private static void SetupValidAggregate(Fixture fixture) + { + SetupSessionAndChain(fixture); + + fixture.Store + .Setup(x => x.GetRootByUserAsync( + fixture.User, + It.IsAny())) + .ReturnsAsync(fixture.Root); + } + + private static Fixture CreateFixture( + DateTimeOffset? sessionExpiresAt = null, + DateTimeOffset? chainCreatedAt = null, + DateTimeOffset? chainExpiresAt = null, + TimeSpan? idleTimeout = null, + DeviceContext? requestDevice = null, + DeviceMismatchBehavior deviceMismatchBehavior = + DeviceMismatchBehavior.Reject) + { + var tenant = TenantKey.Single; + var user = UserKey.New(); + + var root = UAuthSessionRoot.Create( + tenant, + user, + Now.AddDays(-1)); + + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + root.RootId, + tenant, + user, + chainCreatedAt ?? Now.AddHours(-1), + chainExpiresAt ?? Now.AddDays(7), + TestDevice.Default(), + ClaimsSnapshot.Empty, + root.SecurityVersion); + + var session = UAuthSession.Create( + TestIds.Session("test-session"), + tenant, + user, + chain.ChainId, + Now.AddMinutes(-30), + sessionExpiresAt ?? Now.AddHours(8), + root.SecurityVersion, + TestDevice.Default(), + ClaimsSnapshot.Empty, + SessionMetadata.Empty); + + var store = new Mock(MockBehavior.Strict); + + var storeFactory = + new Mock(MockBehavior.Strict); + + storeFactory + .Setup(x => x.Create(tenant)) + .Returns(store.Object); + + var claimsProvider = + new Mock(MockBehavior.Strict); + + var options = new UAuthServerOptions(); + + options.Session.IdleTimeout = idleTimeout; + options.Session.DeviceMismatchBehavior = deviceMismatchBehavior; + + var sut = new UAuthSessionValidator( + storeFactory.Object, + claimsProvider.Object, + Options.Create(options)); + + var context = new SessionValidationContext + { + Tenant = tenant, + SessionId = session.SessionId, + Now = Now, + Device = requestDevice ?? TestDevice.Default() + }; + + return new Fixture( + sut, + store, + claimsProvider, + tenant, + user, + root, + chain, + session, + context); + } + + private sealed record Fixture( + UAuthSessionValidator Sut, + Mock Store, + Mock ClaimsProvider, + TenantKey Tenant, + UserKey User, + UAuthSessionRoot Root, + UAuthSessionChain Chain, + UAuthSession Session, + SessionValidationContext Context); +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ValidateEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ValidateEndpointHandlerTests.cs new file mode 100644 index 00000000..0784187f --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ValidateEndpointHandlerTests.cs @@ -0,0 +1,539 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Contracts; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server; + +public sealed class ValidateEndpointHandlerTests +{ + private static readonly DateTimeOffset Now = + new(2026, 9, 20, 12, 0, 0, TimeSpan.Zero); + + // ===================================================================== + // Missing / unsupported credential + // ===================================================================== + + [Fact] + public async Task ValidateAsync_WhenCredentialIsMissing_ReturnsUnauthorizedNotFound() + { + var fixture = CreateFixture(); + + fixture.CredentialResolver + .Setup(x => x.ResolveAsync( + fixture.HttpContext, + fixture.Flow.Response)) + .ReturnsAsync((ResolvedCredential?)null); + + var result = await fixture.Sut.ValidateAsync( + fixture.HttpContext, + fixture.CancellationToken); + + var json = result.Should() + .BeOfType>() + .Subject; + + json.StatusCode.Should() + .Be(StatusCodes.Status401Unauthorized); + + json.Value.Should().NotBeNull(); + json.Value!.State.Should() + .Be(SessionState.NotFound); + + json.Value.Snapshot.Should().BeNull(); + + fixture.SessionValidator.VerifyNoOtherCalls(); + fixture.SnapshotFactory.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ValidateAsync_WhenCredentialKindIsAccessToken_ReturnsUnauthorizedUnsupported() + { + var fixture = CreateFixture(); + + var credential = new ResolvedCredential + { + Kind = PrimaryTokenKind.AccessToken, + Value = "access-token", + Tenant = fixture.Flow.Tenant, + Device = TestDevice.DefaultDeviceInfo() + }; + + fixture.CredentialResolver + .Setup(x => x.ResolveAsync( + fixture.HttpContext, + fixture.Flow.Response)) + .ReturnsAsync(credential); + + var result = await fixture.Sut.ValidateAsync( + fixture.HttpContext, + fixture.CancellationToken); + + var json = result.Should() + .BeOfType>() + .Subject; + + json.StatusCode.Should() + .Be(StatusCodes.Status401Unauthorized); + + json.Value.Should().NotBeNull(); + json.Value!.State.Should() + .Be(SessionState.Unsupported); + + json.Value.Snapshot.Should().BeNull(); + + fixture.SessionValidator.VerifyNoOtherCalls(); + fixture.SnapshotFactory.VerifyNoOtherCalls(); + } + + // ===================================================================== + // Invalid session credential + // ===================================================================== + + [Fact] + public async Task ValidateAsync_WhenSessionCredentialCannotBeParsed_ReturnsUnauthorizedInvalid() + { + var fixture = CreateFixture(); + + var credential = new ResolvedCredential + { + Kind = PrimaryTokenKind.Session, + Value = "invalid", + Tenant = fixture.Flow.Tenant, + Device = TestDevice.DefaultDeviceInfo() + }; + + fixture.CredentialResolver + .Setup(x => x.ResolveAsync( + fixture.HttpContext, + fixture.Flow.Response)) + .ReturnsAsync(credential); + + var result = await fixture.Sut.ValidateAsync( + fixture.HttpContext, + fixture.CancellationToken); + + var json = result.Should() + .BeOfType>() + .Subject; + + json.StatusCode.Should() + .Be(StatusCodes.Status401Unauthorized); + + json.Value.Should().NotBeNull(); + json.Value!.State.Should() + .Be(SessionState.Invalid); + + json.Value.Snapshot.Should().BeNull(); + + fixture.SessionValidator.VerifyNoOtherCalls(); + fixture.SnapshotFactory.VerifyNoOtherCalls(); + } + + // ===================================================================== + // Session validation context + // ===================================================================== + + [Fact] + public async Task ValidateAsync_WhenSessionCredentialIsValid_PassesExpectedContextToValidator() + { + var fixture = CreateFixture(); + + var sessionId = + TestIds.Session("validate-session"); + + SetupSessionCredential( + fixture, + sessionId); + + SessionValidationContext? capturedContext = null; + CancellationToken capturedToken = default; + + var validation = CreateActiveValidation( + fixture.Flow.Tenant, + sessionId); + + fixture.SessionValidator + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + fixture.CancellationToken)) + .Callback( + (context, ct) => + { + capturedContext = context; + capturedToken = ct; + }) + .ReturnsAsync(validation); + + fixture.SnapshotFactory + .Setup(x => x.CreateAsync( + validation, + It.IsAny())) + .ReturnsAsync((AuthStateSnapshot?)null); + + await fixture.Sut.ValidateAsync( + fixture.HttpContext, + fixture.CancellationToken); + + capturedContext.Should().NotBeNull(); + + capturedContext!.SessionId.Should() + .Be(sessionId); + + capturedContext.Tenant.Should() + .Be(fixture.Flow.Tenant); + + capturedContext.Now.Should() + .Be(Now); + + capturedContext.Device.Should() + .Be(fixture.Flow.Device); + + capturedToken.Should() + .Be(fixture.CancellationToken); + } + + // ===================================================================== + // Missing UserKey + // ===================================================================== + + [Fact] + public async Task ValidateAsync_WhenValidationHasNoUserKey_ReturnsUnauthorizedInvalid() + { + var fixture = CreateFixture(); + + var sessionId = + TestIds.Session("missing-user-session"); + + SetupSessionCredential( + fixture, + sessionId); + + var validation = + SessionValidationResult.Invalid( + SessionState.Invalid, + userId: null, + sessionId: sessionId); + + fixture.SessionValidator + .Setup(x => x.ValidateSessionAsync( + It.Is( + c => c.SessionId == sessionId), + fixture.CancellationToken)) + .ReturnsAsync(validation); + + var result = await fixture.Sut.ValidateAsync( + fixture.HttpContext, + fixture.CancellationToken); + + var json = result.Should() + .BeOfType>() + .Subject; + + json.StatusCode.Should() + .Be(StatusCodes.Status401Unauthorized); + + json.Value.Should().NotBeNull(); + json.Value!.State.Should() + .Be(SessionState.Invalid); + + json.Value.Snapshot.Should().BeNull(); + + fixture.SnapshotFactory.Verify( + x => x.CreateAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + // ===================================================================== + // Successful validation + // ===================================================================== + + [Fact] + public async Task ValidateAsync_WhenSessionIsValid_ReturnsOkActiveWithSnapshot() + { + var fixture = CreateFixture(); + + var sessionId = + TestIds.Session("active-session"); + + SetupSessionCredential( + fixture, + sessionId); + + var validation = CreateActiveValidation( + fixture.Flow.Tenant, + sessionId); + + var snapshot = CreateSnapshot( + fixture.Flow.Tenant, + validation.UserKey!.Value); + + fixture.SessionValidator + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + fixture.CancellationToken)) + .ReturnsAsync(validation); + + fixture.SnapshotFactory + .Setup(x => x.CreateAsync( + validation, + It.IsAny())) + .ReturnsAsync(snapshot); + + var result = await fixture.Sut.ValidateAsync( + fixture.HttpContext, + fixture.CancellationToken); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().NotBeNull(); + + ok.Value!.State.Should() + .Be(SessionState.Active); + + ok.Value.Snapshot.Should() + .BeSameAs(snapshot); + + fixture.SnapshotFactory.Verify(x => x.CreateAsync( + validation, + It.IsAny()), + Times.Once); + } + + // ===================================================================== + // Current invalid-state behavior + // ===================================================================== + + [Fact] + public async Task ValidateAsync_WhenValidationIsInvalidButContainsUserKey_ReturnsOkWithValidationState() + { + var fixture = CreateFixture(); + + var sessionId = + TestIds.Session("revoked-session"); + + var userKey = + UserKey.New(); + + SetupSessionCredential( + fixture, + sessionId); + + var validation = + SessionValidationResult.Invalid( + SessionState.Revoked, + userId: userKey, + sessionId: sessionId); + + fixture.SessionValidator + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + fixture.CancellationToken)) + .ReturnsAsync(validation); + + fixture.SnapshotFactory + .Setup(x => x.CreateAsync( + validation, + It.IsAny())) + .ReturnsAsync((AuthStateSnapshot?)null); + + var result = await fixture.Sut.ValidateAsync( + fixture.HttpContext, + fixture.CancellationToken); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().NotBeNull(); + + ok.Value!.State.Should() + .Be(SessionState.Revoked); + + ok.Value.Snapshot.Should().BeNull(); + + fixture.SnapshotFactory.Verify(x => x.CreateAsync( + validation, + It.IsAny()), + Times.Once); + } + + // ===================================================================== + // CancellationToken + // ===================================================================== + + [Fact] + public async Task ValidateAsync_PropagatesCancellationTokenToSessionValidator() + { + var fixture = CreateFixture(); + + var sessionId = + TestIds.Session("cancellation-session"); + + SetupSessionCredential( + fixture, + sessionId); + + var validation = CreateActiveValidation( + fixture.Flow.Tenant, + sessionId); + + fixture.SessionValidator + .Setup(x => x.ValidateSessionAsync( + It.IsAny(), + fixture.CancellationToken)) + .ReturnsAsync(validation); + + fixture.SnapshotFactory + .Setup(x => x.CreateAsync( + validation, + It.IsAny())) + .ReturnsAsync((AuthStateSnapshot?)null); + + await fixture.Sut.ValidateAsync( + fixture.HttpContext, + fixture.CancellationToken); + + fixture.SessionValidator.Verify( + x => x.ValidateSessionAsync( + It.IsAny(), + fixture.CancellationToken), + Times.Once); + } + + // ===================================================================== + // Helpers + // ===================================================================== + + private static Fixture CreateFixture() + { + var flow = + AuthFlowTestFactory.LoginSuccess(); + + var authContext = + new Mock( + MockBehavior.Strict); + + var credentialResolver = + new Mock( + MockBehavior.Strict); + + var sessionValidator = + new Mock( + MockBehavior.Strict); + + var snapshotFactory = + new Mock( + MockBehavior.Strict); + + var clock = + new Mock( + MockBehavior.Strict); + + authContext + .SetupGet(x => x.Current) + .Returns(flow); + + clock + .SetupGet(x => x.UtcNow) + .Returns(Now); + + var httpContext = + new DefaultHttpContext(); + + httpContext.Items[UAuthConstants.HttpItems.TenantContextKey] = UAuthTenantContext.Resolved(flow.Tenant); + + var cancellationToken = new CancellationTokenSource().Token; + + var sut = new ValidateEndpointHandler( + authContext.Object, + credentialResolver.Object, + sessionValidator.Object, + snapshotFactory.Object, + clock.Object); + + return new Fixture( + sut, + flow, + credentialResolver, + sessionValidator, + snapshotFactory, + httpContext, + cancellationToken); + } + + private static void SetupSessionCredential( + Fixture fixture, + AuthSessionId sessionId) + { + fixture.CredentialResolver + .Setup(x => x.ResolveAsync( + fixture.HttpContext, + fixture.Flow.Response)) + .ReturnsAsync( + new ResolvedCredential + { + Kind = PrimaryTokenKind.Session, + Value = sessionId.Value, + Tenant = fixture.Flow.Tenant, + Device = TestDevice.DefaultDeviceInfo() + }); + } + + private static SessionValidationResult CreateActiveValidation( + TenantKey tenant, + AuthSessionId sessionId) + { + return SessionValidationResult.Active( + tenant: tenant, + userKey: UserKey.New(), + sessionId: sessionId, + chainId: SessionChainId.New(), + rootId: SessionRootId.New(), + claims: ClaimsSnapshot.Empty, + authenticatedAt: Now); + } + + private static AuthStateSnapshot CreateSnapshot( + TenantKey tenant, + UserKey userKey) + { + return new AuthStateSnapshot + { + Identity = new AuthIdentitySnapshot + { + UserKey = userKey, + Tenant = tenant, + AuthenticatedAt = Now, + SessionState = SessionState.Active, + UserStatus = UserStatus.Unknown + }, + Claims = ClaimsSnapshot.Empty + }; + } + + private sealed record Fixture( + ValidateEndpointHandler Sut, + AuthFlowContext Flow, + Mock CredentialResolver, + Mock SessionValidator, + Mock SnapshotFactory, + DefaultHttpContext HttpContext, + CancellationToken CancellationToken); +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/EfCoreSessionStoreChainContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/EfCoreSessionStoreChainContractTests.cs new file mode 100644 index 00000000..9a050bb6 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/EfCoreSessionStoreChainContractTests.cs @@ -0,0 +1,62 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Sessions.Contracts; + +public sealed class EfCoreSessionStoreChainContractTests + : SessionStoreChainContractTests +{ + protected override async Task + CreateDatabaseAsync() + { + var database = new Database(); + + await database.InitializeAsync(); + + return database; + } + + private sealed class Database + : ISessionStoreTestDatabase + { + private readonly SqliteConnection _connection; + private readonly UAuthSessionDbContext _db; + + public Database() + { + _connection = new SqliteConnection( + "Data Source=:memory:"); + + var options = + new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _db = new UAuthSessionDbContext(options); + } + + public async Task InitializeAsync() + { + await _connection.OpenAsync(); + + await _db.Database.EnsureCreatedAsync(); + } + + public ISessionStore CreateStore( + TenantKey tenant) + { + return new EfCoreSessionStore( + _db, + new TenantExecutionContext(tenant)); + } + + public async ValueTask DisposeAsync() + { + await _db.DisposeAsync(); + await _connection.DisposeAsync(); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/EfCoreSessionStoreLifecycleContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/EfCoreSessionStoreLifecycleContractTests.cs new file mode 100644 index 00000000..0ab1778b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/EfCoreSessionStoreLifecycleContractTests.cs @@ -0,0 +1,55 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Tests.Unit.Sessions.Contracts; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +public sealed class EfCoreSessionStoreLifecycleContractTests + : SessionStoreLifecycleContractTests +{ + protected override async Task + CreateDatabaseAsync() + { + var database = new Database(); + await database.InitializeAsync(); + return database; + } + + private sealed class Database + : ISessionStoreTestDatabase + { + private readonly SqliteConnection _connection; + private readonly UAuthSessionDbContext _db; + + public Database() + { + _connection = + new SqliteConnection("Data Source=:memory:"); + + var options = + new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _db = new UAuthSessionDbContext(options); + } + + public async Task InitializeAsync() + { + await _connection.OpenAsync(); + await _db.Database.EnsureCreatedAsync(); + } + + public ISessionStore CreateStore(TenantKey tenant) + => new EfCoreSessionStore( + _db, + new TenantExecutionContext(tenant)); + + public async ValueTask DisposeAsync() + { + await _db.DisposeAsync(); + await _connection.DisposeAsync(); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/EfCoreSessionStoreRootContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/EfCoreSessionStoreRootContractTests.cs new file mode 100644 index 00000000..14ff40a9 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/EfCoreSessionStoreRootContractTests.cs @@ -0,0 +1,62 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Sessions.Contracts; + +public sealed class EfCoreSessionStoreRootContractTests + : SessionStoreRootContractTests +{ + protected override async Task + CreateDatabaseAsync() + { + var database = new Database(); + await database.InitializeAsync(); + return database; + } + + private sealed class Database + : ISessionStoreTestDatabase + { + private readonly SqliteConnection _connection; + private readonly UAuthSessionDbContext _db; + + public Database() + { + _connection = new SqliteConnection( + "Data Source=:memory:"); + + var options = + new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _db = + new UAuthSessionDbContext(options); + } + + public async Task InitializeAsync() + { + await _connection.OpenAsync(); + + await _db.Database + .EnsureCreatedAsync(); + } + + public ISessionStore CreateStore( + TenantKey tenant) + { + return new EfCoreSessionStore( + _db, + new TenantExecutionContext(tenant)); + } + + public async ValueTask DisposeAsync() + { + await _db.DisposeAsync(); + await _connection.DisposeAsync(); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/EfCoreSessionStoreSessionContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/EfCoreSessionStoreSessionContractTests.cs new file mode 100644 index 00000000..f534ac6f --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/EfCoreSessionStoreSessionContractTests.cs @@ -0,0 +1,59 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Sessions.Contracts; + +public sealed class EfCoreSessionStoreSessionContractTests + : SessionStoreSessionContractTests +{ + protected override async Task + CreateDatabaseAsync() + { + var database = new Database(); + await database.InitializeAsync(); + return database; + } + + private sealed class Database + : ISessionStoreTestDatabase + { + private readonly SqliteConnection _connection; + private readonly UAuthSessionDbContext _db; + + public Database() + { + _connection = new SqliteConnection( + "Data Source=:memory:"); + + var options = + new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _db = new UAuthSessionDbContext(options); + } + + public async Task InitializeAsync() + { + await _connection.OpenAsync(); + await _db.Database.EnsureCreatedAsync(); + } + + public ISessionStore CreateStore(TenantKey tenant) + { + return new EfCoreSessionStore( + _db, + new TenantExecutionContext(tenant)); + } + + public async ValueTask DisposeAsync() + { + await _db.DisposeAsync(); + await _connection.DisposeAsync(); + } + } +} + diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/ISessionStoreTestDatabase.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/ISessionStoreTestDatabase.cs new file mode 100644 index 00000000..f90fdd00 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/ISessionStoreTestDatabase.cs @@ -0,0 +1,9 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Sessions.Contracts; + +public interface ISessionStoreTestDatabase : IAsyncDisposable +{ + ISessionStore CreateStore(TenantKey tenant); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/InMemorySessionStoreChainContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/InMemorySessionStoreChainContractTests.cs new file mode 100644 index 00000000..919b77dc --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/InMemorySessionStoreChainContractTests.cs @@ -0,0 +1,29 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Sessions.InMemory; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Sessions.Contracts; + +public sealed class InMemorySessionStoreChainContractTests + : SessionStoreChainContractTests +{ + protected override Task + CreateDatabaseAsync() + { + return Task.FromResult( + new Database()); + } + + private sealed class Database + : ISessionStoreTestDatabase + { + private readonly InMemorySessionStoreFactory _factory = + new(); + + public ISessionStore CreateStore(TenantKey tenant) + => _factory.Create(tenant); + + public ValueTask DisposeAsync() + => ValueTask.CompletedTask; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/InMemorySessionStoreLifecycleContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/InMemorySessionStoreLifecycleContractTests.cs new file mode 100644 index 00000000..94af509e --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/InMemorySessionStoreLifecycleContractTests.cs @@ -0,0 +1,25 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Sessions.InMemory; +using CodeBeam.UltimateAuth.Tests.Unit.Sessions.Contracts; + +public sealed class InMemorySessionStoreLifecycleContractTests + : SessionStoreLifecycleContractTests +{ + protected override Task + CreateDatabaseAsync() + => Task.FromResult( + new Database()); + + private sealed class Database + : ISessionStoreTestDatabase + { + private readonly InMemorySessionStoreFactory _factory = new(); + + public ISessionStore CreateStore(TenantKey tenant) + => _factory.Create(tenant); + + public ValueTask DisposeAsync() + => ValueTask.CompletedTask; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/InMemorySessionStoreRootContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/InMemorySessionStoreRootContractTests.cs new file mode 100644 index 00000000..ff5245b5 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/InMemorySessionStoreRootContractTests.cs @@ -0,0 +1,29 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Sessions.InMemory; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Sessions.Contracts; + +public sealed class InMemorySessionStoreRootContractTests + : SessionStoreRootContractTests +{ + protected override Task + CreateDatabaseAsync() + { + return Task.FromResult( + new Database()); + } + + private sealed class Database + : ISessionStoreTestDatabase + { + private readonly InMemorySessionStoreFactory _factory = + new(); + + public ISessionStore CreateStore(TenantKey tenant) + => _factory.Create(tenant); + + public ValueTask DisposeAsync() + => ValueTask.CompletedTask; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/InMemorySessionStoreSessionContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/InMemorySessionStoreSessionContractTests.cs new file mode 100644 index 00000000..926d4b4f --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/InMemorySessionStoreSessionContractTests.cs @@ -0,0 +1,24 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Sessions.InMemory; +using CodeBeam.UltimateAuth.Tests.Unit.Sessions.Contracts; + +public sealed class InMemorySessionStoreSessionContractTests : SessionStoreSessionContractTests +{ + protected override Task CreateDatabaseAsync() + { + return Task.FromResult(new Database()); + } + + private sealed class Database : ISessionStoreTestDatabase + { + private readonly InMemorySessionStoreFactory _factory = + new(); + + public ISessionStore CreateStore(TenantKey tenant) + => _factory.Create(tenant); + + public ValueTask DisposeAsync() + => ValueTask.CompletedTask; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreChainContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreChainContractTests.cs new file mode 100644 index 00000000..a03cec9f --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreChainContractTests.cs @@ -0,0 +1,528 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Sessions.Contracts; + +public abstract class SessionStoreChainContractTests +{ + protected abstract Task CreateDatabaseAsync(); + + protected static readonly TenantKey Tenant = + TenantKey.FromExternal("tenant-a"); + + protected static readonly DateTimeOffset Now = + new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + protected static async Task<( + UAuthSessionRoot Root, + UAuthSessionChain Chain)> + CreateChainGraphAsync( + ISessionStore store, + TenantKey tenant, + UserKey? userKey = null) + { + var user = userKey ?? UserKey.New(); + + var root = UAuthSessionRoot.Create( + tenant, + user, + Now); + + await store.ExecuteAsync( + ct => store.CreateRootAsync(root, ct)); + + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + root.RootId, + tenant, + user, + Now, + Now.AddDays(30), + TestDevice.Default(), + ClaimsSnapshot.Empty, + root.SecurityVersion); + + await store.ExecuteAsync( + ct => store.CreateChainAsync(chain, ct)); + + return (root, chain); + } + + private static UAuthSessionChain CreateChain( + UAuthSessionRoot root, + TenantKey tenant, + UserKey user) + { + return UAuthSessionChain.Create( + SessionChainId.New(), + root.RootId, + tenant, + user, + Now, + Now.AddDays(30), + TestDevice.Default(), + ClaimsSnapshot.Empty, + root.SecurityVersion); + } + + // ------------------------------------------------------------ + // CREATE / GET + // ------------------------------------------------------------ + + [Fact] + public async Task CreateChainAsync_PersistsChain() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var (root, chain) = + await CreateChainGraphAsync(store, Tenant); + + var result = + await store.GetChainAsync(chain.ChainId); + + result.Should().NotBeNull(); + + result!.ChainId.Should().Be(chain.ChainId); + result.RootId.Should().Be(root.RootId); + result.Tenant.Should().Be(Tenant); + result.UserKey.Should().Be(chain.UserKey); + result.CreatedAt.Should().Be(Now); + result.SecurityVersionAtCreation + .Should().Be(root.SecurityVersion); + result.ActiveSessionId.Should().BeNull(); + result.IsRevoked.Should().BeFalse(); + result.Version.Should().Be(0); + } + + [Fact] + public async Task GetChainAsync_WhenMissing_ReturnsNull() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var result = + await store.GetChainAsync( + SessionChainId.New()); + + result.Should().BeNull(); + } + + [Fact] + public async Task CreateChainAsync_WhenVersionIsNotZero_IsRejected() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var user = UserKey.New(); + + var root = UAuthSessionRoot.Create( + Tenant, + user, + Now); + + await store.ExecuteAsync( + ct => store.CreateRootAsync(root, ct)); + + var chain = CreateChain( + root, + Tenant, + user); + + chain.Version = 1; + + var act = () => store.ExecuteAsync( + ct => store.CreateChainAsync(chain, ct)); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task CreateChainAsync_WhenIdAlreadyExists_ThrowsConcurrency() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var (root, first) = + await CreateChainGraphAsync( + store, + Tenant); + + var second = UAuthSessionChain.Create( + first.ChainId, + root.RootId, + Tenant, + first.UserKey, + Now.AddMinutes(1), + Now.AddDays(30), + TestDevice.Default(), + ClaimsSnapshot.Empty, + root.SecurityVersion); + + var act = () => store.ExecuteAsync( + ct => store.CreateChainAsync( + second, + ct)); + + await act.Should() + .ThrowAsync(); + } + + // ------------------------------------------------------------ + // SAVE + // ------------------------------------------------------------ + + [Fact] + public async Task SaveChainAsync_WhenExpectedVersionMatches_PersistsChanges() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var (_, chain) = + await CreateChainGraphAsync( + store, + Tenant); + + var updated = + chain.Touch( + Now.AddMinutes(10)); + + await store.ExecuteAsync( + ct => store.SaveChainAsync( + updated, + expectedVersion: chain.Version, + ct)); + + var result = + await store.GetChainAsync( + chain.ChainId); + + result.Should().NotBeNull(); + result!.LastSeenAt.Should().Be( + Now.AddMinutes(10)); + result.TouchCount.Should().Be(1); + result.Version.Should().Be(1); + } + + [Fact] + public async Task SaveChainAsync_WhenMissing_ThrowsNotFound() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var user = UserKey.New(); + + var root = UAuthSessionRoot.Create( + Tenant, + user, + Now); + + var chain = CreateChain( + root, + Tenant, + user); + + var updated = + chain.Touch(Now.AddMinutes(10)); + + var act = () => store.ExecuteAsync( + ct => store.SaveChainAsync( + updated, + expectedVersion: chain.Version, + ct)); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task SaveChainAsync_WhenExpectedVersionIsStale_ThrowsConcurrency() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var (_, chain) = + await CreateChainGraphAsync( + store, + Tenant); + + var updated = + chain.Touch(Now.AddMinutes(10)); + + var act = () => store.ExecuteAsync( + ct => store.SaveChainAsync( + updated, + expectedVersion: 999, + ct)); + + await act.Should() + .ThrowAsync(); + } + + // ------------------------------------------------------------ + // REVOKE + // ------------------------------------------------------------ + + [Fact] + public async Task RevokeChainAsync_RevokesChain() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var (_, chain) = + await CreateChainGraphAsync( + store, + Tenant); + + await store.ExecuteAsync( + ct => store.RevokeChainAsync( + chain.ChainId, + Now.AddMinutes(10), + ct)); + + var result = + await store.GetChainAsync( + chain.ChainId); + + result.Should().NotBeNull(); + result!.IsRevoked.Should().BeTrue(); + result.RevokedAt.Should().Be( + Now.AddMinutes(10)); + result.Version.Should().Be(1); + } + + [Fact] + public async Task RevokeChainAsync_WhenMissing_IsIdempotent() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var act = () => store.ExecuteAsync( + ct => store.RevokeChainAsync( + SessionChainId.New(), + Now, + ct)); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task RevokeChainAsync_WhenAlreadyRevoked_DoesNotChangeVersionAgain() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var (_, chain) = + await CreateChainGraphAsync( + store, + Tenant); + + await store.ExecuteAsync( + ct => store.RevokeChainAsync( + chain.ChainId, + Now.AddMinutes(5), + ct)); + + await store.ExecuteAsync( + ct => store.RevokeChainAsync( + chain.ChainId, + Now.AddMinutes(10), + ct)); + + var result = + await store.GetChainAsync( + chain.ChainId); + + result.Should().NotBeNull(); + result!.RevokedAt.Should().Be( + Now.AddMinutes(5)); + result.Version.Should().Be(1); + } + + // ------------------------------------------------------------ + // QUERY + // ------------------------------------------------------------ + + [Fact] + public async Task GetChainsByRootAsync_ReturnsOnlyRequestedRoot() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var userA = UserKey.New(); + var userB = UserKey.New(); + + var (rootA, chainA) = + await CreateChainGraphAsync( + store, + Tenant, + userA); + + var secondChainA = + CreateChain( + rootA, + Tenant, + userA); + + await store.ExecuteAsync( + ct => store.CreateChainAsync( + secondChainA, + ct)); + + var (_, chainB) = + await CreateChainGraphAsync( + store, + Tenant, + userB); + + var result = + await store.GetChainsByRootAsync( + rootA.RootId); + + result.Should().HaveCount(2); + + result.Select(x => x.ChainId) + .Should() + .BeEquivalentTo([ + chainA.ChainId, + secondChainA.ChainId + ]); + + result.Should() + .NotContain(x => + x.ChainId == chainB.ChainId); + } + + [Fact] + public async Task GetChainsByUserAsync_ReturnsUsersChains() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var user = UserKey.New(); + + var (root, first) = + await CreateChainGraphAsync( + store, + Tenant, + user); + + var second = + CreateChain( + root, + Tenant, + user); + + await store.ExecuteAsync( + ct => store.CreateChainAsync( + second, + ct)); + + var result = + await store.GetChainsByUserAsync(user); + + result.Select(x => x.ChainId) + .Should() + .BeEquivalentTo([ + first.ChainId, + second.ChainId + ]); + } + + // ------------------------------------------------------------ + // TENANT + // ------------------------------------------------------------ + + [Fact] + public async Task GetChainAsync_IsTenantIsolated() + { + await using var db = await CreateDatabaseAsync(); + + var tenantA = + TenantKey.FromExternal("tenant-a"); + + var tenantB = + TenantKey.FromExternal("tenant-b"); + + var storeA = db.CreateStore(tenantA); + var storeB = db.CreateStore(tenantB); + + var (_, chain) = + await CreateChainGraphAsync( + storeA, + tenantA); + + var fromA = + await storeA.GetChainAsync( + chain.ChainId); + + var fromB = + await storeB.GetChainAsync( + chain.ChainId); + + fromA.Should().NotBeNull(); + fromB.Should().BeNull(); + } + + [Fact] + public async Task CreateChainAsync_WhenChainBelongsToDifferentTenant_IsRejected() + { + await using var db = await CreateDatabaseAsync(); + + var tenantA = + TenantKey.FromExternal("tenant-a"); + + var tenantB = + TenantKey.FromExternal("tenant-b"); + + var storeA = db.CreateStore(tenantA); + + var rootB = UAuthSessionRoot.Create( + tenantB, + UserKey.New(), + Now); + + var chainB = CreateChain( + rootB, + tenantB, + rootB.UserKey); + + var act = () => storeA.ExecuteAsync( + ct => storeA.CreateChainAsync( + chainB, + ct)); + + await act.Should() + .ThrowAsync() + .WithMessage("Tenant mismatch."); + } + + // ------------------------------------------------------------ + // CANCELLATION + // ------------------------------------------------------------ + + [Fact] + public async Task GetChainAsync_WhenCancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + using var cts = + new CancellationTokenSource(); + + cts.Cancel(); + + var act = () => + store.GetChainAsync( + SessionChainId.New(), + cts.Token); + + await act.Should() + .ThrowAsync(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreLifecycleContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreLifecycleContractTests.cs new file mode 100644 index 00000000..903be7eb --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreLifecycleContractTests.cs @@ -0,0 +1,1127 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Sessions.Contracts; + +public abstract class SessionStoreLifecycleContractTests +{ + protected abstract Task CreateDatabaseAsync(); + + protected static readonly TenantKey Tenant = + TenantKey.FromExternal("tenant-a"); + + protected static readonly DateTimeOffset Now = + new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + /* + User A + └── Root A + β”œβ”€β”€ Chain A1 + β”‚ β”œβ”€β”€ Session A11 + β”‚ └── Session A12 [ACTIVE] + β”‚ + └── Chain A2 + └── Session A21 [ACTIVE] + + User B + └── Root B + └── Chain B1 + └── Session B11 [ACTIVE] + */ + + protected sealed record LifecycleGraph( + UserKey UserA, + UAuthSessionRoot RootA, + UAuthSessionChain ChainA1, + UAuthSession SessionA11, + UAuthSession SessionA12, + UAuthSessionChain ChainA2, + UAuthSession SessionA21, + + UserKey UserB, + UAuthSessionRoot RootB, + UAuthSessionChain ChainB1, + UAuthSession SessionB11); + + // ============================================================ + // GRAPH + // ============================================================ + + protected static async Task CreateGraphAsync( + ISessionStore store) + { + var userA = UserKey.New(); + var userB = UserKey.New(); + + var rootA = UAuthSessionRoot.Create( + Tenant, + userA, + Now); + + var rootB = UAuthSessionRoot.Create( + Tenant, + userB, + Now); + + await store.ExecuteAsync(async ct => + { + await store.CreateRootAsync(rootA, ct); + await store.CreateRootAsync(rootB, ct); + }); + + var chainA1 = CreateChain( + rootA, + userA, + Now.AddMinutes(1)); + + var chainA2 = CreateChain( + rootA, + userA, + Now.AddMinutes(2), + TestDevice.Alternative()); + + var chainB1 = CreateChain( + rootB, + userB, + Now.AddMinutes(3)); + + await store.ExecuteAsync(async ct => + { + await store.CreateChainAsync(chainA1, ct); + await store.CreateChainAsync(chainA2, ct); + await store.CreateChainAsync(chainB1, ct); + }); + + var sessionA11 = CreateSession( + chainA1, + userA, + "lifecycle-session-a11", + Now.AddMinutes(4)); + + var sessionA12 = CreateSession( + chainA1, + userA, + "lifecycle-session-a12", + Now.AddMinutes(5)); + + var sessionA21 = CreateSession( + chainA2, + userA, + "lifecycle-session-a21", + Now.AddMinutes(6)); + + var sessionB11 = CreateSession( + chainB1, + userB, + "lifecycle-session-b11", + Now.AddMinutes(7)); + + await store.ExecuteAsync(async ct => + { + await store.CreateSessionAsync(sessionA11, ct); + await store.CreateSessionAsync(sessionA12, ct); + await store.CreateSessionAsync(sessionA21, ct); + await store.CreateSessionAsync(sessionB11, ct); + }); + + /* + * Establish active sessions through the Chain aggregate. + * + * A12 = active on A1 + * A21 = active on A2 + * B11 = active on B1 + */ + + var chainA1Active = chainA1.AttachSession( + sessionA12.SessionId, + Now.AddMinutes(8)); + + var chainA2Active = chainA2.AttachSession( + sessionA21.SessionId, + Now.AddMinutes(8)); + + var chainB1Active = chainB1.AttachSession( + sessionB11.SessionId, + Now.AddMinutes(8)); + + await store.ExecuteAsync(async ct => + { + await store.SaveChainAsync( + chainA1Active, + chainA1.Version, + ct); + + await store.SaveChainAsync( + chainA2Active, + chainA2.Version, + ct); + + await store.SaveChainAsync( + chainB1Active, + chainB1.Version, + ct); + }); + + return new LifecycleGraph( + userA, + rootA, + chainA1Active, + sessionA11, + sessionA12, + chainA2Active, + sessionA21, + + userB, + rootB, + chainB1Active, + sessionB11); + } + + private static UAuthSessionChain CreateChain( + UAuthSessionRoot root, + UserKey user, + DateTimeOffset createdAt, + DeviceContext? device = null) + { + return UAuthSessionChain.Create( + SessionChainId.New(), + root.RootId, + root.Tenant, + user, + createdAt, + createdAt.AddDays(30), + device ?? TestDevice.Default(), + ClaimsSnapshot.Empty, + root.SecurityVersion); + } + + private static UAuthSession CreateSession( + UAuthSessionChain chain, + UserKey user, + string id, + DateTimeOffset createdAt) + { + return UAuthSession.Create( + TestIds.Session(id), + chain.Tenant, + user, + chain.ChainId, + createdAt, + createdAt.AddHours(1), + chain.SecurityVersionAtCreation, + chain.Device, + claims: null, + metadata: new SessionMetadata()); + } + + // ============================================================ + // GRAPH SANITY + // ============================================================ + + [Fact] + public async Task Graph_HasExpectedActiveSessions() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + var chainA1 = await store.GetChainAsync( + graph.ChainA1.ChainId); + + var chainA2 = await store.GetChainAsync( + graph.ChainA2.ChainId); + + var chainB1 = await store.GetChainAsync( + graph.ChainB1.ChainId); + + chainA1!.ActiveSessionId + .Should().Be(graph.SessionA12.SessionId); + + chainA2!.ActiveSessionId + .Should().Be(graph.SessionA21.SessionId); + + chainB1!.ActiveSessionId + .Should().Be(graph.SessionB11.SessionId); + } + + // ============================================================ + // SINGLE SESSION REVOKE + // ============================================================ + + [Fact] + public async Task RevokeSessionAsync_WhenSessionIsNotActive_RevokesSession() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + var revokedAt = Now.AddMinutes(20); + + await store.ExecuteAsync( + ct => store.RevokeSessionAsync( + graph.SessionA11.SessionId, + revokedAt, + ct)); + + var session = await store.GetSessionAsync( + graph.SessionA11.SessionId); + + session.Should().NotBeNull(); + session!.IsRevoked.Should().BeTrue(); + session.RevokedAt.Should().Be(revokedAt); + } + + [Fact] + public async Task RevokeSessionAsync_WhenSessionIsNotActive_PreservesActiveSession() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RevokeSessionAsync( + graph.SessionA11.SessionId, + Now.AddMinutes(20), + ct)); + + var chain = await store.GetChainAsync( + graph.ChainA1.ChainId); + + chain!.ActiveSessionId + .Should().Be(graph.SessionA12.SessionId); + } + + [Fact] + public async Task RevokeSessionAsync_WhenSessionIsActive_DoesNotLeaveRevokedSessionActive() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RevokeSessionAsync( + graph.SessionA12.SessionId, + Now.AddMinutes(20), + ct)); + + var session = await store.GetSessionAsync( + graph.SessionA12.SessionId); + + var chain = await store.GetChainAsync( + graph.ChainA1.ChainId); + + session.Should().NotBeNull(); + session!.IsRevoked.Should().BeTrue(); + + chain.Should().NotBeNull(); + chain!.ActiveSessionId.Should().BeNull( + "a revoked session must not remain the active session of its chain"); + } + + [Fact] + public async Task RevokeSessionAsync_DoesNotAffectOtherChainsOrUsers() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RevokeSessionAsync( + graph.SessionA12.SessionId, + Now.AddMinutes(20), + ct)); + + var a21 = await store.GetSessionAsync( + graph.SessionA21.SessionId); + + var b11 = await store.GetSessionAsync( + graph.SessionB11.SessionId); + + a21!.IsRevoked.Should().BeFalse(); + b11!.IsRevoked.Should().BeFalse(); + } + + // ============================================================ + // REMOVE SESSION + // ============================================================ + + [Fact] + public async Task RemoveSessionAsync_WhenSessionIsNotActive_RemovesSession() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RemoveSessionAsync( + graph.SessionA11.SessionId, + ct)); + + var session = await store.GetSessionAsync( + graph.SessionA11.SessionId); + + session.Should().BeNull(); + } + + [Fact] + public async Task RemoveSessionAsync_WhenSessionIsNotActive_PreservesActiveSession() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RemoveSessionAsync( + graph.SessionA11.SessionId, + ct)); + + var chain = await store.GetChainAsync( + graph.ChainA1.ChainId); + + chain!.ActiveSessionId + .Should().Be(graph.SessionA12.SessionId); + } + + [Fact] + public async Task RemoveSessionAsync_WhenSessionIsActive_DoesNotLeaveDanglingReference() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RemoveSessionAsync( + graph.SessionA12.SessionId, + ct)); + + var session = await store.GetSessionAsync( + graph.SessionA12.SessionId); + + var chain = await store.GetChainAsync( + graph.ChainA1.ChainId); + + session.Should().BeNull(); + + chain.Should().NotBeNull(); + chain!.ActiveSessionId.Should().BeNull( + "a removed session must not remain referenced by its chain"); + } + + // ============================================================ + // LOGOUT CHAIN + // ============================================================ + + [Fact] + public async Task LogoutChainAsync_RevokesAllSessionsInChain() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + var at = Now.AddMinutes(20); + + await store.ExecuteAsync( + ct => store.LogoutChainAsync( + graph.ChainA1.ChainId, + at, + ct)); + + var a11 = await store.GetSessionAsync( + graph.SessionA11.SessionId); + + var a12 = await store.GetSessionAsync( + graph.SessionA12.SessionId); + + a11!.IsRevoked.Should().BeTrue(); + a12!.IsRevoked.Should().BeTrue(); + + a11.RevokedAt.Should().Be(at); + a12.RevokedAt.Should().Be(at); + } + + [Fact] + public async Task LogoutChainAsync_DetachesActiveSession() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.LogoutChainAsync( + graph.ChainA1.ChainId, + Now.AddMinutes(20), + ct)); + + var chain = await store.GetChainAsync( + graph.ChainA1.ChainId); + + chain.Should().NotBeNull(); + chain!.ActiveSessionId.Should().BeNull(); + } + + [Fact] + public async Task LogoutChainAsync_DoesNotRevokeChain() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.LogoutChainAsync( + graph.ChainA1.ChainId, + Now.AddMinutes(20), + ct)); + + var chain = await store.GetChainAsync( + graph.ChainA1.ChainId); + + chain.Should().NotBeNull(); + chain!.IsRevoked.Should().BeFalse(); + } + + [Fact] + public async Task LogoutChainAsync_DoesNotAffectOtherChainsOrUsers() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.LogoutChainAsync( + graph.ChainA1.ChainId, + Now.AddMinutes(20), + ct)); + + var chainA2 = await store.GetChainAsync( + graph.ChainA2.ChainId); + + var sessionA21 = await store.GetSessionAsync( + graph.SessionA21.SessionId); + + var chainB1 = await store.GetChainAsync( + graph.ChainB1.ChainId); + + var sessionB11 = await store.GetSessionAsync( + graph.SessionB11.SessionId); + + chainA2!.IsRevoked.Should().BeFalse(); + chainA2.ActiveSessionId.Should().Be( + graph.SessionA21.SessionId); + + sessionA21!.IsRevoked.Should().BeFalse(); + + chainB1!.IsRevoked.Should().BeFalse(); + chainB1.ActiveSessionId.Should().Be( + graph.SessionB11.SessionId); + + sessionB11!.IsRevoked.Should().BeFalse(); + } + + // ============================================================ + // CHAIN CASCADE + // ============================================================ + + [Fact] + public async Task RevokeChainCascadeAsync_RevokesChain() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + var at = Now.AddMinutes(20); + + await store.ExecuteAsync( + ct => store.RevokeChainCascadeAsync( + graph.ChainA1.ChainId, + at, + ct)); + + var chain = await store.GetChainAsync( + graph.ChainA1.ChainId); + + chain.Should().NotBeNull(); + chain!.IsRevoked.Should().BeTrue(); + chain.RevokedAt.Should().Be(at); + } + + [Fact] + public async Task RevokeChainCascadeAsync_RevokesAllSessionsInChain() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RevokeChainCascadeAsync( + graph.ChainA1.ChainId, + Now.AddMinutes(20), + ct)); + + var a11 = await store.GetSessionAsync( + graph.SessionA11.SessionId); + + var a12 = await store.GetSessionAsync( + graph.SessionA12.SessionId); + + a11!.IsRevoked.Should().BeTrue(); + a12!.IsRevoked.Should().BeTrue(); + } + + [Fact] + public async Task RevokeChainCascadeAsync_DoesNotAffectOtherChainsOrUsers() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RevokeChainCascadeAsync( + graph.ChainA1.ChainId, + Now.AddMinutes(20), + ct)); + + var chainA2 = await store.GetChainAsync( + graph.ChainA2.ChainId); + + var sessionA21 = await store.GetSessionAsync( + graph.SessionA21.SessionId); + + var chainB1 = await store.GetChainAsync( + graph.ChainB1.ChainId); + + var sessionB11 = await store.GetSessionAsync( + graph.SessionB11.SessionId); + + chainA2!.IsRevoked.Should().BeFalse(); + sessionA21!.IsRevoked.Should().BeFalse(); + + chainB1!.IsRevoked.Should().BeFalse(); + sessionB11!.IsRevoked.Should().BeFalse(); + } + + // ============================================================ + // REVOKE ALL SESSIONS + // ============================================================ + + [Fact] + public async Task RevokeAllSessionsAsync_RevokesAllSessionsForUser() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RevokeAllSessionsAsync( + graph.UserA, + Now.AddMinutes(20), + ct)); + + var a11 = await store.GetSessionAsync( + graph.SessionA11.SessionId); + + var a12 = await store.GetSessionAsync( + graph.SessionA12.SessionId); + + var a21 = await store.GetSessionAsync( + graph.SessionA21.SessionId); + + a11!.IsRevoked.Should().BeTrue(); + a12!.IsRevoked.Should().BeTrue(); + a21!.IsRevoked.Should().BeTrue(); + } + + [Fact] + public async Task RevokeAllSessionsAsync_DetachesActiveSessions() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RevokeAllSessionsAsync( + graph.UserA, + Now.AddMinutes(20), + ct)); + + var chainA1 = await store.GetChainAsync( + graph.ChainA1.ChainId); + + var chainA2 = await store.GetChainAsync( + graph.ChainA2.ChainId); + + chainA1!.ActiveSessionId.Should().BeNull(); + chainA2!.ActiveSessionId.Should().BeNull(); + } + + [Fact] + public async Task RevokeAllSessionsAsync_DoesNotAffectOtherUsers() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RevokeAllSessionsAsync( + graph.UserA, + Now.AddMinutes(20), + ct)); + + var chainB1 = await store.GetChainAsync( + graph.ChainB1.ChainId); + + var sessionB11 = await store.GetSessionAsync( + graph.SessionB11.SessionId); + + chainB1!.IsRevoked.Should().BeFalse(); + + chainB1.ActiveSessionId + .Should().Be(graph.SessionB11.SessionId); + + sessionB11!.IsRevoked.Should().BeFalse(); + } + + // ============================================================ + // REVOKE OTHER SESSIONS + // + // IMPORTANT: + // RevokeOtherSessionsAsync keeps an entire CHAIN, not one + // individual session. + // ============================================================ + + [Fact] + public async Task RevokeOtherSessionsAsync_PreservesSessionsInKeptChain() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RevokeOtherSessionsAsync( + graph.UserA, + graph.ChainA1.ChainId, + Now.AddMinutes(20), + ct)); + + var a11 = await store.GetSessionAsync( + graph.SessionA11.SessionId); + + var a12 = await store.GetSessionAsync( + graph.SessionA12.SessionId); + + a11!.IsRevoked.Should().BeFalse(); + a12!.IsRevoked.Should().BeFalse(); + + var chainA1 = await store.GetChainAsync( + graph.ChainA1.ChainId); + + chainA1!.ActiveSessionId + .Should().Be(graph.SessionA12.SessionId); + } + + [Fact] + public async Task RevokeOtherSessionsAsync_RevokesSessionsOutsideKeptChain() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RevokeOtherSessionsAsync( + graph.UserA, + graph.ChainA1.ChainId, + Now.AddMinutes(20), + ct)); + + var a21 = await store.GetSessionAsync( + graph.SessionA21.SessionId); + + a21.Should().NotBeNull(); + a21!.IsRevoked.Should().BeTrue(); + } + + [Fact] + public async Task RevokeOtherSessionsAsync_DetachesActiveSessionFromAffectedChains() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RevokeOtherSessionsAsync( + graph.UserA, + graph.ChainA1.ChainId, + Now.AddMinutes(20), + ct)); + + var kept = await store.GetChainAsync( + graph.ChainA1.ChainId); + + var affected = await store.GetChainAsync( + graph.ChainA2.ChainId); + + kept!.ActiveSessionId + .Should().Be(graph.SessionA12.SessionId); + + affected!.ActiveSessionId + .Should().BeNull(); + } + + [Fact] + public async Task RevokeOtherSessionsAsync_DoesNotAffectOtherUsers() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RevokeOtherSessionsAsync( + graph.UserA, + graph.ChainA1.ChainId, + Now.AddMinutes(20), + ct)); + + var b11 = await store.GetSessionAsync( + graph.SessionB11.SessionId); + + var chainB1 = await store.GetChainAsync( + graph.ChainB1.ChainId); + + b11!.IsRevoked.Should().BeFalse(); + + chainB1!.ActiveSessionId + .Should().Be(graph.SessionB11.SessionId); + } + + // ============================================================ + // REVOKE ALL CHAINS + // + // This intentionally does NOT assert that sessions are revoked. + // RevokeChainCascadeAsync exists for cascade semantics. + // ============================================================ + + [Fact] + public async Task RevokeAllChainsAsync_RevokesAllChainsForUser() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RevokeAllChainsAsync( + graph.UserA, + Now.AddMinutes(20), + ct)); + + var a1 = await store.GetChainAsync( + graph.ChainA1.ChainId); + + var a2 = await store.GetChainAsync( + graph.ChainA2.ChainId); + + a1!.IsRevoked.Should().BeTrue(); + a2!.IsRevoked.Should().BeTrue(); + } + + [Fact] + public async Task RevokeAllChainsAsync_DoesNotAffectOtherUsers() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RevokeAllChainsAsync( + graph.UserA, + Now.AddMinutes(20), + ct)); + + var b1 = await store.GetChainAsync( + graph.ChainB1.ChainId); + + b1!.IsRevoked.Should().BeFalse(); + } + + // ============================================================ + // REVOKE OTHER CHAINS + // ============================================================ + + [Fact] + public async Task RevokeOtherChainsAsync_PreservesSpecifiedChain() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RevokeOtherChainsAsync( + graph.UserA, + graph.ChainA1.ChainId, + Now.AddMinutes(20), + ct)); + + var a1 = await store.GetChainAsync( + graph.ChainA1.ChainId); + + a1.Should().NotBeNull(); + a1!.IsRevoked.Should().BeFalse(); + } + + [Fact] + public async Task RevokeOtherChainsAsync_RevokesOtherChains() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RevokeOtherChainsAsync( + graph.UserA, + graph.ChainA1.ChainId, + Now.AddMinutes(20), + ct)); + + var a2 = await store.GetChainAsync( + graph.ChainA2.ChainId); + + a2.Should().NotBeNull(); + a2!.IsRevoked.Should().BeTrue(); + } + + [Fact] + public async Task RevokeOtherChainsAsync_DoesNotAffectOtherUsers() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RevokeOtherChainsAsync( + graph.UserA, + graph.ChainA1.ChainId, + Now.AddMinutes(20), + ct)); + + var b1 = await store.GetChainAsync( + graph.ChainB1.ChainId); + + b1!.IsRevoked.Should().BeFalse(); + } + + // ============================================================ + // ROOT CASCADE + // ============================================================ + + [Fact] + public async Task RevokeRootCascadeAsync_RevokesRoot() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + var at = Now.AddMinutes(20); + + await store.ExecuteAsync( + ct => store.RevokeRootCascadeAsync( + graph.UserA, + at, + ct)); + + var root = await store.GetRootByUserAsync( + graph.UserA); + + root.Should().NotBeNull(); + root!.IsRevoked.Should().BeTrue(); + root.RevokedAt.Should().Be(at); + } + + [Fact] + public async Task RevokeRootCascadeAsync_IncreasesRootSecurityVersion() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RevokeRootCascadeAsync( + graph.UserA, + Now.AddMinutes(20), + ct)); + + var root = await store.GetRootByUserAsync( + graph.UserA); + + root.Should().NotBeNull(); + + root!.SecurityVersion.Should().Be( + graph.RootA.SecurityVersion + 1); + + root.Version.Should().Be( + graph.RootA.Version + 1); + } + + [Fact] + public async Task RevokeRootCascadeAsync_RevokesAllUserChains() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RevokeRootCascadeAsync( + graph.UserA, + Now.AddMinutes(20), + ct)); + + var a1 = await store.GetChainAsync( + graph.ChainA1.ChainId); + + var a2 = await store.GetChainAsync( + graph.ChainA2.ChainId); + + a1!.IsRevoked.Should().BeTrue(); + a2!.IsRevoked.Should().BeTrue(); + } + + [Fact] + public async Task RevokeRootCascadeAsync_RevokesAllUserSessions() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RevokeRootCascadeAsync( + graph.UserA, + Now.AddMinutes(20), + ct)); + + var a11 = await store.GetSessionAsync( + graph.SessionA11.SessionId); + + var a12 = await store.GetSessionAsync( + graph.SessionA12.SessionId); + + var a21 = await store.GetSessionAsync( + graph.SessionA21.SessionId); + + a11!.IsRevoked.Should().BeTrue(); + a12!.IsRevoked.Should().BeTrue(); + a21!.IsRevoked.Should().BeTrue(); + } + + [Fact] + public async Task RevokeRootCascadeAsync_DoesNotAffectOtherUsers() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RevokeRootCascadeAsync( + graph.UserA, + Now.AddMinutes(20), + ct)); + + var rootB = await store.GetRootByUserAsync( + graph.UserB); + + var chainB1 = await store.GetChainAsync( + graph.ChainB1.ChainId); + + var sessionB11 = await store.GetSessionAsync( + graph.SessionB11.SessionId); + + rootB.Should().NotBeNull(); + chainB1.Should().NotBeNull(); + sessionB11.Should().NotBeNull(); + + rootB!.IsRevoked.Should().BeFalse(); + chainB1!.IsRevoked.Should().BeFalse(); + sessionB11!.IsRevoked.Should().BeFalse(); + + chainB1.ActiveSessionId + .Should().Be(graph.SessionB11.SessionId); + } + + // ============================================================ + // QUERY / CASCADE CONSISTENCY + // ============================================================ + + [Fact] + public async Task LogoutChainAsync_GetSessionsByChainReflectsRevocation() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.LogoutChainAsync( + graph.ChainA1.ChainId, + Now.AddMinutes(20), + ct)); + + var sessions = await store.GetSessionsByChainAsync( + graph.ChainA1.ChainId); + + sessions.Should().HaveCount(2); + sessions.Should().OnlyContain(x => x.IsRevoked); + } + + [Fact] + public async Task RevokeRootCascadeAsync_GetChainsByUserIncludingHistoricalRootsReflectsRevocation() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var graph = await CreateGraphAsync(store); + + await store.ExecuteAsync( + ct => store.RevokeRootCascadeAsync( + graph.UserA, + Now.AddMinutes(20), + ct)); + + var chains = await store.GetChainsByUserAsync( + graph.UserA, + includeHistoricalRoots: true); + + chains.Should().HaveCount(2); + chains.Should().OnlyContain(x => x.IsRevoked); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreRootContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreRootContractTests.cs new file mode 100644 index 00000000..79e88aef --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreRootContractTests.cs @@ -0,0 +1,555 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Sessions.Contracts; + +public abstract class SessionStoreRootContractTests +{ + protected abstract Task CreateDatabaseAsync(); + + protected static readonly TenantKey Tenant = + TenantKey.FromExternal("tenant-a"); + + protected static readonly DateTimeOffset Now = + new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + private static async Task CreateRootAsync( + ISessionStore store, + TenantKey tenant, + UserKey? userKey = null) + { + var root = UAuthSessionRoot.Create( + tenant, + userKey ?? UserKey.New(), + Now); + + await store.ExecuteAsync( + ct => store.CreateRootAsync(root, ct)); + + return root; + } + + // ------------------------------------------------------------ + // CREATE / GET + // ------------------------------------------------------------ + + [Fact] + public async Task CreateRootAsync_PersistsRoot() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var user = UserKey.New(); + + var root = UAuthSessionRoot.Create( + Tenant, + user, + Now); + + await store.ExecuteAsync( + ct => store.CreateRootAsync(root, ct)); + + var result = + await store.GetRootByUserAsync(user); + + result.Should().NotBeNull(); + + result!.RootId.Should().Be(root.RootId); + result.Tenant.Should().Be(Tenant); + result.UserKey.Should().Be(user); + + result.CreatedAt.Should().Be(Now); + + // new root not mutated yet + result.UpdatedAt.Should().BeNull(); + + result.RevokedAt.Should().BeNull(); + result.IsRevoked.Should().BeFalse(); + + result.SecurityVersion.Should().Be(0); + result.Version.Should().Be(0); + } + + [Fact] + public async Task GetRootByUserAsync_WhenMissing_ReturnsNull() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var result = + await store.GetRootByUserAsync( + UserKey.New()); + + result.Should().BeNull(); + } + + [Fact] + public async Task GetRootByIdAsync_ReturnsRoot() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var root = + await CreateRootAsync( + store, + Tenant); + + var result = + await store.GetRootByIdAsync( + root.RootId); + + result.Should().NotBeNull(); + + result!.RootId.Should().Be(root.RootId); + result.UserKey.Should().Be(root.UserKey); + result.Tenant.Should().Be(Tenant); + } + + [Fact] + public async Task GetRootByIdAsync_WhenMissing_ReturnsNull() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var missingRoot = + UAuthSessionRoot.Create( + Tenant, + UserKey.New(), + Now); + + var result = + await store.GetRootByIdAsync( + missingRoot.RootId); + + result.Should().BeNull(); + } + + // ------------------------------------------------------------ + // CREATE INVARIANTS + // ------------------------------------------------------------ + + [Fact] + public async Task CreateRootAsync_WhenVersionIsNotZero_IsRejected() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var root = UAuthSessionRoot.Create( + Tenant, + UserKey.New(), + Now); + + root.Version = 1; + + var act = () => store.ExecuteAsync( + ct => store.CreateRootAsync( + root, + ct)); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task CreateRootAsync_WhenUserAlreadyHasRoot_ThrowsConcurrency() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var user = UserKey.New(); + + await CreateRootAsync( + store, + Tenant, + user); + + var secondRoot = + UAuthSessionRoot.Create( + Tenant, + user, + Now.AddMinutes(1)); + + var act = () => store.ExecuteAsync( + ct => store.CreateRootAsync( + secondRoot, + ct)); + + await act.Should() + .ThrowAsync(); + } + + // ------------------------------------------------------------ + // SAVE + // ------------------------------------------------------------ + + [Fact] + public async Task SaveRootAsync_WhenExpectedVersionMatches_PersistsSecurityVersionIncrease() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var root = + await CreateRootAsync( + store, + Tenant); + + var changedAt = + Now.AddMinutes(10); + + var updated = + root.IncreaseSecurityVersion( + changedAt); + + await store.ExecuteAsync( + ct => store.SaveRootAsync( + updated, + expectedVersion: root.Version, + ct)); + + var result = + await store.GetRootByUserAsync( + root.UserKey); + + result.Should().NotBeNull(); + + result!.RootId.Should().Be(root.RootId); + + result.SecurityVersion + .Should().Be( + root.SecurityVersion + 1); + + result.UpdatedAt + .Should().Be(changedAt); + + result.Version + .Should().Be( + root.Version + 1); + + result.IsRevoked.Should().BeFalse(); + } + + [Fact] + public async Task SaveRootAsync_WhenRootDoesNotExist_ThrowsNotFound() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var root = + UAuthSessionRoot.Create( + Tenant, + UserKey.New(), + Now); + + var updated = + root.IncreaseSecurityVersion( + Now.AddMinutes(10)); + + var act = () => store.ExecuteAsync( + ct => store.SaveRootAsync( + updated, + expectedVersion: root.Version, + ct)); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task SaveRootAsync_WhenExpectedVersionIsStale_ThrowsConcurrency() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var root = + await CreateRootAsync( + store, + Tenant); + + var updated = + root.IncreaseSecurityVersion( + Now.AddMinutes(10)); + + var act = () => store.ExecuteAsync( + ct => store.SaveRootAsync( + updated, + expectedVersion: 999, + ct)); + + await act.Should() + .ThrowAsync(); + } + + // ------------------------------------------------------------ + // REVOKE + // ------------------------------------------------------------ + + [Fact] + public async Task RevokeRootAsync_WhenRootExists_RevokesRoot() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var root = + await CreateRootAsync( + store, + Tenant); + + var revokedAt = + Now.AddMinutes(10); + + await store.ExecuteAsync( + ct => store.RevokeRootAsync( + root.UserKey, + revokedAt, + ct)); + + var result = + await store.GetRootByUserAsync( + root.UserKey); + + result.Should().NotBeNull(); + + result!.IsRevoked.Should().BeTrue(); + result.RevokedAt.Should().Be(revokedAt); + result.UpdatedAt.Should().Be(revokedAt); + + result.SecurityVersion + .Should().Be( + root.SecurityVersion + 1); + + result.Version + .Should().Be( + root.Version + 1); + } + + [Fact] + public async Task RevokeRootAsync_WhenRootDoesNotExist_IsIdempotent() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var act = () => store.ExecuteAsync( + ct => store.RevokeRootAsync( + UserKey.New(), + Now, + ct)); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task RevokeRootAsync_WhenAlreadyRevoked_DoesNotMutateAgain() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var root = + await CreateRootAsync( + store, + Tenant); + + var firstRevokedAt = + Now.AddMinutes(5); + + await store.ExecuteAsync( + ct => store.RevokeRootAsync( + root.UserKey, + firstRevokedAt, + ct)); + + var afterFirst = + await store.GetRootByUserAsync( + root.UserKey); + + afterFirst.Should().NotBeNull(); + + await store.ExecuteAsync( + ct => store.RevokeRootAsync( + root.UserKey, + Now.AddMinutes(10), + ct)); + + var afterSecond = + await store.GetRootByUserAsync( + root.UserKey); + + afterSecond.Should().NotBeNull(); + + afterSecond!.RevokedAt + .Should().Be(firstRevokedAt); + + afterSecond.UpdatedAt + .Should().Be(firstRevokedAt); + + afterSecond.SecurityVersion + .Should().Be( + afterFirst!.SecurityVersion); + + afterSecond.Version + .Should().Be( + afterFirst.Version); + } + + // ------------------------------------------------------------ + // TENANT ISOLATION - READ + // ------------------------------------------------------------ + + [Fact] + public async Task GetRootByUserAsync_IsTenantIsolated() + { + await using var db = await CreateDatabaseAsync(); + + var tenantA = + TenantKey.FromExternal("tenant-a"); + + var tenantB = + TenantKey.FromExternal("tenant-b"); + + var storeA = db.CreateStore(tenantA); + var storeB = db.CreateStore(tenantB); + + var user = UserKey.New(); + + var root = + await CreateRootAsync( + storeA, + tenantA, + user); + + var fromA = + await storeA.GetRootByUserAsync(user); + + var fromB = + await storeB.GetRootByUserAsync(user); + + fromA.Should().NotBeNull(); + fromA!.RootId.Should().Be(root.RootId); + + fromB.Should().BeNull(); + } + + [Fact] + public async Task GetRootByIdAsync_IsTenantIsolated() + { + await using var db = await CreateDatabaseAsync(); + + var tenantA = + TenantKey.FromExternal("tenant-a"); + + var tenantB = + TenantKey.FromExternal("tenant-b"); + + var storeA = db.CreateStore(tenantA); + var storeB = db.CreateStore(tenantB); + + var root = + await CreateRootAsync( + storeA, + tenantA); + + var fromA = + await storeA.GetRootByIdAsync( + root.RootId); + + var fromB = + await storeB.GetRootByIdAsync( + root.RootId); + + fromA.Should().NotBeNull(); + fromB.Should().BeNull(); + } + + // ------------------------------------------------------------ + // TENANT ISOLATION - WRITE + // ------------------------------------------------------------ + + [Fact] + public async Task CreateRootAsync_WhenRootBelongsToDifferentTenant_IsRejected() + { + await using var db = await CreateDatabaseAsync(); + + var tenantA = + TenantKey.FromExternal("tenant-a"); + + var tenantB = + TenantKey.FromExternal("tenant-b"); + + var storeA = db.CreateStore(tenantA); + + var rootB = + UAuthSessionRoot.Create( + tenantB, + UserKey.New(), + Now); + + var act = () => storeA.ExecuteAsync( + ct => storeA.CreateRootAsync( + rootB, + ct)); + + await act.Should() + .ThrowAsync() + .WithMessage("Tenant mismatch."); + } + + [Fact] + public async Task SaveRootAsync_WhenRootBelongsToDifferentTenant_IsRejected() + { + await using var db = await CreateDatabaseAsync(); + + var tenantA = + TenantKey.FromExternal("tenant-a"); + + var tenantB = + TenantKey.FromExternal("tenant-b"); + + var storeA = db.CreateStore(tenantA); + + var rootB = + UAuthSessionRoot.Create( + tenantB, + UserKey.New(), + Now); + + var updatedRootB = + rootB.IncreaseSecurityVersion( + Now.AddMinutes(10)); + + var act = () => storeA.ExecuteAsync( + ct => storeA.SaveRootAsync( + updatedRootB, + expectedVersion: rootB.Version, + ct)); + + await act.Should() + .ThrowAsync() + .WithMessage("Tenant mismatch."); + } + + // ------------------------------------------------------------ + // CANCELLATION + // ------------------------------------------------------------ + + [Fact] + public async Task GetRootByUserAsync_WhenCancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + using var cts = + new CancellationTokenSource(); + + cts.Cancel(); + + var act = () => + store.GetRootByUserAsync( + UserKey.New(), + cts.Token); + + await act.Should() + .ThrowAsync(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreSessionContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreSessionContractTests.cs new file mode 100644 index 00000000..9fe34862 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/SessionStoreSessionContractTests.cs @@ -0,0 +1,604 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Sessions.Contracts; + +public abstract class SessionStoreSessionContractTests +{ + protected abstract Task CreateDatabaseAsync(); + + protected static async Task<(UAuthSessionRoot Root, UAuthSessionChain Chain, UAuthSession Session)> CreateSessionGraphAsync( + ISessionStore store, + TenantKey tenant, + UserKey? userKey = null, + AuthSessionId? sessionId = null) + { + var user = userKey ?? UserKey.New(); + + // 1. Root + var root = UAuthSessionRoot.Create( + tenant, + user, + Now); + + await store.ExecuteAsync( + ct => store.CreateRootAsync(root, ct)); + + // 2. Chain + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + root.RootId, + tenant, + user, + Now, + expiresAt: Now.AddDays(30), + device: TestDevice.Default(), + claimsSnapshot: ClaimsSnapshot.Empty, + securityVersion: root.SecurityVersion); + + await store.ExecuteAsync( + ct => store.CreateChainAsync(chain, ct)); + + // 3. Session + var session = UAuthSession.Create( + sessionId ?? NewSessionId(), + tenant, + user, + chain.ChainId, + Now, + Now.AddHours(1), + securityVersion: root.SecurityVersion, + device: TestDevice.Default(), + claims: ClaimsSnapshot.Empty, + metadata: SessionMetadata.Empty); + + await store.ExecuteAsync( + ct => store.CreateSessionAsync(session, ct)); + + return (root, chain, session); + } + + protected static readonly TenantKey Tenant = + TenantKey.FromExternal("tenant-a"); + + protected static readonly DateTimeOffset Now = + new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + private static UAuthSession CreateSession( + TenantKey tenant, + UserKey user, + SessionChainId chainId, + AuthSessionId? sessionId = null) + { + return UAuthSession.Create( + sessionId ?? NewSessionId(), + tenant, + user, + chainId, + now: Now, + expiresAt: Now.AddHours(1), + securityVersion: 0, + device: TestDevice.Default(), + claims: ClaimsSnapshot.Empty, + metadata: SessionMetadata.Empty); + } + + private static AuthSessionId NewSessionId() + { + var raw = $"session-{Guid.NewGuid():N}"; + + if (!AuthSessionId.TryCreate(raw, out var id)) + throw new InvalidOperationException( + "Failed to create test session id."); + + return id; + } + + private static Task WriteAsync( + ISessionStore store, + Func action) + => store.ExecuteAsync(action); + + // ------------------------------------------------------------ + // CREATE / GET + // ------------------------------------------------------------ + + [Fact] + public async Task CreateSessionAsync_PersistsSession() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var (_, chain, session) = + await CreateSessionGraphAsync( + store, + Tenant); + + var result = await store.GetSessionAsync( + session.SessionId); + + result.Should().NotBeNull(); + + result!.SessionId.Should().Be(session.SessionId); + result.Tenant.Should().Be(Tenant); + result.UserKey.Should().Be(session.UserKey); + result.ChainId.Should().Be(chain.ChainId); + result.CreatedAt.Should().Be(Now); + result.ExpiresAt.Should().Be(Now.AddHours(1)); + result.RevokedAt.Should().BeNull(); + result.SecurityVersionAtCreation.Should().Be(0); + result.Version.Should().Be(0); + } + + [Fact] + public async Task GetSessionAsync_WhenSessionDoesNotExist_ReturnsNull() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var result = await store.GetSessionAsync( + NewSessionId()); + + result.Should().BeNull(); + } + + [Fact] + public async Task CreateSessionAsync_WhenIdAlreadyExists_ThrowsConcurrency() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var (root, chain, first) = + await CreateSessionGraphAsync( + store, + Tenant); + + var second = UAuthSession.Create( + first.SessionId, + Tenant, + first.UserKey, + chain.ChainId, + Now, + Now.AddHours(2), + root.SecurityVersion, + TestDevice.Default(), + ClaimsSnapshot.Empty, + SessionMetadata.Empty); + + var act = () => store.ExecuteAsync( + ct => store.CreateSessionAsync( + second, + ct)); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task CreateSessionAsync_WhenVersionIsNotZero_IsRejected() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var session = CreateSession( + Tenant, + UserKey.New(), + SessionChainId.New()); + + session.Version = 1; + + var act = () => WriteAsync( + store, + ct => store.CreateSessionAsync(session, ct)); + + await act.Should() + .ThrowAsync(); + } + + // ------------------------------------------------------------ + // SAVE + // ------------------------------------------------------------ + + [Fact] + public async Task SaveSessionAsync_WhenExpectedVersionMatches_PersistsChanges() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var (_, _, session) = + await CreateSessionGraphAsync( + store, + Tenant); + + var updated = + session.Revoke(Now.AddMinutes(10)); + + await store.ExecuteAsync( + ct => store.SaveSessionAsync( + updated, + expectedVersion: session.Version, + ct)); + + var result = + await store.GetSessionAsync( + session.SessionId); + + result.Should().NotBeNull(); + result!.IsRevoked.Should().BeTrue(); + result.RevokedAt.Should().Be( + Now.AddMinutes(10)); + result.Version.Should().Be(1); + } + + [Fact] + public async Task SaveSessionAsync_WhenSessionDoesNotExist_ThrowsNotFound() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var session = CreateSession( + Tenant, + UserKey.New(), + SessionChainId.New()); + + var updated = session.Revoke( + Now.AddMinutes(10)); + + var act = () => WriteAsync( + store, + ct => store.SaveSessionAsync( + updated, + expectedVersion: session.Version, + ct)); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task SaveSessionAsync_WhenExpectedVersionIsStale_ThrowsConcurrency() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var (_, _, session) = + await CreateSessionGraphAsync( + store, + Tenant); + + var updated = + session.Revoke(Now.AddMinutes(10)); + + var act = () => store.ExecuteAsync( + ct => store.SaveSessionAsync( + updated, + expectedVersion: 999, + ct)); + + await act.Should() + .ThrowAsync(); + } + + // ------------------------------------------------------------ + // REVOKE + // ------------------------------------------------------------ + + [Fact] + public async Task RevokeSessionAsync_WhenSessionExists_RevokesAndReturnsTrue() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var (_, _, session) = + await CreateSessionGraphAsync( + store, + Tenant); + + bool revoked = false; + + await store.ExecuteAsync(async ct => + { + revoked = await store.RevokeSessionAsync( + session.SessionId, + Now.AddMinutes(10), + ct); + }); + + revoked.Should().BeTrue(); + + var result = await store.GetSessionAsync( + session.SessionId); + + result.Should().NotBeNull(); + result!.IsRevoked.Should().BeTrue(); + result.RevokedAt.Should().Be(Now.AddMinutes(10)); + result.Version.Should().Be(1); + } + + [Fact] + public async Task RevokeSessionAsync_WhenSessionDoesNotExist_ReturnsFalse() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + bool result = true; + + await store.ExecuteAsync(async ct => + { + result = await store.RevokeSessionAsync( + NewSessionId(), + Now, + ct); + }); + + result.Should().BeFalse(); + } + + [Fact] + public async Task RevokeSessionAsync_WhenAlreadyRevoked_ReturnsFalse() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var (_, _, session) = + await CreateSessionGraphAsync( + store, + Tenant); + + await store.ExecuteAsync( + ct => store.RevokeSessionAsync( + session.SessionId, + Now.AddMinutes(5), + ct)); + + bool secondResult = true; + + await store.ExecuteAsync(async ct => + { + secondResult = + await store.RevokeSessionAsync( + session.SessionId, + Now.AddMinutes(10), + ct); + }); + + secondResult.Should().BeFalse(); + + var result = + await store.GetSessionAsync( + session.SessionId); + + result.Should().NotBeNull(); + result!.RevokedAt.Should().Be( + Now.AddMinutes(5)); + result.Version.Should().Be(1); + } + + // ------------------------------------------------------------ + // REMOVE + // ------------------------------------------------------------ + + [Fact] + public async Task RemoveSessionAsync_RemovesSession() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var (_, _, session) = + await CreateSessionGraphAsync( + store, + Tenant); + + await store.ExecuteAsync( + ct => store.RemoveSessionAsync( + session.SessionId, + ct)); + + var result = + await store.GetSessionAsync( + session.SessionId); + + result.Should().BeNull(); + } + + [Fact] + public async Task RemoveSessionAsync_WhenSessionDoesNotExist_IsIdempotent() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var act = () => WriteAsync( + store, + ct => store.RemoveSessionAsync( + NewSessionId(), + ct)); + + await act.Should().NotThrowAsync(); + } + + // ------------------------------------------------------------ + // LOOKUPS + // ------------------------------------------------------------ + + [Fact] + public async Task GetChainIdBySessionAsync_ReturnsOwningChain() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var (_, chain, session) = + await CreateSessionGraphAsync( + store, + Tenant); + + var result = + await store.GetChainIdBySessionAsync( + session.SessionId); + + result.Should().Be(chain.ChainId); + } + + [Fact] + public async Task GetChainIdBySessionAsync_WhenMissing_ReturnsNull() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var result = await store.GetChainIdBySessionAsync( + NewSessionId()); + + result.Should().BeNull(); + } + + [Fact] + public async Task GetSessionsByChainAsync_ReturnsOnlyRequestedChain() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + var user = UserKey.New(); + + var (_, chainA, a1) = + await CreateSessionGraphAsync( + store, + Tenant, + user); + + var a2 = CreateSession( + Tenant, + user, + chainA.ChainId); + + await store.ExecuteAsync( + ct => store.CreateSessionAsync(a2, ct)); + + var root = + await store.GetRootByUserAsync(user); + + root.Should().NotBeNull(); + + var chainB = UAuthSessionChain.Create( + SessionChainId.New(), + root!.RootId, + Tenant, + user, + Now, + Now.AddDays(30), + TestDevice.Default(), + ClaimsSnapshot.Empty, + root.SecurityVersion); + + await store.ExecuteAsync( + ct => store.CreateChainAsync(chainB, ct)); + + var b1 = CreateSession( + Tenant, + user, + chainB.ChainId); + + await store.ExecuteAsync( + ct => store.CreateSessionAsync(b1, ct)); + + var result = + await store.GetSessionsByChainAsync( + chainA.ChainId); + + result.Should().HaveCount(2); + + result.Select(x => x.SessionId) + .Should() + .BeEquivalentTo([ + a1.SessionId, + a2.SessionId + ]); + } + + // ------------------------------------------------------------ + // TENANT ISOLATION + // ------------------------------------------------------------ + + [Fact] + public async Task GetSessionAsync_IsTenantIsolated() + { + await using var db = await CreateDatabaseAsync(); + + var tenantA = + TenantKey.FromExternal("tenant-a"); + + var tenantB = + TenantKey.FromExternal("tenant-b"); + + var storeA = db.CreateStore(tenantA); + var storeB = db.CreateStore(tenantB); + + var (_, _, session) = + await CreateSessionGraphAsync( + storeA, + tenantA); + + var fromA = + await storeA.GetSessionAsync( + session.SessionId); + + var fromB = + await storeB.GetSessionAsync( + session.SessionId); + + fromA.Should().NotBeNull(); + fromB.Should().BeNull(); + } + + [Fact] + public async Task CreateSessionAsync_WhenSessionBelongsToDifferentTenant_IsRejected() + { + await using var db = await CreateDatabaseAsync(); + + var tenantA = + TenantKey.FromExternal("tenant-a"); + + var tenantB = + TenantKey.FromExternal("tenant-b"); + + var storeA = db.CreateStore(tenantA); + + var session = CreateSession( + tenantB, + UserKey.New(), + SessionChainId.New()); + + var act = () => storeA.ExecuteAsync( + ct => storeA.CreateSessionAsync( + session, + ct)); + + await act.Should() + .ThrowAsync() + .WithMessage("Tenant mismatch."); + } + + // ------------------------------------------------------------ + // CANCELLATION + // ------------------------------------------------------------ + + [Fact] + public async Task GetSessionAsync_WhenCancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(Tenant); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => + store.GetSessionAsync( + NewSessionId(), + cts.Token); + + await act.Should() + .ThrowAsync(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/EfCoreRefreshTokenStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/EfCoreRefreshTokenStoreContractTests.cs new file mode 100644 index 00000000..b185dc9d --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/EfCoreRefreshTokenStoreContractTests.cs @@ -0,0 +1,56 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Tokens.Contracts; + +public sealed class EfCoreRefreshTokenStoreContractTests + : RefreshTokenStoreContractTests +{ + protected override async Task + CreateDatabaseAsync() + { + var db = new Database(); + await db.InitializeAsync(); + return db; + } + + private sealed class Database + : IRefreshTokenStoreTestDatabase + { + private readonly SqliteConnection _connection; + private readonly UAuthTokenDbContext _db; + + public Database() + { + _connection = + new SqliteConnection("Data Source=:memory:"); + + var options = + new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _db = new UAuthTokenDbContext(options); + } + + public async Task InitializeAsync() + { + await _connection.OpenAsync(); + await _db.Database.EnsureCreatedAsync(); + } + + public IRefreshTokenStore CreateStore(TenantKey tenant) + => new EfCoreRefreshTokenStore( + _db, + new TenantExecutionContext(tenant)); + + public async ValueTask DisposeAsync() + { + await _db.DisposeAsync(); + await _connection.DisposeAsync(); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/IRefreshTokenStoreTestDatabase.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/IRefreshTokenStoreTestDatabase.cs new file mode 100644 index 00000000..c212472d --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/IRefreshTokenStoreTestDatabase.cs @@ -0,0 +1,9 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Tokens.Contracts; + +public interface IRefreshTokenStoreTestDatabase : IAsyncDisposable +{ + IRefreshTokenStore CreateStore(TenantKey tenant); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/InMemoryRefreshTokenStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/InMemoryRefreshTokenStoreContractTests.cs new file mode 100644 index 00000000..4796e175 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/InMemoryRefreshTokenStoreContractTests.cs @@ -0,0 +1,29 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tokens.InMemory; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Tokens.Contracts; + +public sealed class InMemoryRefreshTokenStoreContractTests + : RefreshTokenStoreContractTests +{ + protected override Task + CreateDatabaseAsync() + { + return Task.FromResult( + new Database()); + } + + private sealed class Database + : IRefreshTokenStoreTestDatabase + { + private readonly InMemoryRefreshTokenStoreFactory _factory = + new(); + + public IRefreshTokenStore CreateStore(TenantKey tenant) + => _factory.Create(tenant); + + public ValueTask DisposeAsync() + => ValueTask.CompletedTask; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/RefreshTokenStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/RefreshTokenStoreContractTests.cs new file mode 100644 index 00000000..3348d13a --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Tokens/RefreshTokenStoreContractTests.cs @@ -0,0 +1,774 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Tokens.Contracts; + +public abstract class RefreshTokenStoreContractTests +{ + protected abstract Task CreateDatabaseAsync(); + + protected static readonly TenantKey TenantA = + TenantKey.FromExternal("tenant-a"); + + protected static readonly TenantKey TenantB = + TenantKey.FromExternal("tenant-b"); + + protected static readonly DateTimeOffset Now = + new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + // ============================================================ + // STORE / FIND + // ============================================================ + + [Fact] + public async Task StoreAsync_PersistsToken() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var token = CreateToken( + TenantA, + "hash-a11"); + + await store.ExecuteAsync( + ct => store.StoreAsync(token, ct)); + + var persisted = await store.FindByHashAsync( + token.TokenHash); + + persisted.Should().NotBeNull(); + + persisted!.TokenHash.Should().Be(token.TokenHash); + persisted.Tenant.Should().Be(token.Tenant); + persisted.UserKey.Should().Be(token.UserKey); + persisted.SessionId.Should().Be(token.SessionId); + persisted.ChainId.Should().Be(token.ChainId); + persisted.CreatedAt.Should().Be(token.CreatedAt); + persisted.ExpiresAt.Should().Be(token.ExpiresAt); + persisted.RevokedAt.Should().BeNull(); + persisted.ReplacedByTokenHash.Should().BeNull(); + } + + [Fact] + public async Task FindByHashAsync_WhenMissing_ReturnsNull() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var result = await store.FindByHashAsync( + "missing-token-hash"); + + result.Should().BeNull(); + } + + [Fact] + public async Task StoreAsync_WhenTokenBelongsToDifferentTenant_IsRejected() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var token = CreateToken( + TenantB, + "cross-tenant-token"); + + var act = () => store.ExecuteAsync( + ct => store.StoreAsync(token, ct)); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task FindByHashAsync_IsTenantIsolated() + { + await using var db = await CreateDatabaseAsync(); + + var storeA = db.CreateStore(TenantA); + var storeB = db.CreateStore(TenantB); + + var token = CreateToken( + TenantA, + "tenant-isolation-token"); + + await storeA.ExecuteAsync( + ct => storeA.StoreAsync(token, ct)); + + var fromA = await storeA.FindByHashAsync( + token.TokenHash); + + var fromB = await storeB.FindByHashAsync( + token.TokenHash); + + fromA.Should().NotBeNull(); + fromB.Should().BeNull(); + } + + // ============================================================ + // SINGLE REVOKE + // ============================================================ + + [Fact] + public async Task RevokeAsync_WhenTokenExists_RevokesToken() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var token = CreateToken( + TenantA, + "revoke-token"); + + await StoreAsync(store, token); + + var revokedAt = Now.AddMinutes(10); + + await store.ExecuteAsync( + ct => store.RevokeAsync( + token.TokenHash, + revokedAt, + ct: ct)); + + var persisted = await store.FindByHashAsync( + token.TokenHash); + + persisted.Should().NotBeNull(); + persisted!.IsRevoked.Should().BeTrue(); + persisted.RevokedAt.Should().Be(revokedAt); + persisted.ReplacedByTokenHash.Should().BeNull(); + } + + [Fact] + public async Task RevokeAsync_WithReplacement_PersistsReplacementHash() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var token = CreateToken( + TenantA, + "rotation-old"); + + await StoreAsync(store, token); + + var revokedAt = Now.AddMinutes(10); + + await store.ExecuteAsync( + ct => store.RevokeAsync( + token.TokenHash, + revokedAt, + "rotation-new", + ct)); + + var persisted = await store.FindByHashAsync( + token.TokenHash); + + persisted.Should().NotBeNull(); + persisted!.IsRevoked.Should().BeTrue(); + persisted.RevokedAt.Should().Be(revokedAt); + + persisted.ReplacedByTokenHash + .Should().Be("rotation-new"); + } + + [Fact] + public async Task RevokeAsync_WhenMissing_IsIdempotent() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var act = () => store.ExecuteAsync( + ct => store.RevokeAsync( + "missing-token", + Now, + ct: ct)); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task RevokeAsync_WhenAlreadyRevoked_DoesNotMutateAgain() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var token = CreateToken( + TenantA, + "already-revoked"); + + await StoreAsync(store, token); + + var firstRevocation = Now.AddMinutes(10); + + await store.ExecuteAsync( + ct => store.RevokeAsync( + token.TokenHash, + firstRevocation, + "replacement-1", + ct)); + + await store.ExecuteAsync( + ct => store.RevokeAsync( + token.TokenHash, + Now.AddMinutes(20), + "replacement-2", + ct)); + + var persisted = await store.FindByHashAsync( + token.TokenHash); + + persisted.Should().NotBeNull(); + + persisted!.RevokedAt + .Should().Be(firstRevocation); + + persisted.ReplacedByTokenHash + .Should().Be("replacement-1"); + } + + // ============================================================ + // REVOKE BY SESSION + // ============================================================ + + [Fact] + public async Task RevokeBySessionAsync_RevokesTokensForSession() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + var chain = SessionChainId.New(); + var session = TestIds.Session("session-revoke-target"); + + var token1 = CreateToken( + TenantA, + "session-token-1", + user, + chain, + session); + + var token2 = CreateToken( + TenantA, + "session-token-2", + user, + chain, + session); + + await StoreAsync(store, token1, token2); + + await store.ExecuteAsync( + ct => store.RevokeBySessionAsync( + session, + Now.AddMinutes(10), + ct)); + + (await store.FindByHashAsync(token1.TokenHash))! + .IsRevoked.Should().BeTrue(); + + (await store.FindByHashAsync(token2.TokenHash))! + .IsRevoked.Should().BeTrue(); + } + + [Fact] + public async Task RevokeBySessionAsync_DoesNotAffectOtherSessions() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + var chain = SessionChainId.New(); + + var targetSession = + TestIds.Session("target-session"); + + var otherSession = + TestIds.Session("other-session"); + + var target = CreateToken( + TenantA, + "target-session-token", + user, + chain, + targetSession); + + var other = CreateToken( + TenantA, + "other-session-token", + user, + chain, + otherSession); + + await StoreAsync(store, target, other); + + await store.ExecuteAsync( + ct => store.RevokeBySessionAsync( + targetSession, + Now.AddMinutes(10), + ct)); + + (await store.FindByHashAsync(target.TokenHash))! + .IsRevoked.Should().BeTrue(); + + (await store.FindByHashAsync(other.TokenHash))! + .IsRevoked.Should().BeFalse(); + } + + // ============================================================ + // REVOKE BY CHAIN + // ============================================================ + + [Fact] + public async Task RevokeByChainAsync_RevokesAllTokensForChain() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + var chain = SessionChainId.New(); + + var token1 = CreateToken( + TenantA, + "chain-token-1", + user, + chain, + TestIds.Session("chain-session-1")); + + var token2 = CreateToken( + TenantA, + "chain-token-2", + user, + chain, + TestIds.Session("chain-session-2")); + + await StoreAsync(store, token1, token2); + + await store.ExecuteAsync( + ct => store.RevokeByChainAsync( + chain, + Now.AddMinutes(10), + ct)); + + (await store.FindByHashAsync(token1.TokenHash))! + .IsRevoked.Should().BeTrue(); + + (await store.FindByHashAsync(token2.TokenHash))! + .IsRevoked.Should().BeTrue(); + } + + [Fact] + public async Task RevokeByChainAsync_DoesNotAffectOtherChains() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + + var targetChain = SessionChainId.New(); + var otherChain = SessionChainId.New(); + + var target = CreateToken( + TenantA, + "target-chain-token", + user, + targetChain, + TestIds.Session("target-chain-session")); + + var other = CreateToken( + TenantA, + "other-chain-token", + user, + otherChain, + TestIds.Session("other-chain-session")); + + await StoreAsync(store, target, other); + + await store.ExecuteAsync( + ct => store.RevokeByChainAsync( + targetChain, + Now.AddMinutes(10), + ct)); + + (await store.FindByHashAsync(target.TokenHash))! + .IsRevoked.Should().BeTrue(); + + (await store.FindByHashAsync(other.TokenHash))! + .IsRevoked.Should().BeFalse(); + } + + // ============================================================ + // REVOKE USER + // ============================================================ + + [Fact] + public async Task RevokeAllForUserAsync_RevokesTokensAcrossUsersChains() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + + var token1 = CreateToken( + TenantA, + "user-token-1", + user, + SessionChainId.New(), + TestIds.Session("user-session-1")); + + var token2 = CreateToken( + TenantA, + "user-token-2", + user, + SessionChainId.New(), + TestIds.Session("user-session-2")); + + await StoreAsync(store, token1, token2); + + await store.ExecuteAsync( + ct => store.RevokeAllForUserAsync( + user, + Now.AddMinutes(10), + ct)); + + (await store.FindByHashAsync(token1.TokenHash))! + .IsRevoked.Should().BeTrue(); + + (await store.FindByHashAsync(token2.TokenHash))! + .IsRevoked.Should().BeTrue(); + } + + [Fact] + public async Task RevokeAllForUserAsync_DoesNotAffectOtherUsers() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var targetUser = UserKey.New(); + var otherUser = UserKey.New(); + + var target = CreateToken( + TenantA, + "target-user-token", + targetUser); + + var other = CreateToken( + TenantA, + "other-user-token", + otherUser); + + await StoreAsync(store, target, other); + + await store.ExecuteAsync( + ct => store.RevokeAllForUserAsync( + targetUser, + Now.AddMinutes(10), + ct)); + + (await store.FindByHashAsync(target.TokenHash))! + .IsRevoked.Should().BeTrue(); + + (await store.FindByHashAsync(other.TokenHash))! + .IsRevoked.Should().BeFalse(); + } + + // ============================================================ + // TENANT ISOLATION + // ============================================================ + + [Fact] + public async Task RevokeAsync_IsTenantIsolated() + { + await using var db = await CreateDatabaseAsync(); + + var storeA = db.CreateStore(TenantA); + var storeB = db.CreateStore(TenantB); + + /* + * Deliberately same hash. + * Identity is (Tenant, TokenHash). + */ + + var tokenA = CreateToken( + TenantA, + "same-token-hash"); + + var tokenB = CreateToken( + TenantB, + "same-token-hash"); + + await StoreAsync(storeA, tokenA); + await StoreAsync(storeB, tokenB); + + await storeA.ExecuteAsync( + ct => storeA.RevokeAsync( + tokenA.TokenHash, + Now.AddMinutes(10), + ct: ct)); + + var persistedA = + await storeA.FindByHashAsync(tokenA.TokenHash); + + var persistedB = + await storeB.FindByHashAsync(tokenB.TokenHash); + + persistedA!.IsRevoked.Should().BeTrue(); + persistedB!.IsRevoked.Should().BeFalse(); + } + + [Fact] + public async Task RevokeBySessionAsync_IsTenantIsolated() + { + await using var db = await CreateDatabaseAsync(); + + var storeA = db.CreateStore(TenantA); + var storeB = db.CreateStore(TenantB); + + var session = + TestIds.Session("shared-session"); + + var tokenA = CreateToken( + TenantA, + "tenant-a-session-token", + sessionId: session); + + var tokenB = CreateToken( + TenantB, + "tenant-b-session-token", + sessionId: session); + + await StoreAsync(storeA, tokenA); + await StoreAsync(storeB, tokenB); + + await storeA.ExecuteAsync( + ct => storeA.RevokeBySessionAsync( + session, + Now.AddMinutes(10), + ct)); + + (await storeA.FindByHashAsync(tokenA.TokenHash))! + .IsRevoked.Should().BeTrue(); + + (await storeB.FindByHashAsync(tokenB.TokenHash))! + .IsRevoked.Should().BeFalse(); + } + + [Fact] + public async Task RevokeByChainAsync_IsTenantIsolated() + { + await using var db = await CreateDatabaseAsync(); + + var storeA = db.CreateStore(TenantA); + var storeB = db.CreateStore(TenantB); + + var chain = SessionChainId.New(); + + var tokenA = CreateToken( + TenantA, + "tenant-a-chain-token", + chainId: chain); + + var tokenB = CreateToken( + TenantB, + "tenant-b-chain-token", + chainId: chain); + + await StoreAsync(storeA, tokenA); + await StoreAsync(storeB, tokenB); + + await storeA.ExecuteAsync( + ct => storeA.RevokeByChainAsync( + chain, + Now.AddMinutes(10), + ct)); + + (await storeA.FindByHashAsync(tokenA.TokenHash))! + .IsRevoked.Should().BeTrue(); + + (await storeB.FindByHashAsync(tokenB.TokenHash))! + .IsRevoked.Should().BeFalse(); + } + + [Fact] + public async Task RevokeAllForUserAsync_IsTenantIsolated() + { + await using var db = await CreateDatabaseAsync(); + + var storeA = db.CreateStore(TenantA); + var storeB = db.CreateStore(TenantB); + + var user = UserKey.New(); + + var tokenA = CreateToken( + TenantA, + "tenant-a-user-token", + user); + + var tokenB = CreateToken( + TenantB, + "tenant-b-user-token", + user); + + await StoreAsync(storeA, tokenA); + await StoreAsync(storeB, tokenB); + + await storeA.ExecuteAsync( + ct => storeA.RevokeAllForUserAsync( + user, + Now.AddMinutes(10), + ct)); + + (await storeA.FindByHashAsync(tokenA.TokenHash))! + .IsRevoked.Should().BeTrue(); + + (await storeB.FindByHashAsync(tokenB.TokenHash))! + .IsRevoked.Should().BeFalse(); + } + + // ============================================================ + // IDEMPOTENCY / ALREADY REVOKED + // ============================================================ + + [Fact] + public async Task BulkRevoke_DoesNotChangePreviouslyRevokedToken() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + + var token = CreateToken( + TenantA, + "previously-revoked", + user); + + await StoreAsync(store, token); + + var firstRevocation = Now.AddMinutes(5); + + await store.ExecuteAsync( + ct => store.RevokeAsync( + token.TokenHash, + firstRevocation, + "replacement-token", + ct)); + + await store.ExecuteAsync( + ct => store.RevokeAllForUserAsync( + user, + Now.AddMinutes(20), + ct)); + + var persisted = + await store.FindByHashAsync(token.TokenHash); + + persisted!.RevokedAt.Should().Be(firstRevocation); + + persisted.ReplacedByTokenHash + .Should().Be("replacement-token"); + } + + // ============================================================ + // CANCELLATION + // ============================================================ + + [Fact] + public async Task FindByHashAsync_WhenCancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => store.FindByHashAsync( + "token", + cts.Token); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task StoreAsync_WhenCancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var token = CreateToken( + TenantA, + "cancelled-store"); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => store.ExecuteAsync( + ct => store.StoreAsync(token, ct), + cts.Token); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task StoreAsync_WhenChainIdIsNull_PersistsToken() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var token = RefreshToken.Create( + tokenId: TokenId.New(), + tokenHash: "token-without-chain", + tenant: TenantA, + userKey: UserKey.New(), + sessionId: TestIds.Session("session-without-chain"), + chainId: null, + createdAt: Now, + expiresAt: Now.AddDays(30)); + + await StoreAsync(store, token); + + var persisted = + await store.FindByHashAsync(token.TokenHash); + + persisted.Should().NotBeNull(); + persisted!.ChainId.Should().BeNull(); + persisted.TokenId.Should().Be(token.TokenId); + } + + // ============================================================ + // HELPERS + // ============================================================ + + protected static Task StoreAsync( + IRefreshTokenStore store, + params RefreshToken[] tokens) + { + return store.ExecuteAsync(async ct => + { + foreach (var token in tokens) + await store.StoreAsync(token, ct); + }); + } + + protected static RefreshToken CreateToken( + TenantKey tenant, + string hash, + UserKey? userKey = null, + SessionChainId? chainId = null, + AuthSessionId? sessionId = null) + { + var user = userKey ?? UserKey.New(); + var chain = chainId ?? SessionChainId.New(); + var session = + sessionId ?? TestIds.Session($"session-{hash}"); + + return RefreshToken.Create( + tokenId: TokenId.New(), + tokenHash: hash, + tenant: tenant, + userKey: user, + sessionId: session, + chainId: chain, + createdAt: Now, + expiresAt: Now.AddDays(30)); + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/EfCoreUserIdentifierStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/EfCoreUserIdentifierStoreContractTests.cs new file mode 100644 index 00000000..e0cca736 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/EfCoreUserIdentifierStoreContractTests.cs @@ -0,0 +1,55 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; +using CodeBeam.UltimateAuth.Users.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +public sealed class EfCoreUserIdentifierStoreContractTests + : UserIdentifierStoreContractTests +{ + protected override async Task + CreateDatabaseAsync() + { + var db = new Database(); + await db.InitializeAsync(); + return db; + } + + private sealed class Database : IUserIdentifierStoreTestDatabase + { + private readonly SqliteConnection _connection; + private readonly UAuthUserDbContext _db; + + public Database() + { + _connection = new SqliteConnection("Data Source=:memory:"); + + var options = + new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _db = new UAuthUserDbContext(options); + } + + public async Task InitializeAsync() + { + await _connection.OpenAsync(); + await _db.Database.EnsureCreatedAsync(); + } + + public IUserIdentifierStore CreateStore(TenantKey tenant) + { + return new EfCoreUserIdentifierStore( + _db, + new TenantExecutionContext(tenant)); + } + + public async ValueTask DisposeAsync() + { + await _db.DisposeAsync(); + await _connection.DisposeAsync(); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/EfCoreUserLifecycleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/EfCoreUserLifecycleStoreContractTests.cs new file mode 100644 index 00000000..d6ddf889 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/EfCoreUserLifecycleStoreContractTests.cs @@ -0,0 +1,57 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; + +public sealed class EfCoreUserLifecycleStoreContractTests + : UserLifecycleStoreContractTests +{ + protected override async Task + CreateDatabaseAsync() + { + var database = new Database(); + await database.InitializeAsync(); + return database; + } + + private sealed class Database : IUserLifecycleStoreTestDatabase + { + private readonly SqliteConnection _connection; + private readonly UAuthUserDbContext _db; + + public Database() + { + _connection = new SqliteConnection( + "Data Source=:memory:"); + + var options = + new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _db = new UAuthUserDbContext(options); + } + + public async Task InitializeAsync() + { + await _connection.OpenAsync(); + await _db.Database.EnsureCreatedAsync(); + } + + public IUserLifecycleStore CreateStore(TenantKey tenant) + { + return new EfCoreUserLifecycleStore( + _db, + new TenantExecutionContext(tenant)); + } + + public async ValueTask DisposeAsync() + { + await _db.DisposeAsync(); + await _connection.DisposeAsync(); + } + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/EfCoreUserProfileStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/EfCoreUserProfileStoreContractTests.cs new file mode 100644 index 00000000..9534cd8e --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/EfCoreUserProfileStoreContractTests.cs @@ -0,0 +1,57 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; + +public sealed class EfCoreUserProfileStoreContractTests + : UserProfileStoreContractTests +{ + protected override async Task + CreateDatabaseAsync() + { + var database = new Database(); + await database.InitializeAsync(); + return database; + } + + private sealed class Database : IUserProfileStoreTestDatabase + { + private readonly SqliteConnection _connection; + private readonly UAuthUserDbContext _db; + + public Database() + { + _connection = + new SqliteConnection("Data Source=:memory:"); + + var options = + new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _db = new UAuthUserDbContext(options); + } + + public async Task InitializeAsync() + { + await _connection.OpenAsync(); + await _db.Database.EnsureCreatedAsync(); + } + + public IUserProfileStore CreateStore(TenantKey tenant) + { + return new EfCoreUserProfileStore( + _db, + new TenantExecutionContext(tenant)); + } + + public async ValueTask DisposeAsync() + { + await _db.DisposeAsync(); + await _connection.DisposeAsync(); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/IUserIdentifierStoreTestDatabase.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/IUserIdentifierStoreTestDatabase.cs new file mode 100644 index 00000000..66d476b4 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/IUserIdentifierStoreTestDatabase.cs @@ -0,0 +1,9 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; + +public interface IUserIdentifierStoreTestDatabase : IAsyncDisposable +{ + IUserIdentifierStore CreateStore(TenantKey tenant); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/IUserLifecycleStoreTestDatabase.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/IUserLifecycleStoreTestDatabase.cs new file mode 100644 index 00000000..889dba61 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/IUserLifecycleStoreTestDatabase.cs @@ -0,0 +1,9 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; + +public interface IUserLifecycleStoreTestDatabase : IAsyncDisposable +{ + IUserLifecycleStore CreateStore(TenantKey tenant); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/IUserProfileStoreTestDatabase.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/IUserProfileStoreTestDatabase.cs new file mode 100644 index 00000000..eda8266f --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/IUserProfileStoreTestDatabase.cs @@ -0,0 +1,9 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; + +public interface IUserProfileStoreTestDatabase : IAsyncDisposable +{ + IUserProfileStore CreateStore(TenantKey tenant); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserIdentifierStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserIdentifierStoreContractTests.cs new file mode 100644 index 00000000..0ecec14f --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserIdentifierStoreContractTests.cs @@ -0,0 +1,37 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.InMemory; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; + +public sealed class InMemoryUserIdentifierStoreContractTests + : UserIdentifierStoreContractTests +{ + protected override Task + CreateDatabaseAsync() + { + return Task.FromResult( + new Database()); + } + + private sealed class Database : IUserIdentifierStoreTestDatabase + { + private readonly Dictionary _stores = []; + + public IUserIdentifierStore CreateStore(TenantKey tenant) + { + if (_stores.TryGetValue(tenant, out var store)) + return store; + + store = new InMemoryUserIdentifierStore( + new TenantExecutionContext(tenant)); + + _stores.Add(tenant, store); + + return store; + } + + public ValueTask DisposeAsync() + => ValueTask.CompletedTask; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserLifecycleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserLifecycleStoreContractTests.cs new file mode 100644 index 00000000..367f4d25 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserLifecycleStoreContractTests.cs @@ -0,0 +1,37 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.InMemory; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; + +public sealed class InMemoryUserLifecycleStoreContractTests + : UserLifecycleStoreContractTests +{ + protected override Task + CreateDatabaseAsync() + { + return Task.FromResult( + new Database()); + } + + private sealed class Database : IUserLifecycleStoreTestDatabase + { + private readonly Dictionary _stores = []; + + public IUserLifecycleStore CreateStore(TenantKey tenant) + { + if (_stores.TryGetValue(tenant, out var existing)) + return existing; + + var store = new InMemoryUserLifecycleStore( + new TenantExecutionContext(tenant)); + + _stores.Add(tenant, store); + + return store; + } + + public ValueTask DisposeAsync() + => ValueTask.CompletedTask; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserProfileStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserProfileStoreContractTests.cs new file mode 100644 index 00000000..0fd7f8ca --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/InMemoryUserProfileStoreContractTests.cs @@ -0,0 +1,38 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.InMemory; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; + +public sealed class InMemoryUserProfileStoreContractTests + : UserProfileStoreContractTests +{ + protected override Task + CreateDatabaseAsync() + { + return Task.FromResult( + new Database()); + } + + private sealed class Database : IUserProfileStoreTestDatabase + { + private readonly Dictionary _stores = []; + + public IUserProfileStore CreateStore(TenantKey tenant) + { + if (_stores.TryGetValue(tenant, out var existing)) + return existing; + + var store = new InMemoryUserProfileStore( + new TenantExecutionContext(tenant)); + + _stores.Add(tenant, store); + + return store; + } + + public ValueTask DisposeAsync() + => ValueTask.CompletedTask; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserIdentifierStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserIdentifierStoreContractTests.cs new file mode 100644 index 00000000..d735cb71 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserIdentifierStoreContractTests.cs @@ -0,0 +1,922 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; + +public abstract class UserIdentifierStoreContractTests +{ + protected abstract Task CreateDatabaseAsync(); + + protected static readonly TenantKey TenantA = + TenantKey.FromExternal("tenant-a"); + + protected static readonly TenantKey TenantB = + TenantKey.FromExternal("tenant-b"); + + protected static readonly DateTimeOffset Now = + new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + protected static UserIdentifier CreateIdentifier( + TenantKey tenant, + UserKey? userKey = null, + UserIdentifierType type = UserIdentifierType.Email, + string value = "user@example.com", + string normalizedValue = "USER@EXAMPLE.COM", + bool isPrimary = false, + DateTimeOffset? createdAt = null) + { + return UserIdentifier.Create( + id: null, + tenant: tenant, + userKey: userKey ?? UserKey.New(), + type: type, + value: value, + normalizedValue: normalizedValue, + now: createdAt ?? Now, + isPrimary: isPrimary); + } + + // ============================================================ + // ADD / GET / EXISTS + // ============================================================ + + [Fact] + public async Task AddAsync_PersistsIdentifier() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var identifier = CreateIdentifier( + TenantA, + isPrimary: true); + + await store.AddAsync(identifier); + + var persisted = await store.GetByIdAsync(identifier.Id); + + persisted.Should().NotBeNull(); + persisted!.Id.Should().Be(identifier.Id); + persisted.Tenant.Should().Be(TenantA); + persisted.UserKey.Should().Be(identifier.UserKey); + persisted.Type.Should().Be(identifier.Type); + persisted.Value.Should().Be(identifier.Value); + persisted.NormalizedValue.Should().Be(identifier.NormalizedValue); + persisted.IsPrimary.Should().BeTrue(); + persisted.CreatedAt.Should().Be(Now); + persisted.Version.Should().Be(0); + } + + [Fact] + public async Task GetByIdAsync_WhenMissing_ReturnsNull() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var result = await store.GetByIdAsync(Guid.NewGuid()); + + result.Should().BeNull(); + } + + [Fact] + public async Task GetAsync_ByTypeAndNormalizedValue_ReturnsIdentifier() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var identifier = CreateIdentifier(TenantA); + + await store.AddAsync(identifier); + + var result = await store.GetAsync( + UserIdentifierType.Email, + "USER@EXAMPLE.COM"); + + result.Should().NotBeNull(); + result!.Id.Should().Be(identifier.Id); + } + + [Fact] + public async Task GetAsync_ByTypeAndNormalizedValue_ExcludesDeleted() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var identifier = CreateIdentifier(TenantA); + + await store.AddAsync(identifier); + + await store.DeleteAsync( + identifier.Id, + expectedVersion: 0, + DeleteMode.Soft, + Now.AddHours(1)); + + var result = await store.GetAsync( + identifier.Type, + identifier.NormalizedValue); + + result.Should().BeNull(); + } + + [Fact] + public async Task GetByUserAsync_ReturnsOnlyActiveIdentifiersForUser() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var userA = UserKey.New(); + var userB = UserKey.New(); + + var first = CreateIdentifier( + TenantA, + userA, + value: "a@example.com", + normalizedValue: "A@EXAMPLE.COM"); + + var second = CreateIdentifier( + TenantA, + userA, + type: UserIdentifierType.Username, + value: "alice", + normalizedValue: "ALICE"); + + var otherUser = CreateIdentifier( + TenantA, + userB, + value: "b@example.com", + normalizedValue: "B@EXAMPLE.COM"); + + await store.AddAsync(first); + await store.AddAsync(second); + await store.AddAsync(otherUser); + + await store.DeleteAsync( + second.Id, + 0, + DeleteMode.Soft, + Now.AddHours(1)); + + var result = await store.GetByUserAsync(userA); + + result.Should().ContainSingle(); + result.Single().Id.Should().Be(first.Id); + } + + // ============================================================ + // SAVE + // ============================================================ + + [Fact] + public async Task SaveAsync_PersistsChangesAndIncrementsVersion() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var identifier = CreateIdentifier(TenantA); + + await store.AddAsync(identifier); + + var changedAt = Now.AddMinutes(10); + + identifier.ChangeValue( + "changed@example.com", + "CHANGED@EXAMPLE.COM", + changedAt); + + await store.SaveAsync( + identifier, + expectedVersion: 0); + + var persisted = await store.GetByIdAsync(identifier.Id); + + persisted.Should().NotBeNull(); + persisted!.Value.Should().Be("changed@example.com"); + persisted.NormalizedValue.Should().Be("CHANGED@EXAMPLE.COM"); + persisted.UpdatedAt.Should().Be(changedAt); + persisted.Version.Should().Be(1); + } + + [Fact] + public async Task SaveAsync_WhenMissing_ThrowsNotFound() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var identifier = CreateIdentifier(TenantA); + + var act = () => store.SaveAsync(identifier, 0); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task SaveAsync_WhenVersionIsStale_ThrowsConcurrency() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var identifier = CreateIdentifier(TenantA); + + await store.AddAsync(identifier); + + identifier.MarkVerified(Now.AddMinutes(1)); + await store.SaveAsync(identifier, 0); + + var act = () => store.SaveAsync( + identifier, + expectedVersion: 0); + + await act.Should() + .ThrowAsync(); + } + + // ============================================================ + // PRIMARY + // ============================================================ + + [Fact] + public async Task AddAsync_PrimaryIdentifier_UnsetsExistingPrimaryOfSameType() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + + var first = CreateIdentifier( + TenantA, + user, + value: "first@example.com", + normalizedValue: "FIRST@EXAMPLE.COM", + isPrimary: true); + + var second = CreateIdentifier( + TenantA, + user, + value: "second@example.com", + normalizedValue: "SECOND@EXAMPLE.COM", + isPrimary: true); + + await store.AddAsync(first); + await store.AddAsync(second); + + var persistedFirst = await store.GetByIdAsync(first.Id); + var persistedSecond = await store.GetByIdAsync(second.Id); + + persistedFirst!.IsPrimary.Should().BeFalse(); + persistedSecond!.IsPrimary.Should().BeTrue(); + } + + [Fact] + public async Task AddAsync_PrimaryIdentifier_DoesNotUnsetPrimaryOfDifferentType() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + + var email = CreateIdentifier( + TenantA, + user, + UserIdentifierType.Email, + "a@example.com", + "A@EXAMPLE.COM", + isPrimary: true); + + var username = CreateIdentifier( + TenantA, + user, + UserIdentifierType.Username, + "alice", + "ALICE", + isPrimary: true); + + await store.AddAsync(email); + await store.AddAsync(username); + + (await store.GetByIdAsync(email.Id))! + .IsPrimary.Should().BeTrue(); + + (await store.GetByIdAsync(username.Id))! + .IsPrimary.Should().BeTrue(); + } + + [Fact] + public async Task SaveAsync_SetPrimary_UnsetsExistingPrimaryOfSameType() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + + var first = CreateIdentifier( + TenantA, + user, + value: "first@example.com", + normalizedValue: "FIRST@EXAMPLE.COM", + isPrimary: true); + + var second = CreateIdentifier( + TenantA, + user, + value: "second@example.com", + normalizedValue: "SECOND@EXAMPLE.COM"); + + await store.AddAsync(first); + await store.AddAsync(second); + + second.SetPrimary(Now.AddMinutes(1)); + + await store.SaveAsync(second, 0); + + (await store.GetByIdAsync(first.Id))! + .IsPrimary.Should().BeFalse(); + + (await store.GetByIdAsync(second.Id))! + .IsPrimary.Should().BeTrue(); + } + + // ============================================================ + // DELETE + // ============================================================ + + [Fact] + public async Task DeleteAsync_Soft_PreservesRecordAndClearsPrimary() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var identifier = CreateIdentifier( + TenantA, + isPrimary: true); + + await store.AddAsync(identifier); + + var deletedAt = Now.AddHours(1); + + await store.DeleteAsync( + identifier.Id, + 0, + DeleteMode.Soft, + deletedAt); + + var persisted = await store.GetByIdAsync(identifier.Id); + + persisted.Should().NotBeNull(); + persisted!.IsDeleted.Should().BeTrue(); + persisted.DeletedAt.Should().Be(deletedAt); + persisted.IsPrimary.Should().BeFalse(); + persisted.Version.Should().Be(1); + } + + [Fact] + public async Task DeleteAsync_Hard_RemovesIdentifier() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var identifier = CreateIdentifier(TenantA); + + await store.AddAsync(identifier); + + await store.DeleteAsync( + identifier.Id, + 0, + DeleteMode.Hard, + Now); + + (await store.GetByIdAsync(identifier.Id)) + .Should().BeNull(); + } + + [Fact] + public async Task DeleteAsync_WhenVersionIsStale_ThrowsConcurrency() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var identifier = CreateIdentifier(TenantA); + + await store.AddAsync(identifier); + + identifier.MarkVerified(Now.AddMinutes(1)); + await store.SaveAsync(identifier, 0); + + var act = () => store.DeleteAsync( + identifier.Id, + expectedVersion: 0, + DeleteMode.Soft, + Now.AddHours(1)); + + await act.Should() + .ThrowAsync(); + } + + // ============================================================ + // TENANT + // ============================================================ + + [Fact] + public async Task AddAsync_WhenEntityBelongsToDifferentTenant_IsRejected() + { + await using var db = await CreateDatabaseAsync(); + + var store = db.CreateStore(TenantA); + var identifier = CreateIdentifier(TenantB); + + var act = () => store.AddAsync(identifier); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task SaveAsync_WhenEntityBelongsToDifferentTenant_IsRejected() + { + await using var db = await CreateDatabaseAsync(); + + var storeA = db.CreateStore(TenantA); + var storeB = db.CreateStore(TenantB); + + var identifier = CreateIdentifier(TenantB); + await storeB.AddAsync(identifier); + + var act = () => storeA.SaveAsync( + identifier, + expectedVersion: 0); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task GetByIdAsync_IsTenantIsolated() + { + await using var db = await CreateDatabaseAsync(); + + var storeA = db.CreateStore(TenantA); + var storeB = db.CreateStore(TenantB); + + var identifier = CreateIdentifier(TenantB); + await storeB.AddAsync(identifier); + + var result = await storeA.GetByIdAsync(identifier.Id); + + result.Should().BeNull(); + } + + // ============================================================ + // CANCELLATION + // ============================================================ + + [Fact] + public async Task AddAsync_WhenCancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => store.AddAsync( + CreateIdentifier(TenantA), + cts.Token); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task GetByIdAsync_WhenCancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => store.GetByIdAsync( + Guid.NewGuid(), + cts.Token); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task QueryAsync_WhenCancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => store.QueryAsync( + new UserIdentifierQuery + { + UserKey = UserKey.New() + }, + cts.Token); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task ExistsAsync_TenantAny_FindsMatchingIdentifier() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var identifier = CreateIdentifier(TenantA); + await store.AddAsync(identifier); + + var result = await store.ExistsAsync( + new IdentifierExistenceQuery( + identifier.Type, + identifier.NormalizedValue, + IdentifierExistenceScope.TenantAny)); + + result.Exists.Should().BeTrue(); + result.OwnerUserKey.Should().Be(identifier.UserKey); + result.OwnerIdentifierId.Should().Be(identifier.Id); + } + + [Fact] + public async Task ExistsAsync_WithinUser_DoesNotMatchOtherUser() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var identifier = CreateIdentifier(TenantA); + await store.AddAsync(identifier); + + var result = await store.ExistsAsync( + new IdentifierExistenceQuery( + identifier.Type, + identifier.NormalizedValue, + IdentifierExistenceScope.WithinUser, + UserKey: UserKey.New())); + + result.Exists.Should().BeFalse(); + } + + [Fact] + public async Task ExistsAsync_TenantPrimaryOnly_IgnoresNonPrimary() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var identifier = CreateIdentifier( + TenantA, + isPrimary: false); + + await store.AddAsync(identifier); + + var result = await store.ExistsAsync( + new IdentifierExistenceQuery( + identifier.Type, + identifier.NormalizedValue, + IdentifierExistenceScope.TenantPrimaryOnly)); + + result.Exists.Should().BeFalse(); + } + + [Fact] + public async Task ExistsAsync_ExcludeIdentifierId_ExcludesCurrentIdentifier() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var identifier = CreateIdentifier(TenantA); + await store.AddAsync(identifier); + + var result = await store.ExistsAsync( + new IdentifierExistenceQuery( + identifier.Type, + identifier.NormalizedValue, + IdentifierExistenceScope.TenantAny, + ExcludeIdentifierId: identifier.Id)); + + result.Exists.Should().BeFalse(); + } + + [Fact] + public async Task ExistsAsync_DoesNotMatchDeletedIdentifier() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var identifier = CreateIdentifier(TenantA); + await store.AddAsync(identifier); + + await store.DeleteAsync( + identifier.Id, + 0, + DeleteMode.Soft, + Now.AddHours(1)); + + var result = await store.ExistsAsync( + new IdentifierExistenceQuery( + identifier.Type, + identifier.NormalizedValue, + IdentifierExistenceScope.TenantAny)); + + result.Exists.Should().BeFalse(); + } + + [Fact] + public async Task GetByUsersAsync_ReturnsActiveIdentifiersForRequestedUsersOnly() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var userA = UserKey.New(); + var userB = UserKey.New(); + var userC = UserKey.New(); + + var a = CreateIdentifier( + TenantA, userA, + value: "a@example.com", + normalizedValue: "A@EXAMPLE.COM"); + + var b = CreateIdentifier( + TenantA, userB, + value: "b@example.com", + normalizedValue: "B@EXAMPLE.COM"); + + var c = CreateIdentifier( + TenantA, userC, + value: "c@example.com", + normalizedValue: "C@EXAMPLE.COM"); + + await store.AddAsync(a); + await store.AddAsync(b); + await store.AddAsync(c); + + var result = await store.GetByUsersAsync( + [userA, userB]); + + result.Should().HaveCount(2); + result.Select(x => x.UserKey) + .Should() + .BeEquivalentTo([userA, userB]); + } + + [Fact] + public async Task DeleteByUserAsync_Soft_DeletesOnlySpecifiedUsersIdentifiers() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var userA = UserKey.New(); + var userB = UserKey.New(); + + var a1 = CreateIdentifier( + TenantA, userA, + value: "a1@example.com", + normalizedValue: "A1@EXAMPLE.COM", + isPrimary: true); + + var a2 = CreateIdentifier( + TenantA, userA, + UserIdentifierType.Username, + "alice", + "ALICE", + isPrimary: true); + + var b = CreateIdentifier( + TenantA, userB, + value: "b@example.com", + normalizedValue: "B@EXAMPLE.COM"); + + await store.AddAsync(a1); + await store.AddAsync(a2); + await store.AddAsync(b); + + var deletedAt = Now.AddHours(1); + + await store.DeleteByUserAsync( + userA, + DeleteMode.Soft, + deletedAt); + + var query = await store.QueryAsync( + new UserIdentifierQuery + { + UserKey = userA, + IncludeDeleted = true + }); + + query.Items.Should().HaveCount(2); + query.Items.Should().OnlyContain(x => + x.IsDeleted && + x.DeletedAt == deletedAt && + !x.IsPrimary); + + (await store.GetByUserAsync(userB)) + .Should() + .ContainSingle(); + } + + [Fact] + public async Task DeleteByUserAsync_Hard_RemovesOnlySpecifiedUsersIdentifiers() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var userA = UserKey.New(); + var userB = UserKey.New(); + + var a = CreateIdentifier( + TenantA, userA, + value: "a@example.com", + normalizedValue: "A@EXAMPLE.COM"); + + var b = CreateIdentifier( + TenantA, userB, + value: "b@example.com", + normalizedValue: "B@EXAMPLE.COM"); + + await store.AddAsync(a); + await store.AddAsync(b); + + await store.DeleteByUserAsync( + userA, + DeleteMode.Hard, + Now); + + (await store.GetByIdAsync(a.Id)).Should().BeNull(); + (await store.GetByIdAsync(b.Id)).Should().NotBeNull(); + } + + [Fact] + public async Task QueryAsync_WhenUserKeyMissing_ThrowsValidation() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var act = () => store.QueryAsync( + new UserIdentifierQuery()); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task QueryAsync_ByDefault_ExcludesDeleted() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + + var active = CreateIdentifier( + TenantA, user, + value: "active@example.com", + normalizedValue: "ACTIVE@EXAMPLE.COM"); + + var deleted = CreateIdentifier( + TenantA, user, + value: "deleted@example.com", + normalizedValue: "DELETED@EXAMPLE.COM"); + + await store.AddAsync(active); + await store.AddAsync(deleted); + + await store.DeleteAsync( + deleted.Id, + 0, + DeleteMode.Soft, + Now.AddHours(1)); + + var result = await store.QueryAsync( + new UserIdentifierQuery + { + UserKey = user + }); + + result.Items.Should().ContainSingle(); + result.Items.Single().Id.Should().Be(active.Id); + result.TotalCount.Should().Be(1); + } + + [Fact] + public async Task QueryAsync_IncludeDeleted_ReturnsDeletedIdentifiers() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + + var active = CreateIdentifier( + TenantA, user, + value: "active@example.com", + normalizedValue: "ACTIVE@EXAMPLE.COM"); + + var deleted = CreateIdentifier( + TenantA, user, + value: "deleted@example.com", + normalizedValue: "DELETED@EXAMPLE.COM"); + + await store.AddAsync(active); + await store.AddAsync(deleted); + + await store.DeleteAsync( + deleted.Id, + 0, + DeleteMode.Soft, + Now.AddHours(1)); + + var result = await store.QueryAsync( + new UserIdentifierQuery + { + UserKey = user, + IncludeDeleted = true + }); + + result.Items.Should().HaveCount(2); + result.TotalCount.Should().Be(2); + } + + [Fact] + public async Task QueryAsync_AppliesPagination() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + + for (var i = 0; i < 5; i++) + { + await store.AddAsync( + CreateIdentifier( + TenantA, + user, + value: $"user{i}@example.com", + normalizedValue: $"USER{i}@EXAMPLE.COM", + createdAt: Now.AddMinutes(i))); + } + + var result = await store.QueryAsync( + new UserIdentifierQuery + { + UserKey = user, + PageNumber = 2, + PageSize = 2, + SortBy = nameof(UserIdentifier.CreatedAt) + }); + + result.TotalCount.Should().Be(5); + result.Items.Should().HaveCount(2); + result.PageNumber.Should().Be(2); + result.PageSize.Should().Be(2); + + result.Items[0].CreatedAt.Should().Be(Now.AddMinutes(2)); + result.Items[1].CreatedAt.Should().Be(Now.AddMinutes(3)); + } + + [Fact] + public async Task QueryAsync_SortByNormalizedValue_IsSupported() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + + await store.AddAsync(CreateIdentifier( + TenantA, user, + value: "c@example.com", + normalizedValue: "C@EXAMPLE.COM")); + + await store.AddAsync(CreateIdentifier( + TenantA, user, + value: "a@example.com", + normalizedValue: "A@EXAMPLE.COM")); + + await store.AddAsync(CreateIdentifier( + TenantA, user, + value: "b@example.com", + normalizedValue: "B@EXAMPLE.COM")); + + var result = await store.QueryAsync( + new UserIdentifierQuery + { + UserKey = user, + SortBy = nameof(UserIdentifier.NormalizedValue) + }); + + result.Items.Select(x => x.NormalizedValue) + .Should() + .ContainInOrder( + "A@EXAMPLE.COM", + "B@EXAMPLE.COM", + "C@EXAMPLE.COM"); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserLifecycleStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserLifecycleStoreContractTests.cs new file mode 100644 index 00000000..dd4e823f --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserLifecycleStoreContractTests.cs @@ -0,0 +1,677 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Reference; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; + +public abstract class UserLifecycleStoreContractTests +{ + protected abstract Task CreateDatabaseAsync(); + + protected static readonly TenantKey TenantA = + TenantKey.FromExternal("tenant-a"); + + protected static readonly TenantKey TenantB = + TenantKey.FromExternal("tenant-b"); + + protected static readonly DateTimeOffset Now = + new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + protected static UserLifecycle CreateLifecycle( + TenantKey tenant, + UserKey? userKey = null, + DateTimeOffset? createdAt = null) + { + return UserLifecycle.Create( + tenant, + userKey ?? UserKey.New(), + createdAt ?? Now); + } + + // ============================================================ + // ADD / GET / EXISTS + // ============================================================ + + [Fact] + public async Task AddAsync_PersistsLifecycle() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var lifecycle = CreateLifecycle(TenantA); + + await store.AddAsync(lifecycle); + + var persisted = await store.GetAsync( + new UserLifecycleKey(TenantA, lifecycle.UserKey)); + + persisted.Should().NotBeNull(); + persisted!.Id.Should().Be(lifecycle.Id); + persisted.Tenant.Should().Be(TenantA); + persisted.UserKey.Should().Be(lifecycle.UserKey); + persisted.Status.Should().Be(UserStatus.Active); + persisted.SecurityVersion.Should().Be(0); + persisted.CreatedAt.Should().Be(Now); + persisted.Version.Should().Be(0); + } + + [Fact] + public async Task GetAsync_WhenMissing_ReturnsNull() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var result = await store.GetAsync( + new UserLifecycleKey(TenantA, UserKey.New())); + + result.Should().BeNull(); + } + + [Fact] + public async Task ExistsAsync_WhenPresent_ReturnsTrue() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var lifecycle = CreateLifecycle(TenantA); + await store.AddAsync(lifecycle); + + var result = await store.ExistsAsync( + new UserLifecycleKey(TenantA, lifecycle.UserKey)); + + result.Should().BeTrue(); + } + + [Fact] + public async Task ExistsAsync_WhenMissing_ReturnsFalse() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var result = await store.ExistsAsync( + new UserLifecycleKey(TenantA, UserKey.New())); + + result.Should().BeFalse(); + } + + [Fact] + public async Task AddAsync_WhenSameUserAlreadyExists_ThrowsConflict() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + + await store.AddAsync(CreateLifecycle(TenantA, user)); + + var act = () => store.AddAsync( + CreateLifecycle(TenantA, user)); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task AddAsync_WhenVersionIsNotZero_IsRejected() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var lifecycle = CreateLifecycle(TenantA); + lifecycle.Version = 42; + + var act = () => store.AddAsync(lifecycle); + + await act.Should() + .ThrowAsync(); + } + + // ============================================================ + // SAVE + // ============================================================ + + [Fact] + public async Task SaveAsync_PersistsDomainChanges_AndIncrementsVersion() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var lifecycle = CreateLifecycle(TenantA); + await store.AddAsync(lifecycle); + + var changedAt = Now.AddMinutes(10); + + lifecycle.ChangeStatus( + changedAt, + UserStatus.Suspended); + + lifecycle.IncrementSecurityVersion(); + + await store.SaveAsync( + lifecycle, + expectedVersion: 0); + + var persisted = await store.GetAsync( + new UserLifecycleKey(TenantA, lifecycle.UserKey)); + + persisted.Should().NotBeNull(); + persisted!.Status.Should().Be(UserStatus.Suspended); + persisted.SecurityVersion.Should().Be(1); + persisted.UpdatedAt.Should().Be(changedAt); + persisted.Version.Should().Be(1); + } + + [Fact] + public async Task SaveAsync_WhenMissing_ThrowsNotFound() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var lifecycle = CreateLifecycle(TenantA); + + var act = () => store.SaveAsync( + lifecycle, + expectedVersion: 0); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task SaveAsync_WhenVersionIsStale_ThrowsConcurrency() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var lifecycle = CreateLifecycle(TenantA); + await store.AddAsync(lifecycle); + + lifecycle.IncrementSecurityVersion(); + + await store.SaveAsync(lifecycle, 0); + + lifecycle.IncrementSecurityVersion(); + + var act = () => store.SaveAsync( + lifecycle, + expectedVersion: 0); + + await act.Should() + .ThrowAsync(); + } + + // ============================================================ + // DELETE + // ============================================================ + + [Fact] + public async Task DeleteAsync_Soft_PreservesRecordAndDeletionState() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var lifecycle = CreateLifecycle(TenantA); + var key = new UserLifecycleKey(TenantA, lifecycle.UserKey); + + await store.AddAsync(lifecycle); + + await store.DeleteAsync( + key, + expectedVersion: 0, + DeleteMode.Soft, + Now.AddHours(1)); + + var persisted = await store.GetAsync(key); + + persisted.Should().NotBeNull(); + persisted!.IsDeleted.Should().BeTrue(); + persisted.DeletedAt.Should().Be(Now.AddHours(1)); + persisted.Version.Should().Be(1); + } + + [Fact] + public async Task DeleteAsync_Hard_RemovesRecord() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var lifecycle = CreateLifecycle(TenantA); + var key = new UserLifecycleKey(TenantA, lifecycle.UserKey); + + await store.AddAsync(lifecycle); + + await store.DeleteAsync( + key, + expectedVersion: 0, + DeleteMode.Hard, + Now); + + (await store.GetAsync(key)).Should().BeNull(); + (await store.ExistsAsync(key)).Should().BeFalse(); + } + + [Fact] + public async Task DeleteAsync_WhenMissing_ThrowsNotFound() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var act = () => store.DeleteAsync( + new UserLifecycleKey(TenantA, UserKey.New()), + 0, + DeleteMode.Soft, + Now); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task DeleteAsync_WhenVersionIsStale_ThrowsConcurrency() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var lifecycle = CreateLifecycle(TenantA); + var key = new UserLifecycleKey(TenantA, lifecycle.UserKey); + + await store.AddAsync(lifecycle); + + lifecycle.IncrementSecurityVersion(); + await store.SaveAsync(lifecycle, 0); + + var act = () => store.DeleteAsync( + key, + expectedVersion: 0, + DeleteMode.Soft, + Now); + + await act.Should() + .ThrowAsync(); + } + + // ============================================================ + // TENANT + // ============================================================ + + [Fact] + public async Task AddAsync_WhenEntityBelongsToDifferentTenant_IsRejected() + { + await using var db = await CreateDatabaseAsync(); + + var store = db.CreateStore(TenantA); + var lifecycle = CreateLifecycle(TenantB); + + var act = () => store.AddAsync(lifecycle); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task SaveAsync_WhenEntityBelongsToDifferentTenant_IsRejected() + { + await using var db = await CreateDatabaseAsync(); + + var storeA = db.CreateStore(TenantA); + var storeB = db.CreateStore(TenantB); + + var lifecycle = CreateLifecycle(TenantB); + await storeB.AddAsync(lifecycle); + + var act = () => storeA.SaveAsync( + lifecycle, + expectedVersion: 0); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task QueryAsync_IsTenantIsolated() + { + await using var db = await CreateDatabaseAsync(); + + var storeA = db.CreateStore(TenantA); + var storeB = db.CreateStore(TenantB); + + await storeA.AddAsync(CreateLifecycle(TenantA)); + await storeB.AddAsync(CreateLifecycle(TenantB)); + + var resultA = await storeA.QueryAsync( + new UserLifecycleQuery()); + + var resultB = await storeB.QueryAsync( + new UserLifecycleQuery()); + + resultA.Items.Should().ContainSingle(); + resultA.Items.Single().Tenant.Should().Be(TenantA); + + resultB.Items.Should().ContainSingle(); + resultB.Items.Single().Tenant.Should().Be(TenantB); + } + + // ============================================================ + // QUERY - DELETION + // ============================================================ + + [Fact] + public async Task QueryAsync_ByDefault_ExcludesDeleted() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var active = CreateLifecycle(TenantA); + var deleted = CreateLifecycle(TenantA); + + await store.AddAsync(active); + await store.AddAsync(deleted); + + await store.DeleteAsync( + new UserLifecycleKey(TenantA, deleted.UserKey), + 0, + DeleteMode.Soft, + Now.AddHours(1)); + + var result = await store.QueryAsync( + new UserLifecycleQuery()); + + result.Items.Should().ContainSingle(); + result.Items.Single().UserKey.Should().Be(active.UserKey); + result.TotalCount.Should().Be(1); + } + + [Fact] + public async Task QueryAsync_IncludeDeleted_ReturnsDeletedRecords() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var active = CreateLifecycle(TenantA); + var deleted = CreateLifecycle(TenantA); + + await store.AddAsync(active); + await store.AddAsync(deleted); + + await store.DeleteAsync( + new UserLifecycleKey(TenantA, deleted.UserKey), + 0, + DeleteMode.Soft, + Now.AddHours(1)); + + var result = await store.QueryAsync( + new UserLifecycleQuery + { + IncludeDeleted = true + }); + + result.Items.Should().HaveCount(2); + result.TotalCount.Should().Be(2); + + result.Items.Should() + .Contain(x => x.UserKey == deleted.UserKey && x.IsDeleted); + } + + // ============================================================ + // QUERY - STATUS + // ============================================================ + + [Fact] + public async Task QueryAsync_Status_FiltersResults() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var active = CreateLifecycle(TenantA); + + var suspended = CreateLifecycle(TenantA); + suspended.ChangeStatus( + Now.AddMinutes(1), + UserStatus.Suspended); + + await store.AddAsync(active); + await store.AddAsync(suspended); + + var result = await store.QueryAsync( + new UserLifecycleQuery + { + Status = UserStatus.Suspended + }); + + result.Items.Should().ContainSingle(); + result.Items.Single().UserKey.Should().Be(suspended.UserKey); + result.TotalCount.Should().Be(1); + } + + // ============================================================ + // QUERY - PAGINATION + // ============================================================ + + [Fact] + public async Task QueryAsync_AppliesPagination() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + for (var i = 0; i < 5; i++) + { + await store.AddAsync( + CreateLifecycle( + TenantA, + createdAt: Now.AddMinutes(i))); + } + + var result = await store.QueryAsync( + new UserLifecycleQuery + { + PageNumber = 2, + PageSize = 2, + SortBy = nameof(UserLifecycle.CreatedAt) + }); + + result.Items.Should().HaveCount(2); + result.TotalCount.Should().Be(5); + result.PageNumber.Should().Be(2); + result.PageSize.Should().Be(2); + + result.Items[0].CreatedAt.Should().Be(Now.AddMinutes(2)); + result.Items[1].CreatedAt.Should().Be(Now.AddMinutes(3)); + } + + // ============================================================ + // QUERY - SORT + // ============================================================ + + [Fact] + public async Task QueryAsync_SortsCreatedAtAscending() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var third = CreateLifecycle( + TenantA, + createdAt: Now.AddMinutes(3)); + + var first = CreateLifecycle( + TenantA, + createdAt: Now.AddMinutes(1)); + + var second = CreateLifecycle( + TenantA, + createdAt: Now.AddMinutes(2)); + + await store.AddAsync(third); + await store.AddAsync(first); + await store.AddAsync(second); + + var result = await store.QueryAsync( + new UserLifecycleQuery + { + SortBy = nameof(UserLifecycle.CreatedAt), + Descending = false + }); + + result.Items.Select(x => x.CreatedAt) + .Should() + .ContainInOrder( + Now.AddMinutes(1), + Now.AddMinutes(2), + Now.AddMinutes(3)); + } + + [Fact] + public async Task QueryAsync_SortsCreatedAtDescending() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + await store.AddAsync( + CreateLifecycle(TenantA, createdAt: Now.AddMinutes(1))); + + await store.AddAsync( + CreateLifecycle(TenantA, createdAt: Now.AddMinutes(3))); + + await store.AddAsync( + CreateLifecycle(TenantA, createdAt: Now.AddMinutes(2))); + + var result = await store.QueryAsync( + new UserLifecycleQuery + { + SortBy = nameof(UserLifecycle.CreatedAt), + Descending = true + }); + + result.Items.Select(x => x.CreatedAt) + .Should() + .ContainInOrder( + Now.AddMinutes(3), + Now.AddMinutes(2), + Now.AddMinutes(1)); + } + + [Fact] + public async Task QueryAsync_SortByUserKey_IsSupported() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var firstUser = UserKey.FromString("user-a"); + var secondUser = UserKey.FromString("user-b"); + var thirdUser = UserKey.FromString("user-c"); + + await store.AddAsync(CreateLifecycle(TenantA, thirdUser)); + await store.AddAsync(CreateLifecycle(TenantA, firstUser)); + await store.AddAsync(CreateLifecycle(TenantA, secondUser)); + + var result = await store.QueryAsync( + new UserLifecycleQuery + { + SortBy = nameof(UserLifecycle.UserKey), + Descending = false + }); + + result.Items.Select(x => x.UserKey.Value) + .Should() + .ContainInOrder( + firstUser.Value, + secondUser.Value, + thirdUser.Value); + } + + [Fact] + public async Task QueryAsync_SortByDeletedAt_IsSupported() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var first = CreateLifecycle(TenantA); + var second = CreateLifecycle(TenantA); + + await store.AddAsync(first); + await store.AddAsync(second); + + await store.DeleteAsync( + new UserLifecycleKey(TenantA, first.UserKey), + 0, + DeleteMode.Soft, + Now.AddHours(1)); + + await store.DeleteAsync( + new UserLifecycleKey(TenantA, second.UserKey), + 0, + DeleteMode.Soft, + Now.AddHours(2)); + + var result = await store.QueryAsync( + new UserLifecycleQuery + { + IncludeDeleted = true, + SortBy = nameof(UserLifecycle.DeletedAt), + Descending = false + }); + + result.Items.Select(x => x.DeletedAt) + .Should() + .ContainInOrder( + Now.AddHours(1), + Now.AddHours(2)); + } + + // ============================================================ + // CANCELLATION + // ============================================================ + + [Fact] + public async Task GetAsync_WhenCancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => store.GetAsync( + new UserLifecycleKey(TenantA, UserKey.New()), + cts.Token); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task AddAsync_WhenCancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => store.AddAsync( + CreateLifecycle(TenantA), + cts.Token); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task QueryAsync_WhenCancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => store.QueryAsync( + new UserLifecycleQuery(), + cts.Token); + + await act.Should() + .ThrowAsync(); + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserProfileStoreContractTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserProfileStoreContractTests.cs new file mode 100644 index 00000000..8f517d58 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/Store/UserProfileStoreContractTests.cs @@ -0,0 +1,896 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Users.Contracts; + +public abstract class UserProfileStoreContractTests +{ + protected abstract Task CreateDatabaseAsync(); + + protected static readonly TenantKey TenantA = + TenantKey.FromExternal("tenant-a"); + + protected static readonly TenantKey TenantB = + TenantKey.FromExternal("tenant-b"); + + protected static readonly DateTimeOffset Now = + new(2026, 1, 15, 12, 0, 0, TimeSpan.Zero); + + protected static UserProfile CreateProfile( + TenantKey tenant, + UserKey? userKey = null, + ProfileKey? profileKey = null, + DateTimeOffset? createdAt = null, + string? firstName = "Alice", + string? lastName = "Example", + string? displayName = "Alice Example") + { + return UserProfile.Create( + id: null, + tenant: tenant, + userKey: userKey ?? UserKey.New(), + profileKey: profileKey, + createdAt: createdAt ?? Now, + firstName: firstName, + lastName: lastName, + displayName: displayName, + birthDate: null, + gender: null, + bio: null, + language: null, + timezone: null, + culture: null); + } + + // ============================================================ + // ADD / GET / EXISTS + // ============================================================ + + [Fact] + public async Task AddAsync_PersistsProfile() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var profile = CreateProfile(TenantA); + + await store.AddAsync(profile); + + var key = new UserProfileKey( + TenantA, + profile.UserKey, + profile.ProfileKey); + + var persisted = await store.GetAsync(key); + + persisted.Should().NotBeNull(); + persisted!.Id.Should().Be(profile.Id); + persisted.Tenant.Should().Be(TenantA); + persisted.UserKey.Should().Be(profile.UserKey); + persisted.ProfileKey.Should().Be(profile.ProfileKey); + persisted.FirstName.Should().Be("Alice"); + persisted.LastName.Should().Be("Example"); + persisted.DisplayName.Should().Be("Alice Example"); + persisted.CreatedAt.Should().Be(Now); + persisted.Version.Should().Be(0); + persisted.IsDeleted.Should().BeFalse(); + } + + [Fact] + public async Task GetAsync_WhenMissing_ReturnsNull() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var result = await store.GetAsync( + new UserProfileKey( + TenantA, + UserKey.New(), + ProfileKey.Default)); + + result.Should().BeNull(); + } + + [Fact] + public async Task ExistsAsync_WhenPresent_ReturnsTrue() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var profile = CreateProfile(TenantA); + await store.AddAsync(profile); + + var result = await store.ExistsAsync( + new UserProfileKey( + TenantA, + profile.UserKey, + profile.ProfileKey)); + + result.Should().BeTrue(); + } + + [Fact] + public async Task ExistsAsync_WhenMissing_ReturnsFalse() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var result = await store.ExistsAsync( + new UserProfileKey( + TenantA, + UserKey.New(), + ProfileKey.Default)); + + result.Should().BeFalse(); + } + + [Fact] + public async Task AddAsync_WhenSameUserAndProfileKeyAlreadyExists_ThrowsConflict() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + + await store.AddAsync( + CreateProfile(TenantA, user)); + + var duplicate = CreateProfile( + TenantA, + user, + ProfileKey.Default); + + var act = () => store.AddAsync(duplicate); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task SameUser_CanHaveDifferentProfileKeys() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + + var defaultProfile = CreateProfile( + TenantA, + user, + ProfileKey.Default); + + var secondaryKey = ProfileKey.Parse("work", null); + + var workProfile = CreateProfile( + TenantA, + user, + secondaryKey); + + await store.AddAsync(defaultProfile); + await store.AddAsync(workProfile); + + var profiles = await store.GetAllProfilesByUserAsync(user); + + profiles.Should().HaveCount(2); + profiles.Select(x => x.ProfileKey) + .Should() + .BeEquivalentTo([ProfileKey.Default, secondaryKey]); + } + + [Fact] + public async Task AddAsync_WhenVersionIsNotZero_IsRejected() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var profile = CreateProfile(TenantA); + profile.Version = 42; + + var act = () => store.AddAsync(profile); + + await act.Should() + .ThrowAsync(); + } + + // ============================================================ + // SAVE + // ============================================================ + + [Fact] + public async Task SaveAsync_PersistsAllMutableFields_AndIncrementsVersion() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var profile = CreateProfile(TenantA); + + await store.AddAsync(profile); + + var changedAt = Now.AddMinutes(10); + + profile.UpdateName( + "Bob", + "Changed", + "Bob Changed", + changedAt); + + profile.UpdatePersonalInfo( + new DateOnly(1990, 5, 10), + "male", + "Updated bio", + changedAt); + + profile.UpdateLocalization( + "tr", + "Europe/Istanbul", + "tr-TR", + changedAt); + + profile.UpdateMetadata( + new Dictionary + { + ["department"] = "engineering", + ["region"] = "emea" + }, + changedAt); + + await store.SaveAsync( + profile, + expectedVersion: 0); + + var persisted = await store.GetAsync( + new UserProfileKey( + TenantA, + profile.UserKey, + profile.ProfileKey)); + + persisted.Should().NotBeNull(); + + persisted!.FirstName.Should().Be("Bob"); + persisted.LastName.Should().Be("Changed"); + persisted.DisplayName.Should().Be("Bob Changed"); + + persisted.BirthDate.Should().Be(new DateOnly(1990, 5, 10)); + persisted.Gender.Should().Be("male"); + persisted.Bio.Should().Be("Updated bio"); + + persisted.Language.Should().Be("tr"); + persisted.TimeZone.Should().Be("Europe/Istanbul"); + persisted.Culture.Should().Be("tr-TR"); + + persisted.Metadata.Should().NotBeNull(); + persisted.Metadata!["department"].Should().Be("engineering"); + persisted.Metadata["region"].Should().Be("emea"); + + persisted.UpdatedAt.Should().Be(changedAt); + persisted.Version.Should().Be(1); + } + + [Fact] + public async Task SaveAsync_WhenMissing_ThrowsNotFound() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var profile = CreateProfile(TenantA); + + var act = () => store.SaveAsync( + profile, + expectedVersion: 0); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task SaveAsync_WhenVersionIsStale_ThrowsConcurrency() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var profile = CreateProfile(TenantA); + await store.AddAsync(profile); + + profile.UpdateName( + "First", + "Update", + "First Update", + Now.AddMinutes(1)); + + await store.SaveAsync(profile, 0); + + profile.UpdateName( + "Second", + "Update", + "Second Update", + Now.AddMinutes(2)); + + var act = () => store.SaveAsync( + profile, + expectedVersion: 0); + + await act.Should() + .ThrowAsync(); + } + + // ============================================================ + // DELETE + // ============================================================ + + [Fact] + public async Task DeleteAsync_Soft_PreservesProfileAndDeletionState() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var profile = CreateProfile(TenantA); + await store.AddAsync(profile); + + var deletedAt = Now.AddHours(1); + + await store.DeleteAsync( + new UserProfileKey( + TenantA, + profile.UserKey, + profile.ProfileKey), + expectedVersion: 0, + DeleteMode.Soft, + deletedAt); + + var persisted = await store.GetAsync( + new UserProfileKey( + TenantA, + profile.UserKey, + profile.ProfileKey)); + + persisted.Should().NotBeNull(); + persisted!.IsDeleted.Should().BeTrue(); + persisted.DeletedAt.Should().Be(deletedAt); + persisted.Version.Should().Be(1); + } + + [Fact] + public async Task DeleteAsync_Hard_RemovesProfile() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var profile = CreateProfile(TenantA); + var key = new UserProfileKey( + TenantA, + profile.UserKey, + profile.ProfileKey); + + await store.AddAsync(profile); + + await store.DeleteAsync( + key, + 0, + DeleteMode.Hard, + Now); + + (await store.GetAsync(key)).Should().BeNull(); + (await store.ExistsAsync(key)).Should().BeFalse(); + } + + [Fact] + public async Task DeleteAsync_WhenMissing_ThrowsNotFound() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var act = () => store.DeleteAsync( + new UserProfileKey( + TenantA, + UserKey.New(), + ProfileKey.Default), + 0, + DeleteMode.Soft, + Now); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task DeleteAsync_WhenVersionIsStale_ThrowsConcurrency() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var profile = CreateProfile(TenantA); + var key = new UserProfileKey( + TenantA, + profile.UserKey, + profile.ProfileKey); + + await store.AddAsync(profile); + + profile.UpdateName( + "Changed", + null, + null, + Now.AddMinutes(1)); + + await store.SaveAsync(profile, 0); + + var act = () => store.DeleteAsync( + key, + expectedVersion: 0, + DeleteMode.Soft, + Now.AddHours(1)); + + await act.Should() + .ThrowAsync(); + } + + // ============================================================ + // TENANT + // ============================================================ + + [Fact] + public async Task AddAsync_WhenEntityBelongsToDifferentTenant_IsRejected() + { + await using var db = await CreateDatabaseAsync(); + + var store = db.CreateStore(TenantA); + var profile = CreateProfile(TenantB); + + var act = () => store.AddAsync(profile); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task SaveAsync_WhenEntityBelongsToDifferentTenant_IsRejected() + { + await using var db = await CreateDatabaseAsync(); + + var storeA = db.CreateStore(TenantA); + var storeB = db.CreateStore(TenantB); + + var profile = CreateProfile(TenantB); + await storeB.AddAsync(profile); + + var act = () => storeA.SaveAsync( + profile, + expectedVersion: 0); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task QueryAsync_IsTenantIsolated() + { + await using var db = await CreateDatabaseAsync(); + + var storeA = db.CreateStore(TenantA); + var storeB = db.CreateStore(TenantB); + + await storeA.AddAsync(CreateProfile(TenantA)); + await storeB.AddAsync(CreateProfile(TenantB)); + + var resultA = await storeA.QueryAsync( + new UserProfileQuery()); + + var resultB = await storeB.QueryAsync( + new UserProfileQuery()); + + resultA.Items.Should().ContainSingle(); + resultA.Items.Single().Tenant.Should().Be(TenantA); + + resultB.Items.Should().ContainSingle(); + resultB.Items.Single().Tenant.Should().Be(TenantB); + } + + // ============================================================ + // GET ALL / GET BY USERS + // ============================================================ + + [Fact] + public async Task GetAllProfilesByUserAsync_ReturnsActiveProfilesOnly() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var user = UserKey.New(); + + var first = CreateProfile( + TenantA, + user, + ProfileKey.Default); + + var secondaryKey = ProfileKey.Parse("work", null); + + var second = CreateProfile( + TenantA, + user, + secondaryKey); + + await store.AddAsync(first); + await store.AddAsync(second); + + await store.DeleteAsync( + new UserProfileKey( + TenantA, + user, + secondaryKey), + 0, + DeleteMode.Soft, + Now.AddHours(1)); + + var result = + await store.GetAllProfilesByUserAsync(user); + + result.Should().ContainSingle(); + result.Single().ProfileKey.Should().Be(ProfileKey.Default); + } + + [Fact] + public async Task GetAllProfilesByUserAsync_DoesNotReturnOtherUsersProfiles() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var userA = UserKey.New(); + var userB = UserKey.New(); + + await store.AddAsync(CreateProfile(TenantA, userA)); + await store.AddAsync(CreateProfile(TenantA, userB)); + + var result = + await store.GetAllProfilesByUserAsync(userA); + + result.Should().ContainSingle(); + result.Single().UserKey.Should().Be(userA); + } + + [Fact] + public async Task GetByUsersAsync_ReturnsRequestedUsersForProfileKey() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var userA = UserKey.New(); + var userB = UserKey.New(); + var userC = UserKey.New(); + + await store.AddAsync(CreateProfile(TenantA, userA)); + await store.AddAsync(CreateProfile(TenantA, userB)); + await store.AddAsync(CreateProfile(TenantA, userC)); + + var result = await store.GetByUsersAsync( + [userA, userB], + ProfileKey.Default); + + result.Should().HaveCount(2); + + result.Select(x => x.UserKey) + .Should() + .BeEquivalentTo([userA, userB]); + } + + [Fact] + public async Task GetByUsersAsync_ExcludesDeletedProfiles() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var userA = UserKey.New(); + var userB = UserKey.New(); + + var first = CreateProfile(TenantA, userA); + var second = CreateProfile(TenantA, userB); + + await store.AddAsync(first); + await store.AddAsync(second); + + await store.DeleteAsync( + new UserProfileKey( + TenantA, + userB, + ProfileKey.Default), + 0, + DeleteMode.Soft, + Now.AddHours(1)); + + var result = await store.GetByUsersAsync( + [userA, userB], + ProfileKey.Default); + + result.Should().ContainSingle(); + result.Single().UserKey.Should().Be(userA); + } + + // ============================================================ + // QUERY + // ============================================================ + + [Fact] + public async Task QueryAsync_ByDefault_ExcludesDeleted() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var active = CreateProfile(TenantA); + var deleted = CreateProfile(TenantA); + + await store.AddAsync(active); + await store.AddAsync(deleted); + + await store.DeleteAsync( + new UserProfileKey( + TenantA, + deleted.UserKey, + deleted.ProfileKey), + 0, + DeleteMode.Soft, + Now.AddHours(1)); + + var result = await store.QueryAsync( + new UserProfileQuery()); + + result.Items.Should().ContainSingle(); + result.Items.Single().Id.Should().Be(active.Id); + result.TotalCount.Should().Be(1); + } + + [Fact] + public async Task QueryAsync_IncludeDeleted_ReturnsDeletedProfiles() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var active = CreateProfile(TenantA); + var deleted = CreateProfile(TenantA); + + await store.AddAsync(active); + await store.AddAsync(deleted); + + await store.DeleteAsync( + new UserProfileKey( + TenantA, + deleted.UserKey, + deleted.ProfileKey), + 0, + DeleteMode.Soft, + Now.AddHours(1)); + + var result = await store.QueryAsync( + new UserProfileQuery + { + IncludeDeleted = true + }); + + result.Items.Should().HaveCount(2); + result.TotalCount.Should().Be(2); + result.Items.Should().Contain(x => + x.Id == deleted.Id && x.IsDeleted); + } + + [Fact] + public async Task QueryAsync_ProfileKey_FiltersProfiles() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + var workKey = ProfileKey.Parse("work", null); + + await store.AddAsync( + CreateProfile( + TenantA, + profileKey: ProfileKey.Default)); + + var work = CreateProfile( + TenantA, + profileKey: workKey); + + await store.AddAsync(work); + + var result = await store.QueryAsync( + new UserProfileQuery + { + ProfileKey = workKey + }); + + result.Items.Should().ContainSingle(); + result.Items.Single().Id.Should().Be(work.Id); + } + + [Fact] + public async Task QueryAsync_AppliesPagination() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + for (var i = 0; i < 5; i++) + { + await store.AddAsync( + CreateProfile( + TenantA, + createdAt: Now.AddMinutes(i), + displayName: $"User {i}")); + } + + var result = await store.QueryAsync( + new UserProfileQuery + { + PageNumber = 2, + PageSize = 2, + SortBy = nameof(UserProfile.CreatedAt) + }); + + result.Items.Should().HaveCount(2); + result.TotalCount.Should().Be(5); + result.PageNumber.Should().Be(2); + result.PageSize.Should().Be(2); + + result.Items[0].CreatedAt + .Should().Be(Now.AddMinutes(2)); + + result.Items[1].CreatedAt + .Should().Be(Now.AddMinutes(3)); + } + + [Theory] + [InlineData(nameof(UserProfile.DisplayName))] + [InlineData(nameof(UserProfile.FirstName))] + [InlineData(nameof(UserProfile.LastName))] + public async Task QueryAsync_SortsStringFieldsAscending( + string sortBy) + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + await store.AddAsync( + CreateProfile( + TenantA, + firstName: "Charlie", + lastName: "Zulu", + displayName: "Mike")); + + await store.AddAsync( + CreateProfile( + TenantA, + firstName: "Alice", + lastName: "Alpha", + displayName: "Alpha")); + + await store.AddAsync( + CreateProfile( + TenantA, + firstName: "Bob", + lastName: "Mike", + displayName: "Zulu")); + + var result = await store.QueryAsync( + new UserProfileQuery + { + SortBy = sortBy + }); + + result.Items.Should().HaveCount(3); + + switch (sortBy) + { + case nameof(UserProfile.FirstName): + result.Items.Select(x => x.FirstName) + .Should() + .ContainInOrder("Alice", "Bob", "Charlie"); + break; + + case nameof(UserProfile.LastName): + result.Items.Select(x => x.LastName) + .Should() + .ContainInOrder("Alpha", "Mike", "Zulu"); + break; + + case nameof(UserProfile.DisplayName): + result.Items.Select(x => x.DisplayName) + .Should() + .ContainInOrder("Alpha", "Mike", "Zulu"); + break; + } + } + + [Fact] + public async Task QueryAsync_SortsCreatedAtDescending() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + await store.AddAsync( + CreateProfile( + TenantA, + createdAt: Now.AddMinutes(1))); + + await store.AddAsync( + CreateProfile( + TenantA, + createdAt: Now.AddMinutes(3))); + + await store.AddAsync( + CreateProfile( + TenantA, + createdAt: Now.AddMinutes(2))); + + var result = await store.QueryAsync( + new UserProfileQuery + { + SortBy = nameof(UserProfile.CreatedAt), + Descending = true + }); + + result.Items.Select(x => x.CreatedAt) + .Should() + .ContainInOrder( + Now.AddMinutes(3), + Now.AddMinutes(2), + Now.AddMinutes(1)); + } + + // ============================================================ + // CANCELLATION + // ============================================================ + + [Fact] + public async Task GetAsync_WhenCancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => store.GetAsync( + new UserProfileKey( + TenantA, + UserKey.New(), + ProfileKey.Default), + cts.Token); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task AddAsync_WhenCancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => store.AddAsync( + CreateProfile(TenantA), + cts.Token); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task QueryAsync_WhenCancelled_Throws() + { + await using var db = await CreateDatabaseAsync(); + var store = db.CreateStore(TenantA); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => store.QueryAsync( + new UserProfileQuery(), + cts.Token); + + await act.Should() + .ThrowAsync(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs new file mode 100644 index 00000000..6ff3b1f4 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserApplicationServiceTests.cs @@ -0,0 +1,1845 @@ +ο»Ώusing CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using FluentAssertions; +using Microsoft.Extensions.Options; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Users; + +public sealed class UserApplicationServiceTests +{ + private static readonly DateTimeOffset Now = + new(2026, 9, 21, 12, 0, 0, TimeSpan.Zero); + + // ============================================================ + // Create + // ============================================================ + + [Fact] + public async Task CreateUserAsync_WhenValidationFails_ThrowsAndDoesNotPersist() + { + var f = CreateFixture(); + var context = CreateContext(); + var request = CreateUserRequest(); + + f.UserCreateValidator + .Setup(x => x.ValidateAsync( + context, + request, + It.IsAny())) + .ReturnsAsync( + UserCreateValidatorResult.Failed( + new[] + { + new UAuthValidationError("identifier_required") + })); + + var act = () => f.Sut.CreateUserAsync(context, request); + + await act.Should() + .ThrowAsync(); + + f.LifecycleStore.Verify( + x => x.AddAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + + f.ProfileStore.Verify( + x => x.AddAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + + f.IdentifierStore.Verify( + x => x.AddAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task CreateUserAsync_WhenValid_CreatesLifecycleAndDefaultProfile() + { + var f = CreateFixture(); + var context = CreateContext(); + + var request = CreateUserRequest(); + + SetupValidCreate(f, context, request); + + UserLifecycle? capturedLifecycle = null; + UserProfile? capturedProfile = null; + + f.LifecycleStore + .Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (x, _) => capturedLifecycle = x) + .Returns(Task.CompletedTask); + + f.ProfileStore + .Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (x, _) => capturedProfile = x) + .Returns(Task.CompletedTask); + + SetupIdentifierPersistence(f); + + var result = await f.Sut.CreateUserAsync( + context, + request); + + result.Succeeded.Should().BeTrue(); + + capturedLifecycle.Should().NotBeNull(); + capturedLifecycle!.Tenant.Should().Be(context.ResourceTenant); + capturedLifecycle.CreatedAt.Should().Be(Now); + + capturedProfile.Should().NotBeNull(); + capturedProfile!.Tenant.Should().Be(context.ResourceTenant); + capturedProfile.ProfileKey.Should().Be(ProfileKey.Default); + capturedProfile.UserKey.Should().Be(capturedLifecycle.UserKey); + } + + [Fact] + public async Task CreateUserAsync_CreatesConfiguredIdentifiersForSameUser() + { + var f = CreateFixture(); + var context = CreateContext(); + + var request = new CreateUserRequest + { + UserName = "alice", + Email = "alice@example.com", + Phone = "+905551112233" + }; + + SetupValidCreate(f, context, request); + + f.LifecycleStore + .Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + f.ProfileStore + .Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + var identifiers = new List(); + + f.IdentifierStore + .Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .Callback( + (x, _) => identifiers.Add(x)) + .Returns(Task.CompletedTask); + + var result = await f.Sut.CreateUserAsync( + context, + request); + + result.Succeeded.Should().BeTrue(); + + identifiers.Should().HaveCount(3); + + identifiers + .Select(x => x.Type) + .Should() + .BeEquivalentTo(new[] + { + UserIdentifierType.Username, + UserIdentifierType.Email, + UserIdentifierType.Phone + }); + + identifiers + .Select(x => x.UserKey) + .Distinct() + .Should() + .ContainSingle(); + } + + [Fact] + public async Task CreateUserAsync_InvokesLifecycleIntegrations() + { + var integration = + new Mock(MockBehavior.Strict); + + var f = CreateFixture(integration.Object); + var context = CreateContext(); + var request = CreateUserRequest(); + + SetupValidCreate(f, context, request); + SetupSuccessfulCreatePersistence(f); + + integration + .Setup(x => x.OnUserCreatedAsync( + context.ResourceTenant, + It.IsAny(), + request, + It.IsAny())) + .Returns(Task.CompletedTask); + + await f.Sut.CreateUserAsync(context, request); + + integration.Verify(x => x.OnUserCreatedAsync( + context.ResourceTenant, + It.IsAny(), + request, + It.IsAny()), + Times.Once); + } + + // ============================================================ + // Change status + // ============================================================ + + [Fact] + public async Task ChangeUserStatusAsync_WhenUserDoesNotExist_ThrowsNotFound() + { + var f = CreateFixture(); + var user = UserKey.New(); + var context = CreateContext(targetUserKey: user); + + f.LifecycleStore + .Setup(x => x.GetAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync((UserLifecycle?)null); + + var request = new ChangeUserStatusAdminRequest + { + NewStatus = AdminAssignableUserStatus.Suspended + }; + + var act = () => f.Sut.ChangeUserStatusAsync( + context, + request); + + await act.Should() + .ThrowAsync(); + + f.LifecycleStore.Verify( + x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ChangeUserStatusAsync_Self_AllowsActiveToSelfSuspended() + { + var f = CreateFixture(); + + var user = UserKey.New(); + + var context = CreateContext( + actorUserKey: user, + targetUserKey: user, + action: UAuthActions.Users.ChangeStatusSelf); + + var lifecycle = UserLifecycle.Create( + context.ResourceTenant, + user, + Now.AddDays(-1)); + + f.LifecycleStore + .Setup(x => x.GetAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(lifecycle); + + f.LifecycleStore + .Setup(x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + var request = new ChangeUserStatusSelfRequest + { + NewStatus = SelfAssignableUserStatus.SelfSuspended + }; + + await f.Sut.ChangeUserStatusAsync( + context, + request); + + lifecycle.Status.Should().Be(UserStatus.SelfSuspended); + + f.LifecycleStore.Verify(x => x.SaveAsync( + lifecycle, + It.IsAny(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ChangeUserStatusAsync_Self_WhenTransitionNotAllowed_ThrowsConflict() + { + var f = CreateFixture(); + + var user = UserKey.New(); + + var context = CreateContext( + actorUserKey: user, + targetUserKey: user, + action: UAuthActions.Users.ChangeStatusSelf); + + var lifecycle = UserLifecycle.Create( + context.ResourceTenant, + user, + Now.AddDays(-1)); + + f.LifecycleStore + .Setup(x => x.GetAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(lifecycle); + + // Active -> Active is explicitly not a valid self transition. + var request = new ChangeUserStatusSelfRequest + { + NewStatus = SelfAssignableUserStatus.Active + }; + + var act = () => f.Sut.ChangeUserStatusAsync( + context, + request); + + await act.Should() + .ThrowAsync(); + + f.LifecycleStore.Verify( + x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + // ============================================================ + // Delete self + // ============================================================ + + [Fact] + public async Task DeleteMeAsync_WhenUserDoesNotExist_ThrowsNotFound() + { + var f = CreateFixture(); + + var user = UserKey.New(); + + var context = CreateContext( + actorUserKey: user, + targetUserKey: user, + action: UAuthActions.Users.DeleteSelf); + + f.LifecycleStore + .Setup(x => x.GetAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync((UserLifecycle?)null); + + var act = () => f.Sut.DeleteMeAsync(context); + + await act.Should() + .ThrowAsync(); + + f.SessionStore.Verify( + x => x.RevokeAllChainsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task DeleteMeAsync_SoftDeletesUserData_AndRevokesAllChains() + { + var f = CreateFixture(); + + var user = UserKey.New(); + + var context = CreateContext( + actorUserKey: user, + targetUserKey: user, + action: UAuthActions.Users.DeleteSelf); + + var lifecycle = UserLifecycle.Create( + context.ResourceTenant, + user, + Now.AddDays(-10)); + + f.LifecycleStore + .Setup(x => x.GetAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(lifecycle); + + f.LifecycleStore + .Setup(x => x.DeleteAsync( + It.IsAny(), + lifecycle.Version, + DeleteMode.Soft, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + f.IdentifierStore + .Setup(x => x.DeleteByUserAsync( + user, + DeleteMode.Soft, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + f.ProfileStore + .Setup(x => x.GetAllProfilesByUserAsync( + user, + It.IsAny())) + .ReturnsAsync(Array.Empty()); + + f.SessionStore + .Setup(x => x.RevokeAllChainsAsync( + user, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + await f.Sut.DeleteMeAsync(context); + + f.LifecycleStore.Verify(x => x.DeleteAsync( + It.IsAny(), + lifecycle.Version, + DeleteMode.Soft, + Now, + It.IsAny()), + Times.Once); + + f.IdentifierStore.Verify(x => x.DeleteByUserAsync( + user, + DeleteMode.Soft, + Now, + It.IsAny()), + Times.Once); + + f.SessionStore.Verify(x => x.RevokeAllChainsAsync( + user, + Now, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task DeleteMeAsync_InvokesIntegrationWithSoftDelete() + { + var integration = + new Mock(MockBehavior.Strict); + + var f = CreateFixture(integration.Object); + + var user = UserKey.New(); + + var context = CreateContext( + actorUserKey: user, + targetUserKey: user, + action: UAuthActions.Users.DeleteSelf); + + var lifecycle = UserLifecycle.Create( + context.ResourceTenant, + user, + Now.AddDays(-1)); + + SetupDeleteSelfPersistence( + f, + user, + lifecycle); + + integration + .Setup(x => x.OnUserDeletedAsync( + context.ResourceTenant, + user, + DeleteMode.Soft, + It.IsAny())) + .Returns(Task.CompletedTask); + + await f.Sut.DeleteMeAsync(context); + + integration.Verify(x => x.OnUserDeletedAsync( + context.ResourceTenant, + user, + DeleteMode.Soft, + It.IsAny()), + Times.Once); + } + + // ============================================================ + // Delete admin + // ============================================================ + + [Theory] + [InlineData(DeleteMode.Soft)] + [InlineData(DeleteMode.Hard)] + public async Task DeleteUserAsync_UsesRequestedDeleteMode( + DeleteMode mode) + { + var f = CreateFixture(); + + var actor = UserKey.New(); + var target = UserKey.New(); + + var context = CreateContext( + actorUserKey: actor, + targetUserKey: target, + action: UAuthActions.Users.DeleteAdmin); + + var lifecycle = UserLifecycle.Create( + context.ResourceTenant, + target, + Now.AddDays(-1)); + + f.LifecycleStore + .Setup(x => x.GetAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(lifecycle); + + f.LifecycleStore + .Setup(x => x.DeleteAsync( + It.IsAny(), + lifecycle.Version, + mode, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + f.IdentifierStore + .Setup(x => x.DeleteByUserAsync( + target, + mode, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + f.ProfileStore + .Setup(x => x.GetAllProfilesByUserAsync( + target, + It.IsAny())) + .ReturnsAsync(Array.Empty()); + + f.SessionStore + .Setup(x => x.RevokeAllChainsAsync( + target, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + var request = new DeleteUserRequest + { + Mode = mode + }; + + await f.Sut.DeleteUserAsync( + context, + request); + + f.LifecycleStore.Verify(x => x.DeleteAsync( + It.IsAny(), + lifecycle.Version, + mode, + Now, + It.IsAny()), + Times.Once); + + f.IdentifierStore.Verify(x => x.DeleteByUserAsync( + target, + mode, + Now, + It.IsAny()), + Times.Once); + } + + // ============================================================ + // Identifiers - security / ownership + // ============================================================ + + [Fact] + public async Task UpdateUserIdentifierAsync_WhenIdentifierDoesNotExist_ThrowsNotFound() + { + var f = CreateFixture(); + var context = CreateContext(); + var id = Guid.NewGuid(); + + f.IdentifierStore + .Setup(x => x.GetByIdAsync( + id, + It.IsAny())) + .ReturnsAsync((UserIdentifier?)null); + + var request = new UpdateUserIdentifierRequest + { + Id = id, + NewValue = "new@example.com" + }; + + var act = () => f.Sut.UpdateUserIdentifierAsync( + context, + request); + + await act.Should() + .ThrowAsync(); + + f.IdentifierStore.Verify( + x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task SetPrimaryUserIdentifierAsync_WhenIdentifierDoesNotExist_ThrowsNotFound() + { + var f = CreateFixture(); + var context = CreateContext(); + var id = Guid.NewGuid(); + + f.IdentifierStore + .Setup(x => x.GetByIdAsync( + id, + It.IsAny())) + .ReturnsAsync((UserIdentifier?)null); + + var request = new SetPrimaryUserIdentifierRequest + { + Id = id + }; + + var act = () => f.Sut.SetPrimaryUserIdentifierAsync( + context, + request); + + await act.Should() + .ThrowAsync(); + + f.IdentifierStore.Verify( + x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task UnsetPrimaryUserIdentifierAsync_WhenIdentifierDoesNotExist_ThrowsNotFound() + { + var f = CreateFixture(); + var context = CreateContext(); + var id = Guid.NewGuid(); + + f.IdentifierStore + .Setup(x => x.GetByIdAsync( + id, + It.IsAny())) + .ReturnsAsync((UserIdentifier?)null); + + var request = new UnsetPrimaryUserIdentifierRequest + { + Id = id + }; + + var act = () => f.Sut.UnsetPrimaryUserIdentifierAsync( + context, + request); + + await act.Should() + .ThrowAsync(); + } + + [Fact] + public async Task VerifyUserIdentifierAsync_WhenIdentifierDoesNotExist_ThrowsNotFound() + { + var f = CreateFixture(); + var context = CreateContext(); + var id = Guid.NewGuid(); + + f.IdentifierStore + .Setup(x => x.GetByIdAsync( + id, + It.IsAny())) + .ReturnsAsync((UserIdentifier?)null); + + var request = new VerifyUserIdentifierRequest + { + Id = id + }; + + var act = () => f.Sut.VerifyUserIdentifierAsync( + context, + request); + + await act.Should() + .ThrowAsync(); + + f.IdentifierStore.Verify( + x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task DeleteUserIdentifierAsync_WhenIdentifierDoesNotExist_ThrowsNotFound() + { + var f = CreateFixture(); + var context = CreateContext(); + var id = Guid.NewGuid(); + + f.IdentifierStore + .Setup(x => x.GetByIdAsync( + id, + It.IsAny())) + .ReturnsAsync((UserIdentifier?)null); + + var request = new DeleteUserIdentifierRequest + { + Id = id, + Mode = DeleteMode.Soft + }; + + var act = () => f.Sut.DeleteUserIdentifierAsync( + context, + request); + + await act.Should() + .ThrowAsync(); + } + + // ============================================================ + // Identifiers - UnsetPrimary invariants + // ============================================================ + + [Fact] + public async Task UnsetPrimaryUserIdentifierAsync_WhenAlreadyNotPrimary_ThrowsValidation() + { + var f = CreateFixture(); + var context = CreateContext(); + + var identifier = CreateIdentifier( + context.GetTargetUserKey(), + isPrimary: false, + isVerified: true); + + f.IdentifierStore + .Setup(x => x.GetByIdAsync( + identifier.Id, + It.IsAny())) + .ReturnsAsync(identifier); + + var request = new UnsetPrimaryUserIdentifierRequest + { + Id = identifier.Id + }; + + var act = () => f.Sut.UnsetPrimaryUserIdentifierAsync( + context, + request); + + await act.Should() + .ThrowAsync() + .WithMessage("*identifier_already_not_primary*"); + + f.IdentifierStore.Verify( + x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task UnsetPrimaryUserIdentifierAsync_WhenItIsLastLoginPrimary_ThrowsConflict() + { + var f = CreateFixture(); + var context = CreateContext(); + + var identifier = CreateIdentifier( + context.GetTargetUserKey(), + type: UserIdentifierType.Username, + isPrimary: true, + isVerified: true); + + f.IdentifierStore + .Setup(x => x.GetByIdAsync( + identifier.Id, + It.IsAny())) + .ReturnsAsync(identifier); + + f.IdentifierStore + .Setup(x => x.GetByUserAsync( + identifier.UserKey, + It.IsAny())) + .ReturnsAsync(new[] { identifier }); + + var request = new UnsetPrimaryUserIdentifierRequest + { + Id = identifier.Id + }; + + var act = () => f.Sut.UnsetPrimaryUserIdentifierAsync( + context, + request); + + await act.Should() + .ThrowAsync() + .WithMessage("*cannot_unset_last_login_identifier*"); + + f.IdentifierStore.Verify( + x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + + // ============================================================ + // Identifiers - Verify + // ============================================================ + + [Fact] + public async Task VerifyUserIdentifierAsync_WhenIdentifierExists_MarksVerifiedAndSaves() + { + var f = CreateFixture(); + var context = CreateContext(); + + var identifier = CreateIdentifier( + context.GetTargetUserKey(), + isVerified: false, + version: 7); + + var expectedVersion = identifier.Version; + + f.IdentifierStore + .Setup(x => x.GetByIdAsync( + identifier.Id, + It.IsAny())) + .ReturnsAsync(identifier); + + f.IdentifierStore + .Setup(x => x.SaveAsync( + identifier, + expectedVersion, + It.IsAny())) + .Returns(Task.CompletedTask); + + await f.Sut.VerifyUserIdentifierAsync( + context, + new VerifyUserIdentifierRequest + { + Id = identifier.Id + }); + + identifier.IsVerified.Should().BeTrue(); + identifier.VerifiedAt.Should().Be(Now); + identifier.UpdatedAt.Should().Be(Now); + + f.IdentifierStore.Verify(x => x.SaveAsync( + identifier, + expectedVersion, + It.IsAny()), + Times.Once); + } + + // ============================================================ + // Identifiers - Delete invariants + // ============================================================ + + [Fact] + public async Task DeleteUserIdentifierAsync_WhenIdentifierIsPrimary_ThrowsValidation() + { + var f = CreateFixture(); + var context = CreateContext(); + + var identifier = CreateIdentifier( + context.GetTargetUserKey(), + isPrimary: true, + isVerified: true); + + f.IdentifierStore + .Setup(x => x.GetByIdAsync( + identifier.Id, + It.IsAny())) + .ReturnsAsync(identifier); + + f.IdentifierStore + .Setup(x => x.GetByUserAsync( + identifier.UserKey, + It.IsAny())) + .ReturnsAsync(new[] { identifier }); + + var act = () => f.Sut.DeleteUserIdentifierAsync( + context, + new DeleteUserIdentifierRequest + { + Id = identifier.Id, + Mode = DeleteMode.Soft + }); + + await act.Should() + .ThrowAsync() + .WithMessage("*cannot_delete_primary_identifier*"); + + f.IdentifierStore.Verify( + x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + + f.IdentifierStore.Verify( + x => x.DeleteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task DeleteUserIdentifierAsync_WhenItIsLastLoginIdentifier_ThrowsConflict() + { + var f = CreateFixture(); + var context = CreateContext(); + + var identifier = CreateIdentifier( + context.GetTargetUserKey(), + type: UserIdentifierType.Email, + isPrimary: false, + isVerified: true); + + f.IdentifierStore + .Setup(x => x.GetByIdAsync( + identifier.Id, + It.IsAny())) + .ReturnsAsync(identifier); + + f.IdentifierStore + .Setup(x => x.GetByUserAsync( + identifier.UserKey, + It.IsAny())) + .ReturnsAsync(new[] { identifier }); + + var act = () => f.Sut.DeleteUserIdentifierAsync( + context, + new DeleteUserIdentifierRequest + { + Id = identifier.Id, + Mode = DeleteMode.Soft + }); + + await act.Should() + .ThrowAsync() + .WithMessage("*cannot_delete_last_login_identifier*"); + + f.IdentifierStore.Verify( + x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task DeleteUserIdentifierAsync_SoftDelete_MarksDeletedAndSaves() + { + var f = CreateFixture(); + var context = CreateContext(); + + var target = CreateIdentifier( + context.GetTargetUserKey(), + type: UserIdentifierType.Email, + isPrimary: false, + isVerified: true); + + // Keeps the user with another active login identifier. + var remaining = CreateIdentifier( + context.GetTargetUserKey(), + type: UserIdentifierType.Username, + isPrimary: true, + isVerified: true); + + var expectedVersion = target.Version; + + f.IdentifierStore + .Setup(x => x.GetByIdAsync( + target.Id, + It.IsAny())) + .ReturnsAsync(target); + + f.IdentifierStore + .Setup(x => x.GetByUserAsync( + target.UserKey, + It.IsAny())) + .ReturnsAsync(new[] { target, remaining }); + + f.IdentifierStore + .Setup(x => x.SaveAsync( + target, + expectedVersion, + It.IsAny())) + .Returns(Task.CompletedTask); + + await f.Sut.DeleteUserIdentifierAsync( + context, + new DeleteUserIdentifierRequest + { + Id = target.Id, + Mode = DeleteMode.Soft + }); + + target.IsDeleted.Should().BeTrue(); + target.DeletedAt.Should().Be(Now); + + f.IdentifierStore.Verify(x => x.SaveAsync( + target, + expectedVersion, + It.IsAny()), + Times.Once); + + f.IdentifierStore.Verify( + x => x.DeleteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task DeleteUserIdentifierAsync_HardDelete_UsesStoreDelete() + { + var f = CreateFixture(); + var context = CreateContext(); + + var target = CreateIdentifier( + context.GetTargetUserKey(), + type: UserIdentifierType.Email, + isPrimary: false, + isVerified: true); + + var remaining = CreateIdentifier( + context.GetTargetUserKey(), + type: UserIdentifierType.Username, + isPrimary: true, + isVerified: true); + + var expectedVersion = target.Version; + + f.IdentifierStore + .Setup(x => x.GetByIdAsync( + target.Id, + It.IsAny())) + .ReturnsAsync(target); + + f.IdentifierStore + .Setup(x => x.GetByUserAsync( + target.UserKey, + It.IsAny())) + .ReturnsAsync(new[] { target, remaining }); + + f.IdentifierStore + .Setup(x => x.DeleteAsync( + target.Id, + expectedVersion, + DeleteMode.Hard, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + await f.Sut.DeleteUserIdentifierAsync( + context, + new DeleteUserIdentifierRequest + { + Id = target.Id, + Mode = DeleteMode.Hard + }); + + f.IdentifierStore.Verify(x => x.DeleteAsync( + target.Id, + expectedVersion, + DeleteMode.Hard, + Now, + It.IsAny()), + Times.Once); + + f.IdentifierStore.Verify( + x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task UpdateUserIdentifierAsync_ValidatesNewValue_NotExistingValue() + { + var f = CreateFixture(); + var context = CreateContext(); + + var identifier = CreateIdentifier( + context.GetTargetUserKey(), + type: UserIdentifierType.Email, + value: "old@example.com", + normalizedValue: "old@example.com", + version: 4); + + f.IdentifierStore + .Setup(x => x.GetByIdAsync( + identifier.Id, + It.IsAny())) + .ReturnsAsync(identifier); + + f.IdentifierValidator + .Setup(x => x.ValidateAsync( + context, + It.Is(x => + x.Value == "new@example.com"), + It.IsAny())) + .ReturnsAsync(IdentifierValidationResult.Success()); + + f.IdentifierNormalizer + .Setup(x => x.Normalize( + UserIdentifierType.Email, + "new@example.com")) + .Returns(new NormalizedIdentifier( + "new@example.com", + "new@example.com", + true, + null)); + + f.IdentifierStore + .Setup(x => x.GetAsync( + UserIdentifierType.Email, + "new@example.com", + It.IsAny())) + .ReturnsAsync((UserIdentifier?)null); + + f.IdentifierStore + .Setup(x => x.ExistsAsync( + It.Is(q => + q.Type == UserIdentifierType.Email && + q.NormalizedValue == "new@example.com" && + q.Scope == IdentifierExistenceScope.WithinUser && + q.UserKey == identifier.UserKey && + q.ExcludeIdentifierId == identifier.Id), + It.IsAny())) + .ReturnsAsync(new IdentifierExistenceResult( + Exists: false)); + + f.IdentifierStore + .Setup(x => x.SaveAsync( + identifier, + 4, + It.IsAny())) + .Returns(Task.CompletedTask); + + await f.Sut.UpdateUserIdentifierAsync( + context, + new UpdateUserIdentifierRequest + { + Id = identifier.Id, + NewValue = "new@example.com" + }); + + identifier.Value.Should().Be("new@example.com"); + identifier.NormalizedValue.Should().Be("new@example.com"); + + f.IdentifierValidator.Verify(x => x.ValidateAsync( + context, + It.Is(i => + i.Value == "new@example.com"), + It.IsAny()), + Times.Once); + + f.IdentifierStore.Verify(x => x.SaveAsync( + identifier, + 4, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task SetPrimaryUserIdentifierAsync_WhenValid_SetsPrimaryAndSavesExpectedVersion() + { + var f = CreateFixture(); + var context = CreateContext(); + + var identifier = CreateIdentifier( + context.GetTargetUserKey(), + isPrimary: false, + isVerified: true, + version: 5); + + f.IdentifierStore + .Setup(x => x.GetByIdAsync( + identifier.Id, + It.IsAny())) + .ReturnsAsync(identifier); + + f.IdentifierStore + .Setup(x => x.ExistsAsync( + It.Is(q => + q.Type == UserIdentifierType.Email && + q.NormalizedValue == "alice@example.com" && + q.Scope == IdentifierExistenceScope.TenantPrimaryOnly && + q.UserKey == null && + q.ExcludeIdentifierId == identifier.Id), + It.IsAny())) + .ReturnsAsync(new IdentifierExistenceResult( + Exists: false)); + + f.IdentifierStore + .Setup(x => x.SaveAsync( + identifier, + 5, + It.IsAny())) + .Returns(Task.CompletedTask); + + await f.Sut.SetPrimaryUserIdentifierAsync( + context, + new SetPrimaryUserIdentifierRequest + { + Id = identifier.Id + }); + + identifier.IsPrimary.Should().BeTrue(); + identifier.UpdatedAt.Should().Be(Now); + + f.IdentifierStore.Verify(x => x.SaveAsync( + identifier, + 5, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task SetPrimaryUserIdentifierAsync_WhenAlreadyPrimary_ThrowsValidation() + { + var f = CreateFixture(); + var context = CreateContext(); + + var identifier = CreateIdentifier( + context.GetTargetUserKey(), + isPrimary: true, + isVerified: true); + + f.IdentifierStore + .Setup(x => x.GetByIdAsync( + identifier.Id, + It.IsAny())) + .ReturnsAsync(identifier); + + var request = new SetPrimaryUserIdentifierRequest + { + Id = identifier.Id + }; + + var act = () => f.Sut.SetPrimaryUserIdentifierAsync( + context, + request); + + await act.Should() + .ThrowAsync() + .WithMessage("*identifier_already_primary*"); + + f.IdentifierStore.Verify( + x => x.SaveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [Fact] + public async Task UnsetPrimaryUserIdentifierAsync_WhenValid_UnsetsPrimaryAndSavesExpectedVersion() + { + var f = CreateFixture(); + var context = CreateContext(); + + var target = CreateIdentifier( + context.GetTargetUserKey(), + type: UserIdentifierType.Email, + isPrimary: true, + isVerified: true, + version: 6); + + var remaining = CreateIdentifier( + context.GetTargetUserKey(), + type: UserIdentifierType.Username, + isPrimary: true, + isVerified: true); + + f.IdentifierStore + .Setup(x => x.GetByIdAsync( + target.Id, + It.IsAny())) + .ReturnsAsync(target); + + f.IdentifierStore + .Setup(x => x.GetByUserAsync( + target.UserKey, + It.IsAny())) + .ReturnsAsync(new[] { target, remaining }); + + f.IdentifierStore + .Setup(x => x.SaveAsync( + target, + 6, + It.IsAny())) + .Returns(Task.CompletedTask); + + await f.Sut.UnsetPrimaryUserIdentifierAsync( + context, + new UnsetPrimaryUserIdentifierRequest + { + Id = target.Id + }); + + target.IsPrimary.Should().BeFalse(); + target.UpdatedAt.Should().Be(Now); + + f.IdentifierStore.Verify(x => x.SaveAsync( + target, + 6, + It.IsAny()), + Times.Once); + } + + [Theory] + [InlineData(DeleteMode.Soft)] + [InlineData(DeleteMode.Hard)] + public async Task DeleteUserAsync_RevokesAllChains(DeleteMode mode) + { + var f = CreateFixture(); + + var actor = UserKey.New(); + var target = UserKey.New(); + + var context = CreateContext( + actorUserKey: actor, + targetUserKey: target, + action: UAuthActions.Users.DeleteAdmin); + + var lifecycle = UserLifecycle.Create( + context.ResourceTenant, + target, + Now.AddDays(-1)); + + f.LifecycleStore + .Setup(x => x.GetAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(lifecycle); + + f.LifecycleStore + .Setup(x => x.DeleteAsync( + It.IsAny(), + lifecycle.Version, + mode, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + f.IdentifierStore + .Setup(x => x.DeleteByUserAsync( + target, + mode, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + f.ProfileStore + .Setup(x => x.GetAllProfilesByUserAsync( + target, + It.IsAny())) + .ReturnsAsync(Array.Empty()); + + f.SessionStore + .Setup(x => x.RevokeAllChainsAsync( + target, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + await f.Sut.DeleteUserAsync( + context, + new DeleteUserRequest + { + Mode = mode + }); + + f.SessionStore.Verify(x => x.RevokeAllChainsAsync( + target, + Now, + It.IsAny()), + Times.Once); + } + + [Theory] + [InlineData(DeleteMode.Soft)] + [InlineData(DeleteMode.Hard)] + public async Task DeleteUserAsync_DeletesProfilesUsingRequestedDeleteMode(DeleteMode mode) + { + var f = CreateFixture(); + + var actor = UserKey.New(); + var target = UserKey.New(); + + var context = CreateContext( + actorUserKey: actor, + targetUserKey: target, + action: UAuthActions.Users.DeleteAdmin); + + var lifecycle = UserLifecycle.Create( + context.ResourceTenant, + target, + Now.AddDays(-1)); + + var defaultProfile = UserProfile.Create( + Guid.NewGuid(), + context.ResourceTenant, + target, + ProfileKey.Default, + Now.AddDays(-1)); + + var secondaryProfileKey = ProfileKey.Parse("secondary", null); + + var secondaryProfile = UserProfile.Create( + Guid.NewGuid(), + context.ResourceTenant, + target, + secondaryProfileKey, + Now.AddDays(-1)); + + f.LifecycleStore + .Setup(x => x.GetAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(lifecycle); + + f.LifecycleStore + .Setup(x => x.DeleteAsync( + It.IsAny(), + lifecycle.Version, + mode, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + f.IdentifierStore + .Setup(x => x.DeleteByUserAsync( + target, + mode, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + f.ProfileStore + .Setup(x => x.GetAllProfilesByUserAsync( + target, + It.IsAny())) + .ReturnsAsync(new[] + { + defaultProfile, + secondaryProfile + }); + + f.ProfileStore + .Setup(x => x.DeleteAsync( + It.IsAny(), + It.IsAny(), + mode, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + f.SessionStore + .Setup(x => x.RevokeAllChainsAsync( + target, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + await f.Sut.DeleteUserAsync( + context, + new DeleteUserRequest + { + Mode = mode + }); + + f.ProfileStore.Verify(x => x.DeleteAsync( + new UserProfileKey( + context.ResourceTenant, + target, + ProfileKey.Default), + defaultProfile.Version, + mode, + Now, + It.IsAny()), + Times.Once); + + f.ProfileStore.Verify(x => x.DeleteAsync( + new UserProfileKey( + context.ResourceTenant, + target, + secondaryProfileKey), + secondaryProfile.Version, + mode, + Now, + It.IsAny()), + Times.Once); + + f.ProfileStore.Verify( + x => x.DeleteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Exactly(2)); + } + + // ============================================================ + // Helpers + // ============================================================ + + private static Fixture CreateFixture( + params IUserLifecycleIntegration[] integrations) + { + var access = + new Mock(MockBehavior.Strict); + + var lifecycleFactory = + new Mock(MockBehavior.Strict); + + var identifierFactory = + new Mock(MockBehavior.Strict); + + var profileFactory = + new Mock(MockBehavior.Strict); + + var lifecycleStore = + new Mock(MockBehavior.Strict); + + var identifierStore = + new Mock(MockBehavior.Strict); + + var profileStore = + new Mock(MockBehavior.Strict); + + var validator = + new Mock(MockBehavior.Strict); + + var identifierValidator = + new Mock(MockBehavior.Strict); + + var normalizer = + new Mock(MockBehavior.Strict); + + var sessionFactory = + new Mock(MockBehavior.Strict); + + var sessionStore = + new Mock(MockBehavior.Strict); + + var clock = + new Mock(MockBehavior.Strict); + + clock.SetupGet(x => x.UtcNow) + .Returns(Now); + + lifecycleFactory + .Setup(x => x.Create(It.IsAny())) + .Returns(lifecycleStore.Object); + + identifierFactory + .Setup(x => x.Create(It.IsAny())) + .Returns(identifierStore.Object); + + profileFactory + .Setup(x => x.Create(It.IsAny())) + .Returns(profileStore.Object); + + sessionFactory + .Setup(x => x.Create(It.IsAny())) + .Returns(sessionStore.Object); + + /* + * Unit-test the service command body without mocking its contents. + * Authorization itself belongs to IAccessOrchestrator tests. + */ + access + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns( + (_, command, ct) => command.ExecuteAsync(ct)); + + access + .Setup(x => x.ExecuteAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>( + (_, command, ct) => command.ExecuteAsync(ct)); + + var options = Options.Create( + new UAuthServerOptions()); + + var sut = new UserApplicationService( + access.Object, + lifecycleFactory.Object, + identifierFactory.Object, + profileFactory.Object, + validator.Object, + identifierValidator.Object, + integrations, + normalizer.Object, + sessionFactory.Object, + options, + clock.Object); + + return new Fixture( + sut, + access, + lifecycleStore, + identifierStore, + profileStore, + validator, + identifierValidator, + normalizer, + sessionStore); + } + + private static AccessContext CreateContext( + UserKey? actorUserKey = null, + UserKey? targetUserKey = null, + string action = "test") + { + actorUserKey ??= UserKey.New(); + + return new AccessContext( + actorUserKey: actorUserKey, + actorTenant: TenantKey.Single, + isAuthenticated: true, + isSystemActor: false, + actorChainId: null, + resource: "users", + targetUserKey: targetUserKey ?? actorUserKey, + resourceTenant: TenantKey.Single, + action: action, + attributes: EmptyAttributes.Instance); + } + + private static CreateUserRequest CreateUserRequest() + => new() + { + UserName = "alice" + }; + + private static void SetupValidCreate( + Fixture f, + AccessContext context, + CreateUserRequest request) + { + f.UserCreateValidator + .Setup(x => x.ValidateAsync( + context, + request, + It.IsAny())) + .ReturnsAsync(UserCreateValidatorResult.Success()); + + SetupCreateNormalizers(f, request); + } + + private static void SetupIdentifierPersistence(Fixture f) + { + f.IdentifierStore + .Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + } + + private static void SetupSuccessfulCreatePersistence(Fixture f) + { + f.LifecycleStore + .Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + f.ProfileStore + .Setup(x => x.AddAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + SetupIdentifierPersistence(f); + } + + private static void SetupDeleteSelfPersistence( + Fixture f, + UserKey user, + UserLifecycle lifecycle) + { + f.LifecycleStore + .Setup(x => x.GetAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(lifecycle); + + f.LifecycleStore + .Setup(x => x.DeleteAsync( + It.IsAny(), + lifecycle.Version, + DeleteMode.Soft, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + f.IdentifierStore + .Setup(x => x.DeleteByUserAsync( + user, + DeleteMode.Soft, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + + f.ProfileStore + .Setup(x => x.GetAllProfilesByUserAsync( + user, + It.IsAny())) + .ReturnsAsync(Array.Empty()); + + f.SessionStore + .Setup(x => x.RevokeAllChainsAsync( + user, + Now, + It.IsAny())) + .Returns(Task.CompletedTask); + } + + private static void SetupCreateNormalizers( + Fixture f, + CreateUserRequest request) + { + if (!string.IsNullOrWhiteSpace(request.UserName)) + { + f.IdentifierNormalizer + .Setup(x => x.Normalize( + UserIdentifierType.Username, + request.UserName)) + .Returns(new NormalizedIdentifier( + request.UserName, + request.UserName, + true, + null)); + } + + if (!string.IsNullOrWhiteSpace(request.Email)) + { + f.IdentifierNormalizer + .Setup(x => x.Normalize( + UserIdentifierType.Email, + request.Email)) + .Returns(new NormalizedIdentifier( + request.Email, + request.Email, + true, + null)); + } + + if (!string.IsNullOrWhiteSpace(request.Phone)) + { + f.IdentifierNormalizer + .Setup(x => x.Normalize( + UserIdentifierType.Phone, + request.Phone)) + .Returns(new NormalizedIdentifier( + request.Phone, + request.Phone, + true, + null)); + } + } + + private sealed record Fixture( + UserApplicationService Sut, + Mock Access, + Mock LifecycleStore, + Mock IdentifierStore, + Mock ProfileStore, + Mock UserCreateValidator, + Mock IdentifierValidator, + Mock IdentifierNormalizer, + Mock SessionStore); + + private static UserIdentifier CreateIdentifier( + UserKey userKey, + UserIdentifierType type = UserIdentifierType.Email, + bool isPrimary = false, + bool isVerified = false, + string? value = null, + string? normalizedValue = null, + long version = 0) + { + value ??= type switch + { + UserIdentifierType.Username => "alice", + UserIdentifierType.Email => "alice@example.com", + UserIdentifierType.Phone => "+905551112233", + _ => "custom-value" + }; + + normalizedValue ??= value; + + return UserIdentifier.FromProjection( + id: Guid.NewGuid(), + tenant: TenantKey.Single, + userKey: userKey, + type: type, + value: value, + normalizedValue: normalizedValue, + isPrimary: isPrimary, + createdAt: Now.AddDays(-1), + verifiedAt: isVerified ? Now.AddHours(-1) : null, + updatedAt: null, + deletedAt: null, + version: version); + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs new file mode 100644 index 00000000..497cae21 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserEndpointHandlerTests.cs @@ -0,0 +1,1813 @@ +ο»Ώusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Moq; +using System.Text; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Users; + +public sealed class UserEndpointHandlerTests +{ + // ============================================================ + // QueryUsers + // ============================================================ + + [Fact] + public async Task QueryUsersAsync_WhenUnauthenticated_ReturnsUnauthorized_AndDoesNotCallDownstream() + { + var f = CreateFixture(authenticated: false); + + var result = await f.Sut.QueryUsersAsync(f.HttpContext); + + result.Should().BeOfType(); + + f.AccessContextFactory.VerifyNoOtherCalls(); + f.Users.VerifyNoOtherCalls(); + } + + [Fact] + public async Task QueryUsersAsync_WhenAuthenticated_UsesQueryAdminAccessContext() + { + var f = CreateFixture(); + + var request = new UserQuery(); + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.Users.QueryAdmin, + "users", + null, + null, + default)) + .ReturnsAsync(accessContext); + + var expected = new PagedResult( + Array.Empty(), + 0, + 1, + 20, + null, + false); + + f.Users + .Setup(x => x.QueryUsersAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .ReturnsAsync(expected); + + var result = await f.Sut.QueryUsersAsync(f.HttpContext); + + var ok = result.Should() + .BeOfType>>() + .Subject; + + ok.Value.Should().BeSameAs(expected); + + f.AccessContextFactory.Verify(x => x.CreateAsync( + f.Flow, + UAuthActions.Users.QueryAdmin, + "users", + null, + null, + default), + Times.Once); + + f.Users.Verify(x => x.QueryUsersAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted), + Times.Once); + } + + // ============================================================ + // Create anonymous + // ============================================================ + + [Fact] + public async Task CreateAsync_WhenUnauthenticated_AllowsAnonymousCreation() + { + var f = CreateFixture(authenticated: false); + + var request = CreateUserRequest(); + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.Users.CreateAnonymous, + "users", + null, + null, + default)) + .ReturnsAsync(accessContext); + + var userKey = UserKey.New(); + var createResult = UserCreateResult.Success(userKey); + + f.Users + .Setup(x => x.CreateUserAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .ReturnsAsync(createResult); + + var result = await f.Sut.CreateAsync(f.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value.Should().BeSameAs(createResult); + + f.AccessContextFactory.Verify(x => x.CreateAsync( + f.Flow, + UAuthActions.Users.CreateAnonymous, + "users", + null, + null, + default), + Times.Once); + } + + [Fact] + public async Task CreateAsync_WhenCreationFails_ReturnsBadRequest() + { + var f = CreateFixture(authenticated: false); + + var request = CreateUserRequest(); + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.Users.CreateAnonymous, + "users", + null, + null, + default)) + .ReturnsAsync(accessContext); + + var createResult = UserCreateResult.Failed("fail"); + + f.Users + .Setup(x => x.CreateUserAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .ReturnsAsync(createResult); + + var result = await f.Sut.CreateAsync(f.HttpContext); + + var badRequest = result.Should() + .BeOfType>() + .Subject; + + badRequest.Value.Should().BeSameAs(createResult); + } + + // ============================================================ + // Create admin + // ============================================================ + + [Fact] + public async Task CreateAdminAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var f = CreateFixture(authenticated: false); + + var result = await f.Sut.CreateAdminAsync(f.HttpContext); + + result.Should().BeOfType(); + + f.AccessContextFactory.VerifyNoOtherCalls(); + f.Users.VerifyNoOtherCalls(); + } + + [Fact] + public async Task CreateAdminAsync_WhenAuthenticated_UsesCreateAdminAction() + { + var f = CreateFixture(); + + SetJsonBody( + f.HttpContext, + CreateUserRequest()); + + var accessContext = CreateAccessContext(f); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.Users.CreateAdmin, + "users", + null, + null, + default)) + .ReturnsAsync(accessContext); + + var createResult = + UserCreateResult.Success(UserKey.New()); + + f.Users + .Setup(x => x.CreateUserAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .ReturnsAsync(createResult); + + var result = await f.Sut.CreateAdminAsync(f.HttpContext); + + result.Should() + .BeOfType>(); + + f.AccessContextFactory.Verify(x => x.CreateAsync( + f.Flow, + UAuthActions.Users.CreateAdmin, + "users", + null, + null, + default), + Times.Once); + } + + // ============================================================ + // Change status self + // ============================================================ + + [Fact] + public async Task ChangeStatusSelfAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var f = CreateFixture(authenticated: false); + + var result = + await f.Sut.ChangeStatusSelfAsync(f.HttpContext); + + result.Should().BeOfType(); + + f.AccessContextFactory.VerifyNoOtherCalls(); + f.Users.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ChangeStatusSelfAsync_UsesSelfActionAndActorUserKey() + { + var f = CreateFixture(); + + var request = new ChangeUserStatusSelfRequest + { + NewStatus = SelfAssignableUserStatus.SelfSuspended + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.Users.ChangeStatusSelf, + "users", + f.Flow.UserKey!.Value.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.ChangeUserStatusAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = + await f.Sut.ChangeStatusSelfAsync(f.HttpContext); + + result.Should().BeOfType(); + + f.Users.Verify(x => x.ChangeUserStatusAsync( + accessContext, + It.Is( + r => r.NewStatus == request.NewStatus), + f.HttpContext.RequestAborted), + Times.Once); + } + + // ============================================================ + // Change status admin + // ============================================================ + + [Fact] + public async Task ChangeStatusAdminAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var f = CreateFixture(authenticated: false); + + var targetUser = UserKey.New(); + + var result = await f.Sut.ChangeStatusAdminAsync( + targetUser, + f.HttpContext); + + result.Should().BeOfType(); + + f.AccessContextFactory.VerifyNoOtherCalls(); + f.Users.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ChangeStatusAdminAsync_UsesAdminActionAndTargetUserKey() + { + var f = CreateFixture(); + + var targetUser = UserKey.New(); + + var request = new ChangeUserStatusAdminRequest + { + NewStatus = AdminAssignableUserStatus.Suspended + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = + CreateAccessContext(f, targetUser); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.Users.ChangeStatusAdmin, + "users", + targetUser.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.ChangeUserStatusAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = await f.Sut.ChangeStatusAdminAsync( + targetUser, + f.HttpContext); + + result.Should().BeOfType(); + + f.Users.Verify(x => x.ChangeUserStatusAsync( + accessContext, + It.Is( + r => r.NewStatus == request.NewStatus), + f.HttpContext.RequestAborted), + Times.Once); + + f.AccessContextFactory.Verify(x => x.CreateAsync( + f.Flow, + UAuthActions.Users.ChangeStatusAdmin, + "users", + targetUser.Value, + null, + default), + Times.Once); + } + + // ============================================================ + // Delete self + // ============================================================ + + [Fact] + public async Task DeleteMeAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var f = CreateFixture(authenticated: false); + + var result = + await f.Sut.DeleteMeAsync(f.HttpContext); + + result.Should().BeOfType(); + + f.AccessContextFactory.VerifyNoOtherCalls(); + f.Users.VerifyNoOtherCalls(); + } + + [Fact] + public async Task DeleteMeAsync_UsesDeleteSelfAndActorUserKey() + { + var f = CreateFixture(); + + var accessContext = CreateAccessContext(f); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.Users.DeleteSelf, + "users", + f.Flow.UserKey!.Value.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.DeleteMeAsync( + accessContext, + f.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = + await f.Sut.DeleteMeAsync(f.HttpContext); + + result.Should().BeOfType(); + + f.Users.Verify(x => x.DeleteMeAsync( + accessContext, + f.HttpContext.RequestAborted), + Times.Once); + } + + // ============================================================ + // Delete admin + // ============================================================ + + [Fact] + public async Task DeleteAsync_UsesDeleteAdminAction_TargetUser_AndDeleteModeAttribute() + { + var f = CreateFixture(); + var targetUser = UserKey.New(); + + var request = new DeleteUserRequest + { + Mode = DeleteMode.Hard + }; + + SetJsonBody(f.HttpContext, request); + + AccessContext? capturedContext = null; + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.Users.DeleteAdmin, + "users", + targetUser.Value, + It.Is>(a => + a.ContainsKey("deleteMode") && + (DeleteMode)a["deleteMode"] == DeleteMode.Hard), + default)) + .ReturnsAsync((AuthFlowContext _, + string _, + string _, + string? _, + IDictionary? _, + CancellationToken _) => + { + capturedContext = CreateAccessContext(f, targetUser); + return capturedContext; + }); + + f.Users + .Setup(x => x.DeleteUserAsync( + It.IsAny(), + It.Is(r => r.Mode == DeleteMode.Hard), + f.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = await f.Sut.DeleteAsync( + targetUser, + f.HttpContext); + + result.Should().BeOfType(); + + f.Users.Verify(x => x.DeleteUserAsync( + It.IsAny(), + It.Is(r => r.Mode == DeleteMode.Hard), + f.HttpContext.RequestAborted), + Times.Once); + } + + + // ============================================================ + // Profiles + // ============================================================ + + // ============================================================ + // Identifier mutation - Set primary + // ============================================================ + + [Fact] + public async Task SetPrimaryUserIdentifierSelfAsync_UsesSetPrimarySelfAction() + { + var f = CreateFixture(); + + var request = new SetPrimaryUserIdentifierRequest + { + Id = Guid.NewGuid() + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserIdentifiers.SetPrimarySelf, + "users", + f.Flow.UserKey!.Value.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.SetPrimaryUserIdentifierAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = + await f.Sut.SetPrimaryUserIdentifierSelfAsync( + f.HttpContext); + + result.Should().BeOfType(); + + f.Users.Verify(x => x.SetPrimaryUserIdentifierAsync( + accessContext, + It.Is( + r => r.Id == request.Id), + f.HttpContext.RequestAborted), + Times.Once); + } + + [Fact] + public async Task SetPrimaryUserIdentifierAdminAsync_UsesSetPrimaryAdminAction() + { + var f = CreateFixture(); + var targetUser = UserKey.New(); + + var request = new SetPrimaryUserIdentifierRequest + { + Id = Guid.NewGuid() + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f, targetUser); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserIdentifiers.SetPrimaryAdmin, + "users", + targetUser.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.SetPrimaryUserIdentifierAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = + await f.Sut.SetPrimaryUserIdentifierAdminAsync( + targetUser, + f.HttpContext); + + result.Should().BeOfType(); + + f.Users.Verify(x => x.SetPrimaryUserIdentifierAsync( + accessContext, + It.Is( + r => r.Id == request.Id), + f.HttpContext.RequestAborted), + Times.Once); + } + + // ============================================================ + // Identifier mutation - Unset primary + // ============================================================ + + [Fact] + public async Task UnsetPrimaryUserIdentifierSelfAsync_UsesUnsetPrimarySelfAction() + { + var f = CreateFixture(); + + var request = new UnsetPrimaryUserIdentifierRequest + { + Id = Guid.NewGuid() + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserIdentifiers.UnsetPrimarySelf, + "users", + f.Flow.UserKey!.Value.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.UnsetPrimaryUserIdentifierAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = + await f.Sut.UnsetPrimaryUserIdentifierSelfAsync( + f.HttpContext); + + result.Should().BeOfType(); + + f.Users.Verify(x => x.UnsetPrimaryUserIdentifierAsync( + accessContext, + It.Is( + r => r.Id == request.Id), + f.HttpContext.RequestAborted), + Times.Once); + } + + [Fact] + public async Task UnsetPrimaryUserIdentifierAdminAsync_UsesUnsetPrimaryAdminAction() + { + var f = CreateFixture(); + var targetUser = UserKey.New(); + + var request = new UnsetPrimaryUserIdentifierRequest + { + Id = Guid.NewGuid() + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f, targetUser); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserIdentifiers.UnsetPrimaryAdmin, + "users", + targetUser.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.UnsetPrimaryUserIdentifierAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = + await f.Sut.UnsetPrimaryUserIdentifierAdminAsync( + targetUser, + f.HttpContext); + + result.Should().BeOfType(); + + f.Users.Verify(x => x.UnsetPrimaryUserIdentifierAsync( + accessContext, + It.Is( + r => r.Id == request.Id), + f.HttpContext.RequestAborted), + Times.Once); + } + + [Fact] + public async Task GetMeAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var f = CreateFixture(authenticated: false); + + var result = await f.Sut.GetMeAsync(f.HttpContext); + + result.Should().BeOfType(); + + f.AccessContextFactory.VerifyNoOtherCalls(); + f.Users.VerifyNoOtherCalls(); + } + + [Fact] + public async Task IdentifierExistsSelfAsync_UsesWithinUserScope() + { + var f = CreateFixture(); + + var request = new IdentifierExistsRequest + { + Type = UserIdentifierType.Email, + Value = "alice@example.com" + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserIdentifiers.GetSelf, + "users", + f.Flow.UserKey!.Value.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.UserIdentifierExistsAsync( + accessContext, + UserIdentifierType.Email, + "alice@example.com", + IdentifierExistenceScope.WithinUser, + f.HttpContext.RequestAborted)) + .ReturnsAsync(true); + + var result = + await f.Sut.IdentifierExistsSelfAsync(f.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value!.Exists.Should().BeTrue(); + } + + [Fact] + public async Task IdentifierExistsAdminAsync_UsesTenantAnyScope() + { + var f = CreateFixture(); + var targetUser = UserKey.New(); + + var request = new IdentifierExistsRequest + { + Type = UserIdentifierType.Email, + Value = "alice@example.com" + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f, targetUser); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserIdentifiers.GetAdmin, + "users", + targetUser.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.UserIdentifierExistsAsync( + accessContext, + UserIdentifierType.Email, + "alice@example.com", + IdentifierExistenceScope.TenantAny, + f.HttpContext.RequestAborted)) + .ReturnsAsync(true); + + var result = await f.Sut.IdentifierExistsAdminAsync( + targetUser, + f.HttpContext); + + var ok = result.Should() + .BeOfType>() + .Subject; + + ok.Value!.Exists.Should().BeTrue(); + } + + + // ============================================================ + // Identifier mutation - Add + // ============================================================ + + [Fact] + public async Task AddUserIdentifierSelfAsync_UsesAddSelfAction() + { + var f = CreateFixture(); + + var request = new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "alice@example.com" + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserIdentifiers.AddSelf, + "users", + f.Flow.UserKey!.Value.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.AddUserIdentifierAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = + await f.Sut.AddUserIdentifierSelfAsync(f.HttpContext); + + result.Should().BeOfType(); + } + + [Fact] + public async Task AddUserIdentifierAdminAsync_UsesAddAdminAction() + { + var f = CreateFixture(); + var targetUser = UserKey.New(); + + var request = new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "alice@example.com" + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f, targetUser); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserIdentifiers.AddAdmin, + "users", + targetUser.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.AddUserIdentifierAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = await f.Sut.AddUserIdentifierAdminAsync( + targetUser, + f.HttpContext); + + result.Should().BeOfType(); + } + + + // ============================================================ + // Identifier mutation - Update + // ============================================================ + + [Fact] + public async Task UpdateUserIdentifierSelfAsync_UsesUpdateSelfAction() + { + var f = CreateFixture(); + + var request = new UpdateUserIdentifierRequest + { + Id = Guid.NewGuid(), + NewValue = "new@example.com" + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserIdentifiers.UpdateSelf, + "users", + f.Flow.UserKey!.Value.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.UpdateUserIdentifierAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = + await f.Sut.UpdateUserIdentifierSelfAsync(f.HttpContext); + + result.Should().BeOfType(); + + f.Users.Verify(x => x.UpdateUserIdentifierAsync( + accessContext, + It.Is(r => + r.Id == request.Id && + r.NewValue == request.NewValue), + f.HttpContext.RequestAborted), + Times.Once); + } + + [Fact] + public async Task UpdateUserIdentifierAdminAsync_UsesUpdateAdminAction() + { + var f = CreateFixture(); + var targetUser = UserKey.New(); + + var request = new UpdateUserIdentifierRequest + { + Id = Guid.NewGuid(), + NewValue = "new@example.com" + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f, targetUser); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserIdentifiers.UpdateAdmin, + "users", + targetUser.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.UpdateUserIdentifierAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = await f.Sut.UpdateUserIdentifierAdminAsync( + targetUser, + f.HttpContext); + + result.Should().BeOfType(); + } + + // ============================================================ + // Identifier queries + // ============================================================ + + [Fact] + public async Task GetMyIdentifiersAsync_UsesGetSelfAction() + { + var f = CreateFixture(); + + SetJsonBody(f.HttpContext, new UserIdentifierQuery()); + + var accessContext = CreateAccessContext(f); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserIdentifiers.GetSelf, + "users", + f.Flow.UserKey!.Value.Value, + null, + default)) + .ReturnsAsync(accessContext); + + var expected = new PagedResult( + Array.Empty(), + 0, + 1, + 20, + null, + false); + + f.Users + .Setup(x => x.GetIdentifiersByUserAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .ReturnsAsync(expected); + + var result = + await f.Sut.GetMyIdentifiersAsync(f.HttpContext); + + var ok = result.Should() + .BeOfType>>() + .Subject; + + ok.Value.Should().BeSameAs(expected); + + f.AccessContextFactory.Verify(x => x.CreateAsync( + f.Flow, + UAuthActions.UserIdentifiers.GetSelf, + "users", + f.Flow.UserKey!.Value.Value, + null, + default), + Times.Once); + + f.Users.Verify(x => x.GetIdentifiersByUserAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted), + Times.Once); + } + + [Fact] + public async Task GetUserIdentifiersAsync_UsesGetAdminActionAndTargetUserKey() + { + var f = CreateFixture(); + var targetUser = UserKey.New(); + + SetJsonBody(f.HttpContext, new UserIdentifierQuery()); + + var accessContext = CreateAccessContext(f, targetUser); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserIdentifiers.GetAdmin, + "users", + targetUser.Value, + null, + default)) + .ReturnsAsync(accessContext); + + var expected = new PagedResult( + Array.Empty(), + 0, + 1, + 20, + null, + false); + + f.Users + .Setup(x => x.GetIdentifiersByUserAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .ReturnsAsync(expected); + + var result = await f.Sut.GetUserIdentifiersAsync( + targetUser, + f.HttpContext); + + var ok = result.Should() + .BeOfType>>() + .Subject; + + ok.Value.Should().BeSameAs(expected); + + f.AccessContextFactory.Verify(x => x.CreateAsync( + f.Flow, + UAuthActions.UserIdentifiers.GetAdmin, + "users", + targetUser.Value, + null, + default), + Times.Once); + } + + + // ============================================================ + // Identifier mutation - Verify + // ============================================================ + + [Fact] + public async Task VerifyUserIdentifierSelfAsync_UsesVerifySelfAction() + { + var f = CreateFixture(); + + var request = new VerifyUserIdentifierRequest + { + Id = Guid.NewGuid() + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserIdentifiers.VerifySelf, + "users", + f.Flow.UserKey!.Value.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.VerifyUserIdentifierAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = + await f.Sut.VerifyUserIdentifierSelfAsync( + f.HttpContext); + + result.Should().BeOfType(); + + f.Users.Verify(x => x.VerifyUserIdentifierAsync( + accessContext, + It.Is( + r => r.Id == request.Id), + f.HttpContext.RequestAborted), + Times.Once); + } + + [Fact] + public async Task VerifyUserIdentifierAdminAsync_UsesVerifyAdminActionAndTargetUserKey() + { + var f = CreateFixture(); + var targetUser = UserKey.New(); + + var request = new VerifyUserIdentifierRequest + { + Id = Guid.NewGuid() + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f, targetUser); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserIdentifiers.VerifyAdmin, + "users", + targetUser.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.VerifyUserIdentifierAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = + await f.Sut.VerifyUserIdentifierAdminAsync( + targetUser, + f.HttpContext); + + result.Should().BeOfType(); + + f.Users.Verify(x => x.VerifyUserIdentifierAsync( + accessContext, + It.Is( + r => r.Id == request.Id), + f.HttpContext.RequestAborted), + Times.Once); + } + + + // ============================================================ + // Identifier mutation - Delete + // ============================================================ + + [Fact] + public async Task DeleteUserIdentifierSelfAsync_UsesDeleteSelfActionAndPassesRequest() + { + var f = CreateFixture(); + + var request = new DeleteUserIdentifierRequest + { + Id = Guid.NewGuid(), + Mode = DeleteMode.Soft + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserIdentifiers.DeleteSelf, + "users", + f.Flow.UserKey!.Value.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.DeleteUserIdentifierAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = + await f.Sut.DeleteUserIdentifierSelfAsync( + f.HttpContext); + + result.Should().BeOfType(); + + f.Users.Verify(x => x.DeleteUserIdentifierAsync( + accessContext, + It.Is(r => + r.Id == request.Id && + r.Mode == DeleteMode.Soft), + f.HttpContext.RequestAborted), + Times.Once); + } + + [Fact] + public async Task DeleteUserIdentifierAdminAsync_UsesDeleteAdminActionAndTargetUserKey() + { + var f = CreateFixture(); + var targetUser = UserKey.New(); + + var request = new DeleteUserIdentifierRequest + { + Id = Guid.NewGuid(), + Mode = DeleteMode.Hard + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f, targetUser); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserIdentifiers.DeleteAdmin, + "users", + targetUser.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.DeleteUserIdentifierAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = + await f.Sut.DeleteUserIdentifierAdminAsync( + targetUser, + f.HttpContext); + + result.Should().BeOfType(); + + f.Users.Verify(x => x.DeleteUserIdentifierAsync( + accessContext, + It.Is(r => + r.Id == request.Id && + r.Mode == DeleteMode.Hard), + f.HttpContext.RequestAborted), + Times.Once); + } + + // ============================================================ + // Profiles - Get + // ============================================================ + + [Fact] + public async Task GetMeAsync_UsesGetSelfActionAndActorUserKey() + { + var f = CreateFixture(); + + var request = new GetProfileRequest + { + ProfileKey = ProfileKey.Default + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserProfiles.GetSelf, + "users", + f.Flow.UserKey!.Value.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.GetMeAsync( + accessContext, + ProfileKey.Default, + f.HttpContext.RequestAborted)) + .ReturnsAsync((UserView)null!); + + var result = await f.Sut.GetMeAsync(f.HttpContext); + + result.Should().BeOfType(); + + f.AccessContextFactory.Verify(x => x.CreateAsync( + f.Flow, + UAuthActions.UserProfiles.GetSelf, + "users", + f.Flow.UserKey!.Value.Value, + null, + default), + Times.Once); + + f.Users.Verify(x => x.GetMeAsync( + accessContext, + ProfileKey.Default, + f.HttpContext.RequestAborted), + Times.Once); + } + + [Fact] + public async Task GetUserAsync_UsesGetAdminActionAndTargetUserKey() + { + var f = CreateFixture(); + var targetUser = UserKey.New(); + + var request = new GetProfileRequest + { + ProfileKey = ProfileKey.Default + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f, targetUser); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserProfiles.GetAdmin, + "users", + targetUser.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.GetUserProfileAsync( + accessContext, + ProfileKey.Default, + f.HttpContext.RequestAborted)) + .ReturnsAsync((UserView)null!); + + var result = await f.Sut.GetUserAsync( + targetUser, + f.HttpContext); + + result.Should().BeOfType(); + + f.AccessContextFactory.Verify(x => x.CreateAsync( + f.Flow, + UAuthActions.UserProfiles.GetAdmin, + "users", + targetUser.Value, + null, + default), + Times.Once); + + f.Users.Verify(x => x.GetUserProfileAsync( + accessContext, + ProfileKey.Default, + f.HttpContext.RequestAborted), + Times.Once); + } + + // ============================================================ + // Profiles - Update + // ============================================================ + + [Fact] + public async Task UpdateMeAsync_UsesUpdateSelfActionAndPassesRequest() + { + var f = CreateFixture(); + + var request = new UpdateProfileRequest + { + ProfileKey = ProfileKey.Default, + DisplayName = "Alice" + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserProfiles.UpdateSelf, + "users", + f.Flow.UserKey!.Value.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.UpdateUserProfileAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = + await f.Sut.UpdateMeAsync(f.HttpContext); + + result.Should().BeOfType(); + + f.Users.Verify(x => x.UpdateUserProfileAsync( + accessContext, + It.Is(r => + r.ProfileKey == ProfileKey.Default && + r.DisplayName == "Alice"), + f.HttpContext.RequestAborted), + Times.Once); + } + + [Fact] + public async Task UpdateUserAsync_UsesUpdateAdminActionAndTargetUserKey() + { + var f = CreateFixture(); + var targetUser = UserKey.New(); + + var request = new UpdateProfileRequest + { + ProfileKey = ProfileKey.Default, + DisplayName = "Alice Admin" + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f, targetUser); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserProfiles.UpdateAdmin, + "users", + targetUser.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.UpdateUserProfileAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = await f.Sut.UpdateUserAsync( + targetUser, + f.HttpContext); + + result.Should().BeOfType(); + + f.Users.Verify(x => x.UpdateUserProfileAsync( + accessContext, + It.Is(r => + r.ProfileKey == ProfileKey.Default && + r.DisplayName == "Alice Admin"), + f.HttpContext.RequestAborted), + Times.Once); + } + + // ============================================================ + // Profiles - Create + // ============================================================ + + [Fact] + public async Task CreateProfileSelfAsync_UsesCreateSelfActionAndPassesRequest() + { + var f = CreateFixture(); + + var request = new CreateProfileRequest + { + ProfileKey = ProfileKey.Default, + DisplayName = "Alice" + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserProfiles.CreateSelf, + "users", + f.Flow.UserKey!.Value.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.CreateProfileAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = + await f.Sut.CreateProfileSelfAsync( + f.HttpContext); + + result.Should().BeOfType(); + + f.Users.Verify(x => x.CreateProfileAsync( + accessContext, + It.Is(r => + r.ProfileKey == ProfileKey.Default && + r.DisplayName == "Alice"), + f.HttpContext.RequestAborted), + Times.Once); + } + + [Fact] + public async Task CreateProfileAdminAsync_UsesCreateAdminActionAndTargetUserKey() + { + var f = CreateFixture(); + var targetUser = UserKey.New(); + + var request = new CreateProfileRequest + { + ProfileKey = ProfileKey.Default, + DisplayName = "Alice" + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f, targetUser); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserProfiles.CreateAdmin, + "users", + targetUser.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.CreateProfileAsync( + accessContext, + It.IsAny(), + f.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = + await f.Sut.CreateProfileAdminAsync( + targetUser, + f.HttpContext); + + result.Should().BeOfType(); + + f.Users.Verify(x => x.CreateProfileAsync( + accessContext, + It.Is(r => + r.ProfileKey == ProfileKey.Default && + r.DisplayName == "Alice"), + f.HttpContext.RequestAborted), + Times.Once); + } + + // ============================================================ + // Profiles - Delete + // ============================================================ + + [Fact] + public async Task DeleteProfileSelfAsync_UsesDeleteSelfActionAndProfileKey() + { + var f = CreateFixture(); + + var request = new DeleteProfileRequest + { + ProfileKey = ProfileKey.Default + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserProfiles.DeleteSelf, + "users", + f.Flow.UserKey!.Value.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.DeleteProfileAsync( + accessContext, + ProfileKey.Default, + f.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = + await f.Sut.DeleteProfileSelfAsync( + f.HttpContext); + + result.Should().BeOfType(); + + f.Users.Verify(x => x.DeleteProfileAsync( + accessContext, + ProfileKey.Default, + f.HttpContext.RequestAborted), + Times.Once); + } + + [Fact] + public async Task DeleteProfileAdminAsync_UsesDeleteAdminActionAndTargetUserKey() + { + var f = CreateFixture(); + var targetUser = UserKey.New(); + + var request = new DeleteProfileRequest + { + ProfileKey = ProfileKey.Default + }; + + SetJsonBody(f.HttpContext, request); + + var accessContext = CreateAccessContext(f, targetUser); + + f.AccessContextFactory + .Setup(x => x.CreateAsync( + f.Flow, + UAuthActions.UserProfiles.DeleteAdmin, + "users", + targetUser.Value, + null, + default)) + .ReturnsAsync(accessContext); + + f.Users + .Setup(x => x.DeleteProfileAsync( + accessContext, + ProfileKey.Default, + f.HttpContext.RequestAborted)) + .Returns(Task.CompletedTask); + + var result = + await f.Sut.DeleteProfileAdminAsync( + targetUser, + f.HttpContext); + + result.Should().BeOfType(); + + f.Users.Verify(x => x.DeleteProfileAsync( + accessContext, + ProfileKey.Default, + f.HttpContext.RequestAborted), + Times.Once); + } + + [Fact] + public async Task DeleteAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var f = CreateFixture(authenticated: false); + var targetUser = UserKey.New(); + + var result = await f.Sut.DeleteAsync( + targetUser, + f.HttpContext); + + result.Should().BeOfType(); + + f.AccessContextFactory.VerifyNoOtherCalls(); + f.Users.VerifyNoOtherCalls(); + } + + [Fact] + public async Task DeleteUserIdentifierSelfAsync_WhenUnauthenticated_ReturnsUnauthorized() + { + var f = CreateFixture(authenticated: false); + + var result = + await f.Sut.DeleteUserIdentifierSelfAsync( + f.HttpContext); + + result.Should().BeOfType(); + + f.AccessContextFactory.VerifyNoOtherCalls(); + f.Users.VerifyNoOtherCalls(); + } + + // ============================================================ + // Fixture + // ============================================================ + + private static Fixture CreateFixture( + bool authenticated = true) + { + var flow = authenticated + ? AuthFlowTestFactory.LoginSuccess() + : AuthFlowTestFactory.New(isAuthenticated: false); + + var authFlow = + new Mock( + MockBehavior.Strict); + + var accessContextFactory = + new Mock( + MockBehavior.Strict); + + var users = + new Mock( + MockBehavior.Strict); + + authFlow + .SetupGet(x => x.Current) + .Returns(flow); + + var httpContext = + new DefaultHttpContext(); + + var cts = + new CancellationTokenSource(); + + httpContext.RequestAborted = cts.Token; + + var sut = new UserEndpointHandler( + authFlow.Object, + accessContextFactory.Object, + users.Object); + + return new Fixture( + sut, + flow, + accessContextFactory, + users, + httpContext); + } + + private static AccessContext CreateAccessContext(Fixture f, UserKey? targetUserKey = null) + { + return new AccessContext( + actorUserKey: f.Flow.UserKey, + actorTenant: f.Flow.Tenant, + isAuthenticated: f.Flow.IsAuthenticated, + isSystemActor: false, + actorChainId: f.Flow.Session?.ChainId, + resource: "users", + targetUserKey: targetUserKey ?? f.Flow.UserKey, + resourceTenant: f.Flow.Tenant, + action: "test", + attributes: EmptyAttributes.Instance); + } + + private static CreateUserRequest CreateUserRequest() + { + return new CreateUserRequest + { + UserName = "alice" + }; + } + + private static void SetJsonBody(HttpContext context, T value) + { + var json = JsonSerializer.Serialize(value); + + context.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + context.Request.ContentType = "application/json"; + + context.Request.ContentLength = context.Request.Body.Length; + } + + private sealed record Fixture( + UserEndpointHandler Sut, + AuthFlowContext Flow, + Mock AccessContextFactory, + Mock Users, + DefaultHttpContext HttpContext); +} \ No newline at end of file