diff --git a/Directory.Packages.props b/Directory.Packages.props
index 0a3051b2ee..e4505d2ba3 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -19,10 +19,10 @@
-
-
-
-
+
+
+
+
diff --git a/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/post.md b/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/post.md
index 49c4f51e60..5d739b1588 100644
--- a/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/post.md
+++ b/docs/en/Community-Articles/2026-06-18-deep-dive-9-workflows/post.md
@@ -30,8 +30,12 @@ If the team always builds a package after an application service change, that sh
Workflows give ABP Studio a place to encode those repeatable steps.
+_**Choose a Workflow and Scope before sending your prompt:**_
+

+_**The selected workflow can be configured through Workflow Settings, where you can create, edit, and manage reusable workflows for common development tasks:**_
+

For me, the value is not only automation. It is also consistency.
diff --git a/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/Post.md b/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/Post.md
new file mode 100644
index 0000000000..2fd61ac23e
--- /dev/null
+++ b/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/Post.md
@@ -0,0 +1,645 @@
+Multi-tenancy sounds simple until the first real requirement lands: tenant-specific data isolation, host-only features, separate databases for a few big customers, and an admin panel that still feels like one product.
+
+ABP Framework gives you most of the plumbing out of the box, but the important part is knowing which pieces to enable, which defaults to trust, and where teams usually get into trouble. This article walks through a practical implementation approach for ABP Framework v8+ and the latest branch, covering shared database, separate database, and hybrid setups.
+
+## What ABP Multi-Tenancy Actually Gives You
+
+ABP's multi-tenancy support is not just a `TenantId` convention. It includes:
+
+- tenant context management
+- automatic data filtering for multi-tenant entities
+- tenant resolution from web requests
+- host vs tenant side separation
+- permission scoping by tenancy side
+- tenant-aware connection string resolution
+- tenant management infrastructure
+
+The first switch is explicit.
+
+```csharp
+Configure(options =>
+{
+ options.IsEnabled = true;
+});
+```
+
+Technically, multi-tenancy is disabled by default, although ABP startup templates usually enable it for you.
+
+ABP models two sides:
+
+- Host: the system owner, platform operator, or SaaS provider
+- Tenant: the customer using the system
+
+A `TenantId` value of `null` typically means the data belongs to the host side.
+
+## Choose the Right Database Architecture First
+
+Before writing entities or resolvers, decide how tenant data will be stored. This choice affects migrations, operations, support cost, and sometimes your pricing model.
+
+### 1. Shared Database
+
+All tenants share the same database and tables. Isolation is enforced with `TenantId` and ABP's built-in data filters.
+
+Why teams choose it:
+
+- simplest deployment model
+- lowest infrastructure cost
+- easiest to operate in early-stage SaaS products
+- one migration pipeline
+
+Trade-offs:
+
+- large tables grow quickly
+- indexing becomes more important
+- noisy-neighbor performance is more likely
+- stricter discipline is required to avoid cross-tenant mistakes
+
+This is usually the best default unless you already know you need stronger isolation.
+
+### 2. Separate Database per Tenant
+
+Each tenant gets its own database. Host data is usually kept in a central database, while tenant-specific data goes to per-tenant databases.
+
+Why teams choose it:
+
+- stronger data isolation
+- easier tenant-specific backup and restore
+- cleaner compliance story
+- large tenants can scale independently
+
+Trade-offs:
+
+- more provisioning logic
+- more migration complexity
+- more operational overhead
+- onboarding a tenant is no longer just inserting a row
+
+### 3. Hybrid Model
+
+Some tenants use the shared database, while others get dedicated databases.
+
+This is often the most realistic long-term model:
+
+- small customers stay in shared infrastructure
+- enterprise customers get isolated databases
+- you can promote selected tenants later
+
+Trade-offs:
+
+- highest implementation and operational complexity
+- migrations and seeding need stronger discipline
+- debugging environment-specific issues becomes harder
+
+
+
+
+
+## Implement Tenant-Aware Entities Correctly
+
+In ABP, tenant-scoped entities implement `IMultiTenant`.
+
+```csharp
+using Volo.Abp.Domain.Entities;
+using Volo.Abp.MultiTenancy;
+
+public class Product : AggregateRoot, IMultiTenant
+{
+ public Guid? TenantId { get; set; }
+ public string Name { get; private set; }
+ public decimal Price { get; private set; }
+
+ private Product()
+ {
+ }
+
+ public Product(Guid id, string name, decimal price, Guid? tenantId)
+ : base(id)
+ {
+ TenantId = tenantId;
+ Name = name;
+ Price = price;
+ }
+}
+```
+
+Once an entity implements `IMultiTenant`, ABP automatically filters queries according to the current tenant.
+
+That means this kind of repository call is already tenant-aware in normal application flow:
+
+```csharp
+var products = await _productRepository.GetListAsync();
+```
+
+### The nullable `TenantId` detail matters
+
+`TenantId` is nullable by design because host-owned data is valid in ABP.
+
+That is useful, but also easy to misuse.
+
+If an entity is truly tenant-only, do not casually allow `TenantId = null`. Enforce the rule in your constructor, factory method, or domain service.
+
+Example:
+
+```csharp
+public Order(Guid id, Guid tenantId, string orderNo) : base(id)
+{
+ TenantId = tenantId;
+ OrderNo = orderNo;
+}
+
+public Guid? TenantId { get; private set; }
+public string OrderNo { get; private set; }
+```
+
+For tenant-only aggregates, this small constraint prevents a surprising number of data leakage bugs.
+
+## Use `ICurrentTenant` for Context-Aware Logic
+
+`ICurrentTenant` is the central service for reading or temporarily changing tenant context.
+
+```csharp
+public class ProductAppService : ApplicationService
+{
+ public async Task GetTenantInfoAsync()
+ {
+ if (CurrentTenant.IsAvailable)
+ {
+ return $"TenantId: {CurrentTenant.Id}, Name: {CurrentTenant.Name}";
+ }
+
+ return "Host context";
+ }
+}
+```
+
+The more interesting capability is context switching.
+
+```csharp
+using (_currentTenant.Change(tenantId))
+{
+ var count = await _productRepository.GetCountAsync();
+}
+```
+
+This is useful for:
+
+- background jobs that process one tenant at a time
+- host-side reporting across tenants
+- tenant seeding during onboarding
+- maintenance tasks and migrations
+
+### A practical warning
+
+Switching tenant context is powerful. It is also a common source of subtle bugs when developers mix host and tenant operations in the same method. Keep tenant context scopes short and obvious.
+
+## How Tenant Resolution Works in ABP
+
+ABP determines the active tenant through a chain of tenant resolvers. Out of the box, the default contributors are checked in this order:
+
+1. Current user claims
+2. Query string, using `__tenant` by default
+3. Route value
+4. Header
+5. Cookie
+
+In practice, this means a request can become tenant-aware even before your application service runs.
+
+### Default key configuration
+
+If you want to change the default `__tenant` key:
+
+```csharp
+Configure(options =>
+{
+ options.TenantKey = "tenant";
+});
+```
+
+This is fine, but if you have a frontend client, especially Angular, the client must use the same tenant key. Otherwise the backend and frontend silently disagree about tenant resolution.
+
+### Domain and subdomain based resolution
+
+ABP also supports domain or subdomain-based tenant resolution.
+
+```csharp
+Configure(options =>
+{
+ options.AddDomainTenantResolver("{0}.myapp.com");
+});
+```
+
+This is usually the cleanest user experience for SaaS applications because the tenant is implied by the hostname.
+
+Use it when:
+
+- each tenant has a branded subdomain
+- the URL should define tenant context naturally
+- you want fewer explicit tenant parameters in requests
+
+Be careful with:
+
+- reverse proxies and forwarded headers
+- wildcard DNS and TLS certificates
+- authentication server issuer validation in wildcard domain scenarios
+- local development setup
+
+If you use OpenIddict or token validation with wildcard domains, make sure issuer validation is configured for that pattern. This is one of the most common production surprises in subdomain-based multi-tenant setups.
+
+### Fallback tenant
+
+ABP can also use a fallback tenant.
+
+That can be convenient in development or in a constrained deployment model, but it comes with an important trade-off: you effectively reduce or hide host context behavior. Use fallback tenants deliberately, not as a shortcut for resolver problems.
+
+
+
+
+
+## Custom Tenant Resolvers for Real Projects
+
+Sooner or later, one tenant comes from a gateway header, another from a custom route pattern, and a third from a legacy integration.
+
+ABP allows custom tenant resolvers by implementing a contributor.
+
+```csharp
+using System.Threading.Tasks;
+using Volo.Abp.MultiTenancy;
+
+public class XTenantHeaderResolveContributor : TenantResolveContributorBase
+{
+ public const string HeaderName = "X-Tenant-Code";
+
+ public override string Name => "XTenantHeader";
+
+ public override Task ResolveAsync(ITenantResolveContext context)
+ {
+ var httpContext = context.GetHttpContext();
+ var tenantCode = httpContext?.Request.Headers[HeaderName].ToString();
+
+ if (!tenantCode.IsNullOrWhiteSpace())
+ {
+ context.Handled = true;
+ context.TenantIdOrName = tenantCode;
+ }
+
+ return Task.CompletedTask;
+ }
+}
+```
+
+Then register it in tenant resolve options.
+
+The main rule here is simple: prefer one primary strategy. A long resolver chain with multiple overlapping sources makes support harder.
+
+## Configure `DbContext` for Host and Tenant Sides
+
+When you move beyond a single shared database, `DbContext` design becomes a core architecture decision.
+
+ABP supports defining which side a context belongs to:
+
+- `Both`
+- `Host`
+- `Tenant`
+
+This matters when you want host-only tables to stay out of tenant databases, or when tenant databases should contain only selected modules.
+
+### Why this matters
+
+Suppose your host side includes tenant management, audit administration, and platform billing, but tenant databases should only include business tables and tenant-facing identity data.
+
+If you blindly configure every module in every context, your tenant databases will accumulate tables they should never have had.
+
+### Practical approach
+
+For a shared database setup, one `DbContext` with `Both` is often enough.
+
+For separate or hybrid databases, a common approach is:
+
+- one host/shared `DbContext`
+- one tenant-only `DbContext`
+- selective module configuration per context
+
+The important implementation detail is not just the side flag. It is also controlling which `builder.ConfigureXyz()` calls are applied in each context.
+
+For example, do not configure host-only modules in the tenant-only context.
+
+## Shared Database Setup: The Best Starting Point
+
+If you are implementing multi-tenancy for the first time in ABP, start with the shared database model unless you have a strong reason not to.
+
+A practical setup looks like this:
+
+1. Enable multi-tenancy
+2. Make tenant-owned entities implement `IMultiTenant`
+3. Use standard ABP repositories
+4. Resolve tenant from user, subdomain, or request key
+5. Keep host-owned data with `TenantId = null`
+6. Define permissions with proper tenancy sides
+
+Example entity creation inside a tenant context:
+
+```csharp
+public class ProductManager : DomainService
+{
+ public async Task CreateAsync(string name, decimal price)
+ {
+ var product = new Product(
+ GuidGenerator.Create(),
+ name,
+ price,
+ CurrentTenant.Id
+ );
+
+ return await _productRepository.InsertAsync(product);
+ }
+}
+```
+
+This works well because ABP naturally fills the application flow with tenant context.
+
+### Performance tips for shared database mode
+
+As tenant count grows:
+
+- index `TenantId` on large tables
+- include `TenantId` in common query patterns
+- monitor large shared tables early
+- be careful with cross-tenant reporting queries
+- verify all custom SQL is tenant-aware
+
+ABP helps with filtering, but it does not replace database design.
+
+## Separate Database per Tenant in ABP
+
+This is where ABP becomes especially useful, because it can resolve the active tenant and then use tenant-specific connection strings.
+
+The Tenant Management module stores tenant metadata, including optional connection strings.
+
+At a high level, the flow is:
+
+1. Resolve the current tenant
+2. Load tenant configuration
+3. Determine the right connection string
+4. Build the `DbContext` against the host or tenant database
+5. Apply data filters inside that database scope as needed
+
+### What is available out of the box
+
+ABP supports the architecture and connection-string-based separation.
+
+Version-wise, the latest ABP docs reflect improved support in open source for separate database per tenant. However, managing tenant connection strings from the UI remains tied to SaaS/PRO features. In open source, teams often provide this through custom admin screens, configuration management, or provisioning services.
+
+### What changes operationally
+
+With per-tenant databases, you now need a plan for:
+
+- database creation during tenant onboarding
+- migrations for new and existing tenant databases
+- tenant-specific seeding
+- backups and restore procedures
+- monitoring failed or drifted tenant databases
+
+This is the real cost of stronger isolation.
+
+
+
+
+
+## Hybrid Multi-Tenancy: Shared by Default, Dedicated When Needed
+
+Hybrid architecture is often the most business-friendly model.
+
+A common pattern looks like this:
+
+- default all new tenants to shared database
+- move larger or regulated tenants to dedicated databases
+- keep host/platform data in a central database
+
+This lets you defer infrastructure cost until a tenant actually needs isolation.
+
+The challenge is not whether ABP supports it. It does. The challenge is operational consistency:
+
+- how a tenant is promoted from shared to dedicated
+- how data is moved safely
+- how migrations stay aligned across both models
+- how support engineers know which storage model a tenant uses
+
+If you choose hybrid, document the lifecycle, not just the code.
+
+## Tenant Management, Onboarding, and Connection Strings
+
+ABP's Tenant Management module is the starting point for tenant administration.
+
+It gives you tenant records and a standard place to store metadata. In more advanced solutions, that metadata is often extended with:
+
+- edition or plan
+- onboarding status
+- provisioning result
+- custom domains
+- support tier
+- infrastructure notes
+
+For separate database scenarios, onboarding usually means more than creating a tenant row. It often includes:
+
+1. create tenant record
+2. assign connection string if needed
+3. create database or schema
+4. run migrations
+5. seed tenant data
+6. create admin user
+7. confirm domain or resolver setup
+
+Treat onboarding as a workflow, not a controller action.
+
+## Permissions and Authorization in a Multi-Tenant App
+
+ABP permissions can be scoped with `MultiTenancySides`.
+
+That is important because host users and tenant users often should not even see the same capabilities.
+
+Example definition:
+
+```csharp
+context.AddGroup(MyPermissions.GroupName)
+ .AddPermission(
+ MyPermissions.HostDashboard,
+ multiTenancySide: MultiTenancySides.Host
+ )
+ .AddPermission(
+ MyPermissions.TenantDashboard,
+ multiTenancySide: MultiTenancySides.Tenant
+ );
+```
+
+This is one of the easiest wins in ABP multi-tenancy. Use it early.
+
+### Why it matters in practice
+
+Without side-aware permission definitions:
+
+- host-only menus can appear in tenant UI
+- tenant-only features can leak into host administration
+- tests become confusing because behavior differs by login context
+
+Also remember that usernames can collide across tenants. That is normal in multi-tenant identity models. What matters is the combination of user identity and tenant context.
+
+## Migrations and Data Seeding Without Regret
+
+Multi-tenant EF Core migrations are straightforward in theory and messy in real systems if you skip the design phase.
+
+### Shared database
+
+This is simplest:
+
+- one database
+- one main migration flow
+- host and tenant data usually seeded into the same database with different contexts or `TenantId` semantics
+
+### Separate or hybrid databases
+
+Now you need to answer:
+
+- which context owns which schema
+- which migration runs against host DB
+- which migration runs against tenant DBs
+- when new tenants receive schema updates
+- how failed migrations are retried
+
+### Seeding strategy
+
+A practical model is:
+
+- seed host-level data in the host database
+- seed tenant defaults when a tenant is created
+- perform tenant seeding inside `CurrentTenant.Change(tenantId)` scopes where appropriate
+
+Example:
+
+```csharp
+using (_currentTenant.Change(tenantId))
+{
+ await _dataSeeder.SeedAsync(new DataSeedContext(tenantId));
+}
+```
+
+That keeps seeding logic tenant-aware and consistent with the rest of the application.
+
+## Common Pitfalls That Break Multi-Tenancy
+
+Most ABP multi-tenancy bugs are not framework bugs. They are design mistakes.
+
+### 1. Tenant-only entity accidentally allows host ownership
+
+If `TenantId` stays nullable for a strictly tenant-owned entity, host-side records can slip in. That often leads to confusing query behavior and data mixing.
+
+### 2. Custom SQL bypasses tenant filtering
+
+ABP filters repository and LINQ queries for `IMultiTenant` entities. Your raw SQL does not magically become safe. Always include tenant scope explicitly when writing custom SQL.
+
+### 3. Host-only modules end up in tenant databases
+
+This usually happens when all module mappings are copied into every `DbContext`. Be intentional about which modules are configured where.
+
+### 4. Resolver strategy is inconsistent
+
+For example:
+
+- frontend sends `tenant`
+- backend expects `__tenant`
+- API gateway injects a header
+- auth claims still refer to a different tenant source
+
+You can spend hours debugging what is really just inconsistent tenant resolution.
+
+### 5. Subdomain authentication is not fully configured
+
+Wildcard domains, issuer validation, proxy headers, and cookie domains all need a coherent setup. Subdomain multi-tenancy is elegant, but only after it is fully wired.
+
+### 6. Shared database performance is ignored too long
+
+If every large table relies on `TenantId` filters, indexing and query shape matter. This usually becomes painful gradually, then suddenly.
+
+## When to Use Shared, Separate, or Hybrid
+
+### Use shared database when
+
+- you are building a standard SaaS product
+- operational simplicity matters most
+- tenants are relatively small
+- strict physical isolation is not required
+- you want the fastest path to production
+
+### Use separate databases when
+
+- customers require stronger isolation
+- you need tenant-level backup and restore
+- data volume varies significantly between tenants
+- some tenants need independent scaling or maintenance windows
+- compliance requirements push you there
+
+### Use hybrid when
+
+- most tenants fit shared infrastructure
+- a few enterprise tenants need dedicated storage
+- you want to defer cost while preserving an upgrade path
+- your team can handle extra migration and operational complexity
+
+### When NOT to over-engineer it
+
+Do not start with hybrid just because it sounds flexible.
+
+If you are early-stage and do not yet have hard isolation requirements, shared database with good tenant discipline is usually the better engineering decision.
+
+## A Practical Implementation Plan
+
+If you want a sane rollout path, use this order:
+
+### Phase 1: Enable and model multi-tenancy
+
+- enable `AbpMultiTenancyOptions`
+- implement `IMultiTenant` on tenant-owned entities
+- review aggregate rules around nullable `TenantId`
+- define host vs tenant permissions correctly
+
+### Phase 2: Pick one tenant resolution strategy
+
+- prefer subdomain or authenticated user claim for web apps
+- keep request key resolution for APIs or development
+- make frontend and backend tenant key configuration consistent
+
+### Phase 3: Start with shared database
+
+- launch with shared DB unless requirements force separation
+- add indexes and monitoring early
+- verify custom queries are tenant-safe
+
+### Phase 4: Prepare for separation only where needed
+
+- isolate `DbContext` boundaries cleanly
+- separate host-only module configuration from tenant-only configuration
+- design onboarding and migration workflows
+- add support for per-tenant connection strings when the business actually needs it
+
+This path keeps your first release simple without blocking future isolation models.
+
+## Final Thoughts
+
+ABP Framework removes a lot of the repetitive work in multi-tenant .NET applications, but it does not remove architectural choices. You still need to decide how tenants are resolved, where data lives, which modules belong to which side, and how strict your isolation really needs to be.
+
+The best ABP multi-tenancy setups are usually boring in the right places:
+
+- one clear tenant resolution strategy
+- strict entity rules
+- explicit host vs tenant boundaries
+- a simple default database model
+- operational workflows designed before enterprise tenants arrive
+
+That is what keeps a multi-tenant system maintainable after the demo phase.
+
+## TL;DR
+
+- Enable ABP multi-tenancy explicitly, then model tenant-owned entities with `IMultiTenant` and disciplined `TenantId` rules.
+- Start with a shared database unless you already need stronger isolation, compliance, or tenant-level scaling.
+- Use `ICurrentTenant` and a clear tenant resolution strategy to keep application logic predictable.
+- For separate or hybrid databases, control `DbContext` boundaries, module mappings, migrations, and onboarding workflows carefully.
+- Define permissions with `MultiTenancySides` so host and tenant experiences stay clean and secure.
\ No newline at end of file
diff --git a/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/cover.png b/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/cover.png
new file mode 100644
index 0000000000..b0e2b14a68
Binary files /dev/null and b/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/cover.png differ
diff --git a/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/inline-1.png b/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/inline-1.png
new file mode 100644
index 0000000000..b0be708000
Binary files /dev/null and b/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/inline-1.png differ
diff --git a/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/inline-2.png b/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/inline-2.png
new file mode 100644
index 0000000000..0483d13091
Binary files /dev/null and b/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/inline-2.png differ
diff --git a/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/inline-3.png b/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/inline-3.png
new file mode 100644
index 0000000000..6e12a4f4d8
Binary files /dev/null and b/docs/en/Community-Articles/2026-06-22-how-to-implement-multitenancy-with-abp-framework/inline-3.png differ
diff --git a/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/Post.md b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/Post.md
new file mode 100644
index 0000000000..be87f21390
--- /dev/null
+++ b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/Post.md
@@ -0,0 +1,607 @@
+Background jobs are one of those features that look simple at first and become operationally important very quickly. Sending emails, generating reports, syncing with third-party APIs, cleaning expired data, and processing imports should not block your HTTP requests.
+
+ABP gives you a clean abstraction for background jobs, and Hangfire gives you a production-friendly execution engine with persistence, retries, queues, and a dashboard. The useful part is that you can keep your application code aligned with ABP’s abstractions while swapping in Hangfire as the actual runner.
+
+In this article, I’ll walk through how to implement background jobs with ABP and Hangfire, when to use each piece, and where teams usually get tripped up.
+
+## Why use Hangfire instead of ABP's default background job manager?
+
+ABP already has a built-in background job system, and it is perfectly fine for simple cases. But it helps to understand what you are trading.
+
+### ABP default job manager
+
+By default, ABP background jobs are:
+
+- Enqueued through `IBackgroundJobManager`
+- Executed in-process
+- FIFO-oriented
+- Single-threaded by default
+- Retried automatically with increasing delays
+- Stored through ABP's background job store
+
+This is good when:
+
+- Your app is small or moderate in workload
+- You want minimal setup
+- You do not need a dashboard
+- You do not need advanced queue management
+
+### Hangfire integration
+
+When you add `Volo.Abp.BackgroundJobs.HangFire`, ABP can keep the same `IBackgroundJobManager` programming model, but Hangfire becomes the execution backend.
+
+That gives you:
+
+- Durable job storage
+- Better operational visibility through the Hangfire dashboard
+- Multiple worker servers
+- Queue-based processing
+- Recurring jobs and scheduling features
+- A mature retry and monitoring model
+
+In practice, Hangfire is the better choice when background processing is part of the actual system design, not just a convenience.
+
+### Quick comparison
+
+Use ABP default when:
+
+- You want the simplest possible setup
+- Background jobs are low volume
+- A single app instance is enough
+- You do not need a dashboard or queue controls
+
+Use Hangfire when:
+
+- You need reliability across restarts
+- You run multiple instances
+- You need recurring jobs or queue isolation
+- You want to inspect failures and retries visually
+- Background processing is operationally important
+
+
+
+
+
+## Defining a background job in ABP
+
+The nice part of ABP is that your job code does not need to know about Hangfire.
+
+Start with a job arguments class:
+
+```csharp
+public class EmailSendingArgs
+{
+ public string To { get; set; } = string.Empty;
+ public string Subject { get; set; } = string.Empty;
+ public string Body { get; set; } = string.Empty;
+}
+```
+
+Then create the job itself:
+
+```csharp
+using System.Threading.Tasks;
+using Volo.Abp.BackgroundJobs;
+using Volo.Abp.DependencyInjection;
+
+public class EmailSendingJob : AsyncBackgroundJob, ITransientDependency
+{
+ private readonly IEmailSender _emailSender;
+
+ public EmailSendingJob(IEmailSender emailSender)
+ {
+ _emailSender = emailSender;
+ }
+
+ public override async Task ExecuteAsync(EmailSendingArgs args)
+ {
+ await _emailSender.SendAsync(
+ args.To,
+ args.Subject,
+ args.Body
+ );
+ }
+}
+```
+
+This job works with ABP’s job abstraction regardless of whether the runtime backend is the default implementation or Hangfire.
+
+To enqueue it:
+
+```csharp
+using System;
+using System.Threading.Tasks;
+using Volo.Abp.BackgroundJobs;
+
+public class NotificationAppService : ApplicationService
+{
+ private readonly IBackgroundJobManager _backgroundJobManager;
+
+ public NotificationAppService(IBackgroundJobManager backgroundJobManager)
+ {
+ _backgroundJobManager = backgroundJobManager;
+ }
+
+ public async Task QueueWelcomeEmailAsync(string email)
+ {
+ await _backgroundJobManager.EnqueueAsync(
+ new EmailSendingArgs
+ {
+ To = email,
+ Subject = "Welcome",
+ Body = "Your account is ready."
+ },
+ priority: BackgroundJobPriority.Normal,
+ delay: TimeSpan.FromMinutes(1)
+ );
+ }
+}
+```
+
+A few practical notes:
+
+- `delay` is useful for short deferrals and back-office workflows.
+- `priority` is part of ABP’s abstraction. How it maps operationally depends on the provider.
+- Keep argument objects small and serializable.
+- Do not pass EF entities or large object graphs into jobs.
+
+## Setting up Hangfire in an ABP application
+
+To integrate Hangfire, install the package and wire it into your ABP module.
+
+### 1. Add the package
+
+Using ABP CLI:
+
+```bash
+abp add-package Volo.Abp.BackgroundJobs.HangFire
+```
+
+Or with NuGet:
+
+```bash
+Install-Package Volo.Abp.BackgroundJobs.HangFire
+```
+
+### 2. Add the module dependency
+
+Typically this goes into your host module, such as `HttpApiHostModule`:
+
+```csharp
+using Volo.Abp.BackgroundJobs.Hangfire;
+
+[DependsOn(
+ typeof(AbpBackgroundJobsHangfireModule)
+)]
+public class MyProjectHttpApiHostModule : AbpModule
+{
+}
+```
+
+### 3. Configure Hangfire services
+
+In `ConfigureServices`:
+
+```csharp
+using Hangfire;
+using Microsoft.Extensions.Configuration;
+
+public override void ConfigureServices(ServiceConfigurationContext context)
+{
+ var configuration = context.Services.GetConfiguration();
+
+ context.Services.AddHangfire(config =>
+ {
+ config.UseSqlServerStorage(
+ configuration.GetConnectionString("Default")
+ );
+ });
+}
+```
+
+If you use PostgreSQL, Redis, or another Hangfire storage provider, configure that instead. The storage decision matters because all servers that process jobs must share the same backing store.
+
+### 4. Enable the Hangfire dashboard
+
+In `OnApplicationInitialization`:
+
+```csharp
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+
+public override void OnApplicationInitialization(ApplicationInitializationContext context)
+{
+ var app = context.GetApplicationBuilder();
+
+ app.UseAuthentication();
+ app.UseAuthorization();
+
+ app.UseAbpHangfireDashboard();
+}
+```
+
+The dashboard middleware should be added after authentication and authorization middleware.
+
+At this point, jobs enqueued through `IBackgroundJobManager` should use Hangfire as long as the integration is correctly activated.
+
+
+
+
+
+## End-to-end example: offloading a report export
+
+A common use case is exporting a report that may take several seconds or minutes.
+
+Instead of generating the file during the HTTP request:
+
+- Save an export request record
+- Enqueue a background job
+- Let the job generate the file
+- Notify the user when it is ready
+
+### Arguments
+
+```csharp
+public class ReportExportJobArgs
+{
+ public Guid ExportRequestId { get; set; }
+ public Guid UserId { get; set; }
+}
+```
+
+### Job implementation
+
+```csharp
+using System.Threading.Tasks;
+using Volo.Abp.BackgroundJobs;
+using Volo.Abp.DependencyInjection;
+using Volo.Abp.Uow;
+
+public class ReportExportJob : AsyncBackgroundJob, ITransientDependency
+{
+ private readonly IReportExportAppService _reportExportAppService;
+
+ public ReportExportJob(IReportExportAppService reportExportAppService)
+ {
+ _reportExportAppService = reportExportAppService;
+ }
+
+ public override async Task ExecuteAsync(ReportExportJobArgs args)
+ {
+ await _reportExportAppService.GenerateAsync(args.ExportRequestId, args.UserId);
+ }
+}
+```
+
+### Enqueue from app service
+
+```csharp
+public async Task RequestExportAsync()
+{
+ var exportRequestId = GuidGenerator.Create();
+
+ await _backgroundJobManager.EnqueueAsync(
+ new ReportExportJobArgs
+ {
+ ExportRequestId = exportRequestId,
+ UserId = CurrentUser.GetId()
+ }
+ );
+
+ return exportRequestId;
+}
+```
+
+This pattern scales much better than holding open a web request while doing CPU-heavy or IO-heavy work.
+
+## Retries, exceptions, and cancellation
+
+ABP and Hangfire both care about retries, but you should still design jobs carefully.
+
+### How ABP behaves
+
+With ABP background jobs:
+
+- Unhandled exceptions trigger retries
+- Retry intervals increase over time
+- Default implementation uses exponential backoff behavior
+- Jobs may eventually time out or be marked abandoned depending on configuration
+
+### What this means for your code
+
+A job should be:
+
+- Idempotent whenever possible
+- Safe to retry
+- Explicit about transient vs permanent failures
+
+For example, sending the same payment capture twice is dangerous. Sending the same “your report is ready” notification twice is annoying but manageable. Design around the difference.
+
+### Cancellation handling
+
+If you use `ICancellationTokenProvider`, be deliberate. If cancellation means “try again later,” let the exception flow. If cancellation means “stop and do not retry,” return gracefully.
+
+Example:
+
+```csharp
+using System.Threading;
+using System.Threading.Tasks;
+using Volo.Abp.BackgroundJobs;
+using Volo.Abp.Threading;
+
+public class DataSyncJob : AsyncBackgroundJob
+{
+ private readonly ICancellationTokenProvider _cancellationTokenProvider;
+
+ public DataSyncJob(ICancellationTokenProvider cancellationTokenProvider)
+ {
+ _cancellationTokenProvider = cancellationTokenProvider;
+ }
+
+ public override async Task ExecuteAsync(int args)
+ {
+ var cancellationToken = _cancellationTokenProvider.Token;
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ await Task.Delay(500, cancellationToken);
+ }
+}
+```
+
+### Practical guidance
+
+- Keep jobs short and composable
+- Persist progress if the job is large
+- Use domain/application services inside jobs instead of putting business logic directly into the job class
+- Log enough context to diagnose retries and failures
+
+## Recurring jobs and periodic work
+
+Not every background task is a one-time job.
+
+There are two different patterns:
+
+- Background jobs: one-off, delayed, or fire-and-forget work
+- Background workers: periodic or recurring work
+
+In ABP, recurring processing is usually modeled with background workers rather than standard background jobs.
+
+### When to use a worker instead of a job
+
+Use a worker when you need:
+
+- A scheduled cleanup task
+- A recurring sync with another system
+- Polling behavior
+- A cron-like schedule
+
+### Hangfire-backed recurring worker
+
+With Hangfire integration, you can derive from `HangfireBackgroundWorkerBase` and provide a cron expression.
+
+```csharp
+using System.Threading.Tasks;
+using Volo.Abp.BackgroundWorkers.Hangfire;
+
+public class ExpiredSessionsCleanupWorker : HangfireBackgroundWorkerBase
+{
+ private readonly ISessionCleanupService _sessionCleanupService;
+
+ public ExpiredSessionsCleanupWorker(ISessionCleanupService sessionCleanupService)
+ {
+ _sessionCleanupService = sessionCleanupService;
+
+ RecurringJobId = "expired-sessions-cleanup";
+ CronExpression = "0 * * * *";
+ }
+
+ public override async Task DoWorkAsync()
+ {
+ await _sessionCleanupService.CleanupAsync();
+ }
+}
+```
+
+A few details matter here:
+
+- `RecurringJobId` should be stable and unique.
+- `CronExpression` controls the schedule.
+- Hangfire recurring scheduling is minute-based in normal use, so do not expect second-level precision.
+
+### Background jobs vs background workers
+
+A simple rule:
+
+- If a user action creates work to do later, use a background job.
+- If the system itself needs to run something on a schedule, use a background worker.
+
+
+
+
+
+## Queue isolation and scaling across multiple instances
+
+Once you run more than one application instance, background processing becomes an architecture concern rather than a coding detail.
+
+### Shared storage is required
+
+If multiple nodes are going to process Hangfire jobs, they must share the same Hangfire storage.
+
+Typical setups include:
+
+- Multiple web instances + one shared SQL Server storage
+- Web instances enqueueing jobs + dedicated worker instances processing them
+- Separate deployment slots or services sharing the same Hangfire backend
+
+### Disabling execution on some nodes
+
+Sometimes you want your web app to enqueue jobs but not execute them.
+
+ABP supports this:
+
+```csharp
+using Microsoft.Extensions.DependencyInjection;
+using Volo.Abp.BackgroundJobs;
+
+public override void ConfigureServices(ServiceConfigurationContext context)
+{
+ Configure(options =>
+ {
+ options.IsJobExecutionEnabled = false;
+ });
+}
+```
+
+This is useful when:
+
+- You run dedicated worker processes
+- You want predictable resource allocation
+- You do not want front-end nodes competing for background work
+
+### Queue prefixing in clustered environments
+
+If multiple applications share the same Hangfire storage, isolate queues intentionally.
+
+For Hangfire integration in ABP, use `AbpHangfireOptions.DefaultQueuePrefix` to avoid queue collisions between different applications or environments.
+
+That matters more than teams expect. Without isolation, staging and production can end up looking at the same queues if storage is misconfigured.
+
+### Queue routing
+
+Hangfire supports multiple queues, and ABP’s Hangfire integration can route jobs based on conventions or attributes.
+
+In some scenarios, you may want specific jobs to go to specific queues, for example:
+
+- `emails`
+- `exports`
+- `integration`
+- `critical`
+
+This is especially helpful when one queue can become noisy and starve more important work.
+
+
+
+
+
+## Securing the Hangfire dashboard
+
+The Hangfire dashboard is extremely useful, but it is also an operations surface. Do not expose it casually.
+
+ABP provides authorization support for the dashboard via `AbpHangfireAuthorizationFilter`.
+
+A typical setup is to:
+
+- Require authentication
+- Restrict by permission or role
+- Optionally consider tenant-specific access rules
+
+Example:
+
+```csharp
+app.UseAbpHangfireDashboard("/hangfire", new DashboardOptions
+{
+ Authorization = new[]
+ {
+ new AbpHangfireAuthorizationFilter(requiredPermissionName: "Administration.Hangfire")
+ }
+});
+```
+
+Even if your app is internal, treat the dashboard like an admin area:
+
+- Put it behind authorization
+- Avoid exposing it publicly without network restrictions
+- Audit who can retry or inspect jobs
+
+## Common pitfalls and behavior differences
+
+This is the part that usually saves the most time.
+
+### 1. Jobs still land in `AbpBackgroundJob` instead of Hangfire
+
+If Hangfire is not properly activated, ABP may continue using its native background job storage and you will see jobs in the `AbpBackgroundJob` table instead of Hangfire storage.
+
+Check these first:
+
+- The `Volo.Abp.BackgroundJobs.HangFire` package is installed
+- `AbpBackgroundJobsHangfireModule` is added in `[DependsOn]`
+- `AddHangfire(...)` is configured correctly
+- The application starts with the expected module graph
+
+If any of those are missing, you may think you are using Hangfire while you are actually still on the default provider.
+
+### 2. Passing large or complex objects into jobs
+
+Keep job args small. Prefer identifiers over rich objects.
+
+Good:
+
+- `OrderId`
+- `UserId`
+- `ExportRequestId`
+
+Bad:
+
+- Full EF entities
+- Large DTO graphs
+- Objects with lazy-loading behavior or runtime-only state
+
+### 3. Non-idempotent job logic
+
+Retries will happen. If running the same job twice can corrupt data, redesign the workflow.
+
+Common fixes:
+
+- Add a processed flag
+- Use unique constraints where appropriate
+- Check prior execution status before applying side effects
+- Make external calls with idempotency keys when supported
+
+### 4. Assuming recurring jobs run with exact timing
+
+Hangfire recurring jobs are cron-based and typically evaluated on minute boundaries. That is fine for most scheduled business work, but it is not a real-time scheduler.
+
+### 5. Ignoring queue isolation in multi-app environments
+
+If several apps share one Hangfire store, queue naming and prefixing must be explicit. Otherwise, one application can accidentally process another application's jobs.
+
+## When to use / When NOT to use ABP + Hangfire
+
+### Use ABP + Hangfire when
+
+- You want ABP-friendly job abstractions with a stronger execution backend
+- You need operational visibility and retry inspection
+- You run multiple instances or worker nodes
+- You have recurring background tasks
+- Your jobs are part of business-critical workflows
+
+### Do NOT use it when
+
+- The work must complete synchronously before responding to the user
+- The task is so trivial that plain in-memory processing is enough
+- You need event streaming rather than job scheduling
+- You need ultra-low-latency real-time processing with very tight timing guarantees
+
+For many line-of-business systems, ABP + Hangfire hits a very practical middle ground: easy enough to implement, strong enough to operate.
+
+## A production-minded implementation checklist
+
+Before shipping, verify these points:
+
+- Jobs are enqueued through `IBackgroundJobManager` unless you explicitly need Hangfire-specific APIs
+- Job arguments are small and serializable
+- Job logic is retry-safe and preferably idempotent
+- Hangfire storage is shared by all processing nodes
+- Dashboard access is restricted
+- Queue names or prefixes are isolated per app/environment
+- Long-running jobs are split into manageable steps where possible
+- You know which nodes execute jobs and which only enqueue them
+
+## TL;DR
+
+- ABP gives you a clean background job abstraction; Hangfire gives you the production-grade execution engine.
+- Keep using `IBackgroundJobManager` for most jobs so your application code stays provider-independent.
+- Use background jobs for one-off work and Hangfire-backed background workers for recurring tasks.
+- In multi-instance deployments, shared storage, queue isolation, and dashboard security are not optional.
+- If jobs still go to `AbpBackgroundJob`, your Hangfire integration is probably not fully activated.
\ No newline at end of file
diff --git a/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/cover.png b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/cover.png
new file mode 100644
index 0000000000..933dd3399c
Binary files /dev/null and b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/cover.png differ
diff --git a/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-1.png b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-1.png
new file mode 100644
index 0000000000..62cec256e6
Binary files /dev/null and b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-1.png differ
diff --git a/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-2.png b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-2.png
new file mode 100644
index 0000000000..1d04df4a76
Binary files /dev/null and b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-2.png differ
diff --git a/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-3.png b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-3.png
new file mode 100644
index 0000000000..a22d1c5d24
Binary files /dev/null and b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-3.png differ
diff --git a/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-4.png b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-4.png
new file mode 100644
index 0000000000..04444db117
Binary files /dev/null and b/docs/en/Community-Articles/2026-06-22-implementing-background-jobs-with-abp-and-hangfire/inline-4.png differ
diff --git a/docs/en/Community-Articles/2026-06-23-deep-dive-index/Post.md b/docs/en/Community-Articles/2026-06-23-deep-dive-index/Post.md
new file mode 100644
index 0000000000..b3672e120c
--- /dev/null
+++ b/docs/en/Community-Articles/2026-06-23-deep-dive-index/Post.md
@@ -0,0 +1,41 @@
+# Deep Dive on ABP AI Agent: The Complete Series
+
+ABP Studio is a development platform built around ABP Framework. With the introduction of **ABP AI Coding Agent**, it became something more: a platform where an AI agent works inside the same environment you already use to build, run, monitor, and ship ABP solutions.
+
+
+
+General-purpose AI coding tools are excellent for horizontal, file-shaped work. They read source files, edit them, and run shell commands. But ABP solutions are **system-shaped**, not just file-shaped. A typical ABP solution is split across multiple modules and layers with strict dependency rules, composed of many runnable units (HTTP services, gateways, identity servers, background workers, Docker containers), and built on a strong set of conventions: aggregate roots, repositories, application services, DTOs, permissions, localization, event bus, distributed cache, and background jobs.
+
+A generic agent has none of that vocabulary. It does not know what a module is, which project is the Domain layer, or that an `ApplicationService` should not depend on a `DbContext` directly. It cannot start your microservices, gateway, and auth server together. It cannot tell you that the latest edit caused a runtime exception in the Identity service, because it has no concept of a running application.
+
+ABP AI Coding Agent was built to close exactly that gap. The agent is born inside a platform that already understands modules, run profiles, builds, migrations, proxies, Docker containers, monitoring, and Git workflows, and it uses every one of them.
+
+We wrote a **nine-part deep dive series** to explain how each part of this system works, not as a product tour, but as a practical look at the decisions, controls, and architecture behind the experience.
+
+## The Series
+
+1. **[Agent, Plan and Ask Modes](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-1-agent-plan-and-ask-modes-62wteg9t)** — The three interaction modes that control how much action the agent is allowed to take: **Ask** for understanding (read-only), **Plan** for designing the approach before editing, and **Agent** for full implementation with builds, tools, and iteration.
+
+2. **[Supported AI Models + Usage Recommendations](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-2-supported-ai-models-in-abp-3krbc7yc)** — How ABP Studio separates models by role (main, research, browser, text processor, Git review) and why treating model selection as a practical decision based on the task leads to a better balance of capability, speed, and cost.
+
+3. **[Rules, Skills and Lessons](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-3-rules-skills-and-lessons-ai6kxubt)** — The three mechanisms that give the agent solution-specific memory: **Rules** (always-on conventions), **Skills** (on-demand procedures), and **Lessons** (corrections the agent records and carries forward).
+
+4. **[Integrated ABP Studio Tools](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-4-integrated-abp-studio-tools-be2xa2om)** — The tools that connect the agent to ABP Studio's runtime environment: monitoring (exceptions, logs, requests), applications, containers, tasks, and build actions, with a practical walkthrough showing the difference between debugging with and without tool access.
+
+5. **[MCP (Model Context Protocol)](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-5-mcp-model-context-protocol-trb9o4ev)** — How MCP extends the agent beyond the solution boundary to reach external systems like Prometheus, SEO analyzers, or documentation services, with per-tool enable/disable controls and stdio/HTTP server support.
+
+6. **[ABP Studio Git Integration](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-6-abp-studio-git-integration-09tr41ec)** — The full Git loop inside ABP Studio: branching, diffing, AI-generated commit messages, AI code review on staged changes, GitHub issue context for starting tasks, and pull request feedback for addressing reviewer comments.
+
+7. **[Scopes](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-7-scopes-tfqtkdzu)** — How AI Scopes restrict the agent's working area to specific modules, packages, or solution areas, reducing unrelated exploration, preventing accidental edits, and making diffs easier to review.
+
+8. **[Parallel Agent Execution](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-8-parallel-agent-execution-1o0cik6g)** — Running multiple agent sessions at the same time, each with its own mode, model, scope, and workflow, plus read-only research subagents that fan out inside a single session.
+
+9. **[Workflows](https://abp.io/community/articles/deep-dive-on-abp-ai-agent-9-workflows-7jo1adb1)** — Repeatable before/after steps that wrap agent runs: start containers, build packages, add migrations, generate proxies, restart applications, and run validation tasks, so the agent focuses on the code change while the platform handles the deterministic parts.
+
+## The Bigger Picture
+
+Each article focuses on one feature, but the real value comes from how they work together.
+
+Modes decide how much action the agent takes. Models decide which brain handles the work. Rules, Skills, and Lessons shape what the agent knows. Tools and MCP extend what it can reach. Scopes define where it can work. Workflows define what happens around the work. Git Integration makes the result reviewable and recoverable. Parallel Execution lets multiple tasks move forward at the same time.
+
+That is the ABP AI Coding Agent experience: **not a single AI button, but a set of controls built into a platform that already understands how ABP solutions are developed, run, and maintained.**
diff --git a/docs/en/Community-Articles/2026-06-23-deep-dive-index/abp-studio-new-design.png b/docs/en/Community-Articles/2026-06-23-deep-dive-index/abp-studio-new-design.png
new file mode 100644
index 0000000000..d74add4b2d
Binary files /dev/null and b/docs/en/Community-Articles/2026-06-23-deep-dive-index/abp-studio-new-design.png differ
diff --git a/docs/en/Community-Articles/2026-06-23-deep-dive-index/cover.png b/docs/en/Community-Articles/2026-06-23-deep-dive-index/cover.png
new file mode 100644
index 0000000000..9d0d5f5987
Binary files /dev/null and b/docs/en/Community-Articles/2026-06-23-deep-dive-index/cover.png differ
diff --git a/docs/en/framework/infrastructure/object-to-object-mapping.md b/docs/en/framework/infrastructure/object-to-object-mapping.md
index 5dcf526266..d8484a277e 100644
--- a/docs/en/framework/infrastructure/object-to-object-mapping.md
+++ b/docs/en/framework/infrastructure/object-to-object-mapping.md
@@ -254,8 +254,8 @@ public partial class UserToUserDtoMapper : TwoWayMapperBase
public override partial UserDto Map(User source);
public override partial void Map(User source, UserDto destination);
- public override partial User ReverseMap(UserDto destination);
- public override partial void ReverseMap(UserDto destination, User source);
+ public override partial User ReverseMap(UserDto source);
+ public override partial void ReverseMap(UserDto source, User destination);
}
````
@@ -280,15 +280,15 @@ public partial class UserToUserDtoMapper : TwoWayMapperBase
//TODO: Perform actions after the mapping
}
- public override partial User ReverseMap(UserDto destination);
- public override partial void ReverseMap(UserDto destination, User source);
+ public override partial User ReverseMap(UserDto source);
+ public override partial void ReverseMap(UserDto source, User destination);
- public override partial void BeforeReverseMap(UserDto destination)
+ public override partial void BeforeReverseMap(UserDto source)
{
//TODO: Perform actions before the reverse mapping
}
- public override partial void AfterReverseMap(UserDto destination, User source)
+ public override partial void AfterReverseMap(UserDto source, User destination)
{
//TODO: Perform actions after the reverse mapping
}
diff --git a/docs/en/others/penetration-test-report.md b/docs/en/others/penetration-test-report.md
index 8ca58a57c9..beee9caf04 100644
--- a/docs/en/others/penetration-test-report.md
+++ b/docs/en/others/penetration-test-report.md
@@ -7,7 +7,7 @@
# ABP Penetration Test Report
-The ABP Commercial MVC `v10.4.0` application template has been tested against security vulnerabilities by the [OWASP ZAP v2.14.0](https://www.zaproxy.org/) tool. The demo web application was scanned on a local HTTPS address. The below alerts have been reported by the pentest tool. These alerts are sorted by the risk level as high, medium, and low. The informational alerts are not mentioned in this document.
+The ABP Commercial MVC `v10.4.1` application template has been tested against security vulnerabilities by the [OWASP ZAP v2.14.0](https://www.zaproxy.org/) tool. The demo web application was scanned on a local HTTPS address. The below alerts have been reported by the pentest tool. These alerts are sorted by the risk level as high, medium, and low. The informational alerts are not mentioned in this document.
Many of these alerts are **false-positive**, meaning the vulnerability scanner detected these issues, but they are not exploitable. It's clearly explained for each false-positive alert why this alert is a false-positive.
diff --git a/docs/en/package-version-changes.md b/docs/en/package-version-changes.md
index 3bdaf00a17..8049ffa986 100644
--- a/docs/en/package-version-changes.md
+++ b/docs/en/package-version-changes.md
@@ -7,6 +7,15 @@
# Package Version Changes
+## 10.5.0-rc.4
+
+| Package | Old Version | New Version | PR |
+|---------|-------------|-------------|-----|
+| Blazorise | 2.1.3 | 2.2.1 | #25683 |
+| Blazorise.Components | 2.1.3 | 2.2.1 | #25683 |
+| Blazorise.DataGrid | 2.1.3 | 2.2.1 | #25683 |
+| Blazorise.Snackbar | 2.1.3 | 2.2.1 | #25683 |
+
## 10.5.0-rc.1
| Package | Old Version | New Version | PR |
diff --git a/docs/en/release-info/migration-guides/AutoMapper-To-Mapperly.md b/docs/en/release-info/migration-guides/AutoMapper-To-Mapperly.md
index f19449d04f..ddae40e1d0 100644
--- a/docs/en/release-info/migration-guides/AutoMapper-To-Mapperly.md
+++ b/docs/en/release-info/migration-guides/AutoMapper-To-Mapperly.md
@@ -95,8 +95,8 @@ public partial class OrganizationUnitRoleToOrganizationUnitRoleDtoMapper : TwoWa
public override partial OrganizationUnitRoleDto Map(OrganizationUnitRole source);
public override partial void Map(OrganizationUnitRole source, OrganizationUnitRoleDto destination);
- public override partial OrganizationUnitRole ReverseMap(OrganizationUnitRoleDto destination);
- public override partial void ReverseMap(OrganizationUnitRoleDto destination, OrganizationUnitRole source);
+ public override partial OrganizationUnitRole ReverseMap(OrganizationUnitRoleDto source);
+ public override partial void ReverseMap(OrganizationUnitRoleDto source, OrganizationUnitRole destination);
}
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
@@ -306,8 +306,8 @@ public partial class OrganizationUnitRoleToOrganizationUnitRoleDtoMapper : TwoWa
public override partial OrganizationUnitRoleDto Map(OrganizationUnitRole source);
public override partial void Map(OrganizationUnitRole source, OrganizationUnitRoleDto destination);
- public override partial OrganizationUnitRole ReverseMap(OrganizationUnitRoleDto destination);
- public override partial void ReverseMap(OrganizationUnitRoleDto destination, OrganizationUnitRole source);
+ public override partial OrganizationUnitRole ReverseMap(OrganizationUnitRoleDto source);
+ public override partial void ReverseMap(OrganizationUnitRoleDto source, OrganizationUnitRole destination);
}
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
diff --git a/docs/en/solution-templates/layered-web-application/deployment/deployment-iis.md b/docs/en/solution-templates/layered-web-application/deployment/deployment-iis.md
index f84b221bb0..047f8bb079 100644
--- a/docs/en/solution-templates/layered-web-application/deployment/deployment-iis.md
+++ b/docs/en/solution-templates/layered-web-application/deployment/deployment-iis.md
@@ -134,7 +134,7 @@ You can execute this command in your command prompt.
````bash
cd Desktop # or another path
-mkcert -pkcs12 auth.sample api.sample web.sample # Replace with your domain names
+mkcert -pkcs12 {{ if Tiered == "Yes" }}auth.sample api.sample web.sample{{ else if UI == "NG" || UI == "Blazor" }}www.sample app.sample{{ else }}www.sample{{ end }} # Replace with your domain names
````
Rename the created file extension to ".pfx"
@@ -147,19 +147,33 @@ Import the certificate to IIS
Add domain names to hosts file(in Windows: `C:\Windows\System32\drivers\etc\hosts`, in Linux and macOS: `/etc/hosts`).
+{{ if Tiered == "Yes" }}
+
> For example, in a tiered MVC project.
+
````json
127.0.0.1 auth.sample
127.0.0.1 api.sample
127.0.0.1 web.sample
````
+{{ else }}
+
+````json
+127.0.0.1 www.sample{{ if UI == "NG" || UI == "Blazor" }}
+127.0.0.1 app.sample{{ end }}
+````
+
+{{ end }}
+
## Publish the Application(s) On IIS
### Update the appsettings
Update the appsettings according to your project type and domain names.
+{{ if Tiered == "Yes" }}
+
> For example, in a tiered MVC project.
````json
@@ -237,6 +251,37 @@ Update the appsettings according to your project type and domain names.
}
````
+{{ else }}
+
+> In a non-tiered solution there is a single application that also acts as the authentication server, so `App:SelfUrl` and `AuthServer:Authority` point to the same URL.
+
+````json
+//{{ if UI == "NG" || UI == "Blazor" }}HttpApi.Host{{ else if UI == "BlazorServer" }}Blazor{{ else }}Web{{ end }}
+{
+ "App": {
+ "SelfUrl": "https://www.sample",{{ if UI == "NG" || UI == "Blazor" }}
+ "CorsOrigins": "https://app.sample",
+ "RedirectAllowedUrls": "https://app.sample",{{ else if UI == "BlazorServer" }}
+ "RedirectAllowedUrls": "https://www.sample",{{ end }}
+ "DisablePII": "false"{{ if UI == "NG" || UI == "Blazor" }},
+ "HealthCheckUrl": "/health-status"{{ end }}
+ },
+ "ConnectionStrings": {
+ "Default": "Server=volo.sample;Database=Sample;User Id=sa;Password=1q2w3E**;TrustServerCertificate=true"
+ },
+ "AuthServer": {
+ "Authority": "https://www.sample",
+ "RequireHttpsMetadata": "true"{{ if UI == "NG" || UI == "Blazor" }},
+ "SwaggerClientId": "Sample_Swagger"{{ end }}
+ },
+ "StringEncryption": {
+ "DefaultPassPhrase": "f9uRkTLdtAZLmlh3"
+ }
+}
+````
+
+{{ end }}
+
### Copy the .pfx file
You need to copy pfx file from ./src/{{ if Tiered == "Yes" }}AuthServer{{ else if UI == "NG" || UI == "Blazor" }}HttpApi.Host{{ else if UI == "BlazorServer" }}Blazor{{ else }}Web{{ end }} to ./publish/{{ if Tiered == "Yes" }}authserver{{ else if UI == "NG" || UI == "Blazor" }}apihost{{ else if UI == "BlazorServer" }}blazor{{ else }}web{{ end }} folder.
diff --git a/docs/en/studio/release-notes.md b/docs/en/studio/release-notes.md
index f6c9e276f2..e5c9a1f474 100644
--- a/docs/en/studio/release-notes.md
+++ b/docs/en/studio/release-notes.md
@@ -9,7 +9,25 @@
This document contains **brief release notes** for each ABP Studio release. Release notes only include **major features** and **visible enhancements**. Therefore, they don't include all the development done in the related version.
-## 3.0.4 (2026-06-03) Latest
+## 3.0.6 (2026-06-18) Latest
+
+* Stabilize Domain.Tests across platforms and fix ReactNative test regression
+* [Linux-x64 Support] Align Linux publish with RID-based packaging
+* Fix GitHub integration problem on production caused
+* Use deferred UI scheduler and marshal CEF updates
+
+## 3.0.5 (2026-06-17)
+
+* Set `DOTNET_INSTALL_DIR` on self-hosted runners in template build workflow
+* Fix mudblazor template delete conditions for dashboard pages
+* Replace modern template AppUser bulk seed backfill with admin bootstrap
+* Fix login button URL in app-nolayers Blazor.WebAssembly template
+* Improve `#blazor-error-ui` visibility on Blazor templates
+* Remove leftover `github-mud-test` source and npm rc patch from templates
+* Improve modern React template error handling
+* Remove sample CRUD from modern microservice template
+
+## 3.0.4 (2026-06-03)
* Blazor UI Selector: Added a built-in choice between MudBlazor and Blazorise for Blazor solution templates
* React Native Updates: Upgraded Expo to the latest version, improved keyboard handling, and ensured UI consistency
diff --git a/docs/en/studio/version-mapping.md b/docs/en/studio/version-mapping.md
index d254004d4f..443c2a2ee5 100644
--- a/docs/en/studio/version-mapping.md
+++ b/docs/en/studio/version-mapping.md
@@ -11,7 +11,7 @@ This document provides a general overview of the relationship between various ve
| **ABP Studio Version** | **ABP Version of Startup Template** |
|------------------------|---------------------------|
-| 3.0.4 | 10.4.1 |
+| 3.0.4 - 3.0.6 | 10.4.1 |
| 3.0.3 | 10.4.0 |
| 2.2.7 - 3.0.2 | 10.3.0 |
| 2.2.5 - 2.2.6 | 10.2.0 |
diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs
index 9da7e7454f..0f80fe219d 100644
--- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs
+++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs
@@ -1,3 +1,4 @@
+using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Filters;
@@ -216,6 +217,27 @@ public class AbpAspNetCoreMvcModule : AbpModule
});
ConfigureRouteBasedCulture(context);
+ DecorateAntiforgery(context);
+ }
+
+ protected virtual void DecorateAntiforgery(ServiceConfigurationContext context)
+ {
+ // Wrap the registered IAntiforgery (DefaultAntiforgery from AddAntiforgery by default) with
+ // AbpAntiforgery so every antiforgery entry point goes through the claim normalization.
+ // Only an implementation-type registration is wrapped; a custom factory/instance registration of
+ // IAntiforgery is left as-is.
+ var descriptor = context.Services.LastOrDefault(d => d.ServiceType == typeof(IAntiforgery));
+ if (descriptor?.ImplementationType == null)
+ {
+ return;
+ }
+
+ context.Services.Replace(ServiceDescriptor.Describe(
+ typeof(IAntiforgery),
+ sp => new AbpAntiforgery(
+ (IAntiforgery)ActivatorUtilities.CreateInstance(sp, descriptor.ImplementationType),
+ sp.GetRequiredService>()),
+ descriptor.Lifetime));
}
protected virtual void ConfigureRouteBasedCulture(ServiceConfigurationContext context)
diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiForgeryClaimsPrincipalNormalizer.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiForgeryClaimsPrincipalNormalizer.cs
new file mode 100644
index 0000000000..9275737245
--- /dev/null
+++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiForgeryClaimsPrincipalNormalizer.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Linq;
+using System.Security.Claims;
+using Volo.Abp.DependencyInjection;
+using Volo.Abp.Security.Claims;
+
+namespace Volo.Abp.AspNetCore.Mvc.AntiForgery;
+
+public class AbpAntiForgeryClaimsPrincipalNormalizer : IAbpAntiForgeryClaimsPrincipalNormalizer, ITransientDependency
+{
+ public const string UserIdClaimIssuer = "AbpAntiForgery";
+
+ protected virtual string NormalizedIssuer => UserIdClaimIssuer;
+
+ public virtual ClaimsPrincipal Normalize(ClaimsPrincipal principal)
+ {
+ var normalized = new ClaimsPrincipal();
+
+ foreach (var identity in principal.Identities)
+ {
+ normalized.AddIdentity(NormalizeIdentity(identity));
+ }
+
+ return normalized;
+ }
+
+ protected virtual ClaimsIdentity NormalizeIdentity(ClaimsIdentity identity)
+ {
+ return new ClaimsIdentity(
+ identity.Claims.Select(NormalizeClaim),
+ identity.AuthenticationType,
+ identity.NameClaimType,
+ identity.RoleClaimType)
+ {
+ Actor = identity.Actor,
+ BootstrapContext = identity.BootstrapContext,
+ Label = identity.Label
+ };
+ }
+
+ protected virtual Claim NormalizeClaim(Claim claim)
+ {
+ var newClaim = new Claim(
+ claim.Type,
+ claim.Value,
+ claim.ValueType,
+ IsUserIdentifierClaim(claim.Type) ? NormalizedIssuer : claim.Issuer,
+ claim.OriginalIssuer);
+
+ foreach (var property in claim.Properties)
+ {
+ newClaim.Properties[property.Key] = property.Value;
+ }
+
+ return newClaim;
+ }
+
+ // The claim types DefaultClaimUidExtractor inspects, in priority order, to build the antiforgery user id.
+ protected virtual bool IsUserIdentifierClaim(string claimType)
+ {
+ return string.Equals(claimType, AbpClaimTypes.UserId, StringComparison.Ordinal) ||
+ string.Equals(claimType, "sub", StringComparison.Ordinal) ||
+ string.Equals(claimType, ClaimTypes.NameIdentifier, StringComparison.Ordinal) ||
+ string.Equals(claimType, ClaimTypes.Upn, StringComparison.Ordinal);
+ }
+}
diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiForgeryOptions.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiForgeryOptions.cs
index 0c2cf8884c..f57a38d57a 100644
--- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiForgeryOptions.cs
+++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiForgeryOptions.cs
@@ -24,6 +24,13 @@ public class AbpAntiForgeryOptions
///
public bool AutoValidate { get; set; } = true;
+ ///
+ /// Normalizes the user id claim issuer before generating/validating the antiforgery token, so the
+ /// same user produces the same token identifier under both cookie and bearer authentication.
+ /// Default value: true.
+ ///
+ public bool NormalizeUserIdClaimIssuer { get; set; } = true;
+
///
/// A predicate to filter types to auto-validate.
/// Return true to select the type to validate.
diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiforgery.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiforgery.cs
new file mode 100644
index 0000000000..22c4900379
--- /dev/null
+++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiforgery.cs
@@ -0,0 +1,101 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Antiforgery;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace Volo.Abp.AspNetCore.Mvc.AntiForgery;
+
+// Wraps the framework IAntiforgery so the antiforgery token's per-user identifier is computed against a
+// normalized principal on every entry point (generation and validation, controllers and Razor Pages,
+// ABP and built-in filters, cookie and bearer). This keeps the same user consistent across schemes whose
+// user id claim carries a different issuer (e.g. "LOCAL AUTHORITY" for the Identity cookie vs. the token
+// authority for a validated JWT or an OIDC cookie).
+public class AbpAntiforgery : IAntiforgery
+{
+ protected IAntiforgery Inner { get; }
+
+ protected AbpAntiForgeryOptions Options { get; }
+
+ public AbpAntiforgery(
+ IAntiforgery inner,
+ IOptions options)
+ {
+ Inner = inner;
+ Options = options.Value;
+ }
+
+ public virtual AntiforgeryTokenSet GetAndStoreTokens(HttpContext httpContext)
+ {
+ return WithNormalizedUser(httpContext, () => Inner.GetAndStoreTokens(httpContext));
+ }
+
+ public virtual AntiforgeryTokenSet GetTokens(HttpContext httpContext)
+ {
+ return WithNormalizedUser(httpContext, () => Inner.GetTokens(httpContext));
+ }
+
+ public virtual Task IsRequestValidAsync(HttpContext httpContext)
+ {
+ return WithNormalizedUserAsync(httpContext, () => Inner.IsRequestValidAsync(httpContext));
+ }
+
+ public virtual Task ValidateRequestAsync(HttpContext httpContext)
+ {
+ return WithNormalizedUserAsync(httpContext, async () =>
+ {
+ await Inner.ValidateRequestAsync(httpContext);
+ return true;
+ });
+ }
+
+ public virtual void SetCookieTokenAndHeader(HttpContext httpContext)
+ {
+ WithNormalizedUser(httpContext, () =>
+ {
+ Inner.SetCookieTokenAndHeader(httpContext);
+ return true;
+ });
+ }
+
+ protected virtual T WithNormalizedUser(HttpContext httpContext, Func action)
+ {
+ if (!Options.NormalizeUserIdClaimIssuer)
+ {
+ return action();
+ }
+
+ var normalizer = httpContext.RequestServices.GetRequiredService();
+ var originalPrincipal = httpContext.User;
+ httpContext.User = normalizer.Normalize(originalPrincipal);
+ try
+ {
+ return action();
+ }
+ finally
+ {
+ httpContext.User = originalPrincipal;
+ }
+ }
+
+ protected virtual async Task WithNormalizedUserAsync(HttpContext httpContext, Func> action)
+ {
+ if (!Options.NormalizeUserIdClaimIssuer)
+ {
+ return await action();
+ }
+
+ var normalizer = httpContext.RequestServices.GetRequiredService();
+ var originalPrincipal = httpContext.User;
+ httpContext.User = normalizer.Normalize(originalPrincipal);
+ try
+ {
+ return await action();
+ }
+ finally
+ {
+ httpContext.User = originalPrincipal;
+ }
+ }
+}
diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/IAbpAntiForgeryClaimsPrincipalNormalizer.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/IAbpAntiForgeryClaimsPrincipalNormalizer.cs
new file mode 100644
index 0000000000..bd2a581b5f
--- /dev/null
+++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AntiForgery/IAbpAntiForgeryClaimsPrincipalNormalizer.cs
@@ -0,0 +1,10 @@
+using System.Security.Claims;
+
+namespace Volo.Abp.AspNetCore.Mvc.AntiForgery;
+
+public interface IAbpAntiForgeryClaimsPrincipalNormalizer
+{
+ // Returns a copy of the principal whose user identifier claims carry a stable issuer, so the
+ // antiforgery token's per-user identifier is the same across authentication schemes.
+ ClaimsPrincipal Normalize(ClaimsPrincipal principal);
+}
diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs
index 464981cb3c..bd5e1e82a0 100644
--- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs
+++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs
@@ -175,7 +175,8 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
GetSupportedVersions(controllerType, method, setting),
allowAnonymous,
authorizeModels,
- implementFrom
+ implementFrom,
+ GetReturnValueContentTypes(apiDescription)
)
);
@@ -199,6 +200,27 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
}
}
+ private static List? GetReturnValueContentTypes(ApiDescription apiDescription)
+ {
+ var preferred = apiDescription.SupportedResponseTypes
+ .FirstOrDefault(x => x.StatusCode == 200 && x.ApiResponseFormats.Any())
+ ?? apiDescription.SupportedResponseTypes
+ .FirstOrDefault(x => x.StatusCode is >= 200 and < 300 && x.ApiResponseFormats.Any());
+
+ if (preferred == null)
+ {
+ return null;
+ }
+
+ var contentTypes = preferred.ApiResponseFormats
+ .Select(f => f.MediaType)
+ .Where(m => !string.IsNullOrWhiteSpace(m))
+ .Distinct()
+ .ToList();
+
+ return contentTypes.Count > 0 ? contentTypes : null;
+ }
+
private static List GetSupportedVersions(Type controllerType, MethodInfo method,
ConventionalControllerSetting? setting)
{
diff --git a/framework/src/Volo.Abp.AspNetCore.SignalR/Volo/Abp/AspNetCore/SignalR/HubConfigList.cs b/framework/src/Volo.Abp.AspNetCore.SignalR/Volo/Abp/AspNetCore/SignalR/HubConfigList.cs
index 0f2a606038..e54734139c 100644
--- a/framework/src/Volo.Abp.AspNetCore.SignalR/Volo/Abp/AspNetCore/SignalR/HubConfigList.cs
+++ b/framework/src/Volo.Abp.AspNetCore.SignalR/Volo/Abp/AspNetCore/SignalR/HubConfigList.cs
@@ -7,7 +7,7 @@ public class HubConfigList : List
{
public void AddOrUpdate(Action? configAction = null)
{
- AddOrUpdate(typeof(THub));
+ AddOrUpdate(typeof(THub), configAction);
}
public void AddOrUpdate(Type hubType, Action? configAction = null)
diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/AbpIoSourceCodeStore.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/AbpIoSourceCodeStore.cs
index 257e12d0dc..84fe8324de 100644
--- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/AbpIoSourceCodeStore.cs
+++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/AbpIoSourceCodeStore.cs
@@ -169,7 +169,15 @@ public class AbpIoSourceCodeStore : ISourceCodeStore, ITransientDependency
if (!string.IsNullOrWhiteSpace(templateSource) && !IsNetworkSource(templateSource))
{
Logger.LogInformation("Using local " + type + ": " + name + ", version: " + version);
- return new TemplateFile(File.ReadAllBytes(Path.Combine(templateSource, name + "-" + version + ".zip")),
+
+ // A local template source can be a direct ".zip" file path or a folder that contains the "{name}-{version}.zip" file.
+ var localTemplateFilePath = templateSource.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
+ ? templateSource
+ : Path.Combine(templateSource, name.Replace("/", ".").EnsureEndsWith('-') + version + ".zip");
+
+ Logger.LogInformation("Using local template file: " + localTemplateFilePath);
+
+ return new TemplateFile(File.ReadAllBytes(localTemplateFilePath),
version, latestVersion, nugetVersion);
}
@@ -188,16 +196,6 @@ public class AbpIoSourceCodeStore : ISourceCodeStore, ITransientDependency
return new TemplateFile(File.ReadAllBytes(localCacheFile), version, latestVersion, nugetVersion);
}
- if (!skipCache && !templateSource.IsNullOrWhiteSpace() && type == SourceCodeTypes.Template)
- {
- var templateFilePath = templateSource.EndsWith(".zip")
- ? templateSource
- : Path.Combine(templateSource, name.Replace("/", ".").EnsureEndsWith('-') + version + ".zip");
-
- Logger.LogInformation("Using cached template: " + name + ", version: " + version + " from template source: " + templateFilePath);
- return new TemplateFile(File.ReadAllBytes(templateFilePath), version, latestVersion, nugetVersion);
- }
-
Logger.LogInformation("Downloading " + type + ": " + name + ", version: " + version);
var fileContent = await DownloadSourceCodeContentAsync(
diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/TemplateProjectBuilder.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/TemplateProjectBuilder.cs
index 4a06557831..1a27ae84d0 100644
--- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/TemplateProjectBuilder.cs
+++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/ProjectBuilding/TemplateProjectBuilder.cs
@@ -69,6 +69,7 @@ public class TemplateProjectBuilder : IProjectBuilder, ITransientDependency
args.Version,
args.TemplateSource,
args.ExtraProperties.ContainsKey(NewCommand.Options.Preview.Long),
+ skipCache: args.SkipCache,
trustUserVersion: args.TrustUserVersion
);
diff --git a/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs b/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs
index 107008deea..0b7a21c71e 100644
--- a/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs
+++ b/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs
@@ -108,8 +108,7 @@ public class ClientProxyBase : ITransientDependency
{
var responseContent = await RequestAsync(requestContext);
- if (typeof(T) == typeof(IRemoteStreamContent) ||
- typeof(T) == typeof(RemoteStreamContent))
+ if (typeof(T) == typeof(IRemoteStreamContent) || typeof(T) == typeof(RemoteStreamContent))
{
/* returning a class that holds a reference to response
* content just to be sure that GC does not dispose of
@@ -127,7 +126,8 @@ public class ClientProxyBase : ITransientDependency
var stringContent = await responseContent.ReadAsStringAsync();
if (typeof(T) == typeof(string))
{
- return (T)(object)stringContent;
+ var unwrapped = UnwrapStringResponse(stringContent, responseContent.Headers?.ContentType?.MediaType);
+ return (T)(object)unwrapped!;
}
if (stringContent.IsNullOrWhiteSpace())
@@ -139,6 +139,39 @@ public class ClientProxyBase : ITransientDependency
}
}
+ protected virtual string? UnwrapStringResponse(string body, string? contentType)
+ {
+ if (body.IsNullOrEmpty() || contentType.IsNullOrWhiteSpace())
+ {
+ return body;
+ }
+
+ if (!IsJsonMediaType(NormalizeMediaType(contentType!)))
+ {
+ return body;
+ }
+
+ try
+ {
+ var parsed = JsonSerializer.Deserialize(body);
+ return parsed ?? string.Empty;
+ }
+ catch
+ {
+ return body;
+ }
+ }
+
+ protected static string NormalizeMediaType(string mediaType)
+ {
+ if (mediaType.IsNullOrWhiteSpace())
+ {
+ return string.Empty;
+ }
+ var semi = mediaType.IndexOf(';');
+ return (semi < 0 ? mediaType : mediaType.Substring(0, semi)).Trim().ToLowerInvariant();
+ }
+
protected virtual async Task RequestAsync(ClientProxyRequestContext requestContext)
{
var clientConfig = ClientOptions.Value.HttpClientProxies.GetOrDefault(requestContext.ServiceType) ?? throw new AbpException($"Could not get HttpClientProxyConfig for {requestContext.ServiceType.FullName}.");
@@ -326,14 +359,7 @@ public class ClientProxyBase : ITransientDependency
HttpRequestMessage requestMessage,
ApiVersionInfo apiVersion)
{
- //API Version
- if (!apiVersion.Version.IsNullOrEmpty())
- {
- //TODO: What about other media types?
- requestMessage.Headers.Add("accept", $"{MimeTypes.Text.Plain}; v={apiVersion.Version}");
- requestMessage.Headers.Add("accept", $"{MimeTypes.Application.Json}; v={apiVersion.Version}");
- requestMessage.Headers.Add("api-version", apiVersion.Version);
- }
+ AddAcceptHeaders(action, requestMessage, apiVersion);
//Header parameters
var headers = action.Parameters.Where(p => p.BindingSourceId == ParameterBindingSources.Header).ToArray();
@@ -378,6 +404,57 @@ public class ClientProxyBase : ITransientDependency
}
}
+ protected virtual void AddAcceptHeaders(
+ ActionApiDescriptionModel action,
+ HttpRequestMessage requestMessage,
+ ApiVersionInfo apiVersion)
+ {
+ var acceptForReturn = GetAcceptForActionReturn(action);
+ var versionSuffix = apiVersion.Version.IsNullOrEmpty() ? string.Empty : $"; v={apiVersion.Version}";
+
+ if (!acceptForReturn.IsNullOrEmpty())
+ {
+ requestMessage.Headers.Add("accept", acceptForReturn + versionSuffix);
+ }
+ else
+ {
+ requestMessage.Headers.Add("accept", MimeTypes.Text.Plain + versionSuffix);
+ requestMessage.Headers.Add("accept", MimeTypes.Application.Json + versionSuffix);
+ }
+
+ if (!apiVersion.Version.IsNullOrEmpty())
+ {
+ requestMessage.Headers.Add("api-version", apiVersion.Version);
+ }
+ }
+
+ protected virtual string? GetAcceptForActionReturn(ActionApiDescriptionModel action)
+ {
+ if (action.ReturnValue.IsRemoteStream ||
+ action.ReturnValue.Type == typeof(IRemoteStreamContent).FullName ||
+ action.ReturnValue.Type == typeof(RemoteStreamContent).FullName)
+ {
+ return MimeTypes.Application.OctetStream;
+ }
+
+ var contentTypes = action.ReturnValue.ContentTypes;
+ if (contentTypes == null || contentTypes.Count == 0)
+ {
+ return null;
+ }
+
+ var normalized = contentTypes.Select(NormalizeMediaType).ToList();
+
+ return normalized.FirstOrDefault(IsJsonMediaType) ?? normalized[0];
+ }
+
+ private static bool IsJsonMediaType(string normalizedMediaType)
+ {
+ return normalizedMediaType.Equals(MimeTypes.Application.Json, StringComparison.OrdinalIgnoreCase) ||
+ normalizedMediaType.Equals("text/json", StringComparison.OrdinalIgnoreCase) ||
+ normalizedMediaType.EndsWith("+json", StringComparison.OrdinalIgnoreCase);
+ }
+
protected virtual StringSegment RemoveQuotes(StringSegment input)
{
if (!StringSegment.IsNullOrEmpty(input) && input.Length >= 2 && input[0] == '"' && input[input.Length - 1] == '"')
diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs
index 7650e40f88..e01b39c94d 100644
--- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs
+++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs
@@ -45,7 +45,7 @@ public class ActionApiDescriptionModel
}
- public static ActionApiDescriptionModel Create([NotNull] string uniqueName, [NotNull] MethodInfo method, [NotNull] string url, string? httpMethod, [NotNull] IList supportedVersions, bool? allowAnonymous = null, IList? authorizeDatas = null, string? implementFrom = null)
+ public static ActionApiDescriptionModel Create([NotNull] string uniqueName, [NotNull] MethodInfo method, [NotNull] string url, string? httpMethod, [NotNull] IList supportedVersions, bool? allowAnonymous = null, IList? authorizeDatas = null, string? implementFrom = null, IList? returnValueContentTypes = null)
{
Check.NotNull(uniqueName, nameof(uniqueName));
Check.NotNull(method, nameof(method));
@@ -58,7 +58,7 @@ public class ActionApiDescriptionModel
Name = method.Name,
Url = url,
HttpMethod = httpMethod,
- ReturnValue = ReturnValueApiDescriptionModel.Create(method.ReturnType),
+ ReturnValue = ReturnValueApiDescriptionModel.Create(method.ReturnType, returnValueContentTypes),
Parameters = new List(),
ParametersOnMethod = method
.GetParameters()
diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs
index e77d2f7fea..25b2fb149a 100644
--- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs
+++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs
@@ -1,4 +1,6 @@
using System;
+using System.Collections.Generic;
+using Volo.Abp.Content;
using Volo.Abp.Reflection;
using Volo.Abp.Threading;
@@ -13,19 +15,30 @@ public class ReturnValueApiDescriptionModel
public string? Summary { get; set; }
+ public IList? ContentTypes { get; set; }
+
+ public bool IsRemoteStream { get; set; }
+
public ReturnValueApiDescriptionModel()
{
}
- public static ReturnValueApiDescriptionModel Create(Type type)
+ public static ReturnValueApiDescriptionModel Create(Type type, IList? contentTypes = null)
{
var unwrappedType = AsyncHelper.UnwrapTask(type);
return new ReturnValueApiDescriptionModel
{
Type = TypeHelper.GetFullNameHandlingNullableAndGenerics(unwrappedType),
- TypeSimple = ApiTypeNameHelper.GetSimpleTypeName(unwrappedType)
+ TypeSimple = ApiTypeNameHelper.GetSimpleTypeName(unwrappedType),
+ ContentTypes = contentTypes,
+ IsRemoteStream = IsRemoteStreamType(unwrappedType)
};
}
+
+ private static bool IsRemoteStreamType(Type type)
+ {
+ return type == typeof(IRemoteStreamContent) || type == typeof(RemoteStreamContent);
+ }
}
diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs
index 18af3e0661..8e15d85191 100644
--- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs
+++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs
@@ -135,10 +135,9 @@ public class JQueryProxyScriptGenerator : IProxyScriptGenerator, ITransientDepen
AddAjaxCallParameters(script, action);
- var ajaxParamsIsFromForm = action.Parameters.Any(x => x.BindingSourceId == ParameterBindingSources.Form);
- var dataType = action.ReturnValue.Type == ReturnValueApiDescriptionModel.Create(typeof(string)).Type
- ? "{ dataType: 'text' }, "
- : string.Empty;
+ var hasFormFile = action.Parameters.Any(x => x.BindingSourceId == ParameterBindingSources.FormFile);
+ var ajaxParamsIsFromForm = !hasFormFile && action.Parameters.Any(x => x.BindingSourceId == ParameterBindingSources.Form);
+ var dataType = GetJQueryDataTypeAndAcceptOverride(action);
script.AppendLine(ajaxParamsIsFromForm
? " }, $.extend(true, {}, " + dataType + "{ contentType: 'application/x-www-form-urlencoded; charset=UTF-8' }, ajaxParams)));"
: " }, " + dataType + "ajaxParams));");
@@ -146,6 +145,54 @@ public class JQueryProxyScriptGenerator : IProxyScriptGenerator, ITransientDepen
script.AppendLine(" };");
}
+ private static string GetJQueryDataTypeAndAcceptOverride(ActionApiDescriptionModel action)
+ {
+ if (action.ReturnValue.IsRemoteStream)
+ {
+ return string.Empty;
+ }
+
+ var contentTypes = action.ReturnValue.ContentTypes;
+ var isStringReturn = action.ReturnValue.Type == ReturnValueApiDescriptionModel.Create(typeof(string)).Type;
+
+ if (contentTypes is { Count: > 0 })
+ {
+ var normalized = contentTypes.Select(NormalizeMediaType).ToList();
+
+ var firstJsonShaped = normalized.FirstOrDefault(IsJsonMediaType);
+ if (firstJsonShaped != null)
+ {
+ return "{ dataType: 'json', headers: { Accept: '" + firstJsonShaped + "' } }, ";
+ }
+
+ if (normalized.All(ct => ct.StartsWith("text/", StringComparison.OrdinalIgnoreCase)))
+ {
+ return "{ dataType: 'text', headers: { Accept: '" + normalized[0] + "' } }, ";
+ }
+
+ return "{ headers: { Accept: '" + normalized[0] + "' } }, ";
+ }
+
+ return isStringReturn ? "{ dataType: 'text' }, " : string.Empty;
+ }
+
+ private static bool IsJsonMediaType(string normalizedMediaType)
+ {
+ return normalizedMediaType.Equals("application/json", StringComparison.OrdinalIgnoreCase) ||
+ normalizedMediaType.Equals("text/json", StringComparison.OrdinalIgnoreCase) ||
+ normalizedMediaType.EndsWith("+json", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string NormalizeMediaType(string mediaType)
+ {
+ if (string.IsNullOrWhiteSpace(mediaType))
+ {
+ return string.Empty;
+ }
+ var semi = mediaType.IndexOf(';');
+ return (semi < 0 ? mediaType : mediaType.Substring(0, semi)).Trim().ToLowerInvariant();
+ }
+
private static string FindBestApiVersion(ActionApiDescriptionModel action)
{
//var configuredVersion = GetConfiguredApiVersion(); //TODO: Implement
@@ -184,6 +231,20 @@ public class JQueryProxyScriptGenerator : IProxyScriptGenerator, ITransientDepen
script.Append(" headers: " + headers);
}
+ var firstFileParam = action.Parameters.FirstOrDefault(p => p.BindingSourceId == ParameterBindingSources.FormFile);
+ if (firstFileParam != null)
+ {
+ var fileVar = ProxyScriptingJsFuncHelper.NormalizeJsVariableName(firstFileParam.NameOnMethod.ToCamelCase());
+ script.AppendLine(",");
+ script.Append(" data: " + fileVar + ",");
+ script.AppendLine();
+ script.Append(" processData: false,");
+ script.AppendLine();
+ script.Append(" contentType: false");
+ script.AppendLine();
+ return;
+ }
+
var body = ProxyScriptingHelper.GenerateBody(action);
if (!body.IsNullOrEmpty())
{
diff --git a/framework/src/Volo.Abp.Mapperly/Volo/Abp/Mapperly/IAbpMapperlyMapper.cs b/framework/src/Volo.Abp.Mapperly/Volo/Abp/Mapperly/IAbpMapperlyMapper.cs
index eac0f6644c..80efb49f4c 100644
--- a/framework/src/Volo.Abp.Mapperly/Volo/Abp/Mapperly/IAbpMapperlyMapper.cs
+++ b/framework/src/Volo.Abp.Mapperly/Volo/Abp/Mapperly/IAbpMapperlyMapper.cs
@@ -13,11 +13,11 @@ public interface IAbpMapperlyMapper
public interface IAbpReverseMapperlyMapper : IAbpMapperlyMapper
{
- TSource ReverseMap(TDestination destination);
+ TSource ReverseMap(TDestination source);
- void ReverseMap(TDestination destination, TSource source);
+ void ReverseMap(TDestination source, TSource destination);
- void BeforeReverseMap(TDestination destination);
+ void BeforeReverseMap(TDestination source);
- void AfterReverseMap(TDestination destination, TSource source);
+ void AfterReverseMap(TDestination source, TSource destination);
}
diff --git a/framework/src/Volo.Abp.Mapperly/Volo/Abp/Mapperly/MapperBase.cs b/framework/src/Volo.Abp.Mapperly/Volo/Abp/Mapperly/MapperBase.cs
index 39d9dce995..f49b2f14c9 100644
--- a/framework/src/Volo.Abp.Mapperly/Volo/Abp/Mapperly/MapperBase.cs
+++ b/framework/src/Volo.Abp.Mapperly/Volo/Abp/Mapperly/MapperBase.cs
@@ -18,15 +18,15 @@ public abstract class MapperBase : IAbpMapperlyMapper : MapperBase, IAbpReverseMapperlyMapper
{
- public abstract TSource ReverseMap(TDestination destination);
+ public abstract TSource ReverseMap(TDestination source);
- public abstract void ReverseMap(TDestination destination, TSource source);
+ public abstract void ReverseMap(TDestination source, TSource destination);
- public virtual void BeforeReverseMap(TDestination destination)
+ public virtual void BeforeReverseMap(TDestination source)
{
}
- public virtual void AfterReverseMap(TDestination destination, TSource source)
+ public virtual void AfterReverseMap(TDestination source, TSource destination)
{
}
}
diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiForgeryClaimsPrincipalNormalizer_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiForgeryClaimsPrincipalNormalizer_Tests.cs
new file mode 100644
index 0000000000..7a2393586c
--- /dev/null
+++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiForgeryClaimsPrincipalNormalizer_Tests.cs
@@ -0,0 +1,263 @@
+using System;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Antiforgery;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+using Volo.Abp.Security.Claims;
+using Xunit;
+
+namespace Volo.Abp.AspNetCore.Mvc.AntiForgery;
+
+public class AbpAntiForgeryClaimsPrincipalNormalizer_Tests
+{
+ private const string CookieIssuer = "LOCAL AUTHORITY";
+ private const string BearerIssuer = "https://localhost:44361/";
+ private const string UserId = "3a0e6f1c-1111-2222-3333-444455556666";
+ private const string AntiForgeryHeaderName = "RequestVerificationToken";
+ private const string AntiForgeryCookieName = "AF";
+
+ [Fact]
+ public void Normalize_should_set_a_constant_issuer_on_user_identifier_claims_only()
+ {
+ var usernameClaim = new Claim("preferred_username", "admin", ClaimValueTypes.String, CookieIssuer);
+ usernameClaim.Properties["test-property"] = "test-value";
+
+ var principal = new ClaimsPrincipal(new ClaimsIdentity(
+ new[]
+ {
+ new Claim("sub", UserId, ClaimValueTypes.String, CookieIssuer),
+ new Claim(ClaimTypes.NameIdentifier, UserId, ClaimValueTypes.String, CookieIssuer),
+ usernameClaim
+ },
+ "Identity.Application"));
+
+ var normalized = new AbpAntiForgeryClaimsPrincipalNormalizer().Normalize(principal);
+
+ normalized.FindFirst("sub")!.Issuer.ShouldBe(AbpAntiForgeryClaimsPrincipalNormalizer.UserIdClaimIssuer);
+ normalized.FindFirst(ClaimTypes.NameIdentifier)!.Issuer.ShouldBe(AbpAntiForgeryClaimsPrincipalNormalizer.UserIdClaimIssuer);
+
+ // value and OriginalIssuer are kept; only Issuer changes
+ normalized.FindFirst("sub")!.Value.ShouldBe(UserId);
+ normalized.FindFirst("sub")!.OriginalIssuer.ShouldBe(CookieIssuer);
+
+ // non-identifier claims and their properties are untouched
+ var normalizedUsername = normalized.FindFirst("preferred_username")!;
+ normalizedUsername.Issuer.ShouldBe(CookieIssuer);
+ normalizedUsername.Properties["test-property"].ShouldBe("test-value");
+
+ // the original principal is not mutated
+ principal.FindFirst("sub")!.Issuer.ShouldBe(CookieIssuer);
+ }
+
+ [Fact]
+ public void Normalize_should_preserve_identity_metadata()
+ {
+ var actor = new ClaimsIdentity(new[] { new Claim(AbpClaimTypes.UserId, "actor-id") }, "Actor");
+ var principal = new ClaimsPrincipal(new ClaimsIdentity(
+ new[] { new Claim("sub", UserId, ClaimValueTypes.String, BearerIssuer) },
+ "Identity.Application")
+ {
+ Actor = actor,
+ BootstrapContext = "raw-token",
+ Label = "my-label"
+ });
+
+ var normalized = new AbpAntiForgeryClaimsPrincipalNormalizer().Normalize(principal);
+ var normalizedIdentity = (ClaimsIdentity)normalized.Identity!;
+
+ // identity metadata that the antiforgery claim uid does not use is still preserved on the copy
+ normalizedIdentity.Actor.ShouldBeSameAs(actor);
+ normalizedIdentity.BootstrapContext.ShouldBe("raw-token");
+ normalizedIdentity.Label.ShouldBe("my-label");
+ // and the user id claim issuer was still normalized
+ normalized.FindFirst("sub")!.Issuer.ShouldBe(AbpAntiForgeryClaimsPrincipalNormalizer.UserIdClaimIssuer);
+ }
+
+ [Fact]
+ public async Task Token_should_validate_for_the_same_cookie_principal_through_the_decorator()
+ {
+ // The common server-rendered case: a token generated and validated for the same cookie principal.
+ // Guards that wrapping IAntiforgery does not break the basic flow every page POST relies on.
+ var (antiforgery, serviceProvider) = CreateDecoratedAntiforgery(normalize: true);
+
+ var cookiePrincipal = CreatePrincipal("Identity.Application", CookieIssuer);
+ var (cookieToken, requestToken) = GenerateToken(antiforgery, serviceProvider, cookiePrincipal);
+
+ var isValid = await ValidateAsync(antiforgery, serviceProvider, cookiePrincipal, cookieToken, requestToken);
+
+ isValid.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task Token_issued_under_one_scheme_should_validate_under_another_when_normalization_is_enabled()
+ {
+ var (antiforgery, serviceProvider) = CreateDecoratedAntiforgery(normalize: true);
+
+ var cookiePrincipal = CreatePrincipal("Identity.Application", CookieIssuer);
+ var (cookieToken, requestToken) = GenerateToken(antiforgery, serviceProvider, cookiePrincipal);
+
+ var bearerPrincipal = CreatePrincipal("AuthenticationTypes.Federation", BearerIssuer);
+ var isValid = await ValidateAsync(antiforgery, serviceProvider, bearerPrincipal, cookieToken, requestToken);
+
+ isValid.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task Token_issued_under_one_scheme_should_fail_under_another_when_normalization_is_disabled()
+ {
+ var (antiforgery, serviceProvider) = CreateDecoratedAntiforgery(normalize: false);
+
+ var cookiePrincipal = CreatePrincipal("Identity.Application", CookieIssuer);
+ var (cookieToken, requestToken) = GenerateToken(antiforgery, serviceProvider, cookiePrincipal);
+
+ var bearerPrincipal = CreatePrincipal("AuthenticationTypes.Federation", BearerIssuer);
+ var isValid = await ValidateAsync(antiforgery, serviceProvider, bearerPrincipal, cookieToken, requestToken);
+
+ isValid.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task Token_should_validate_when_the_cookie_principal_user_id_issuer_is_not_local_authority()
+ {
+ // Tiered/OIDC templates back the cookie with an OIDC principal whose user id issuer is the token
+ // authority. Because the decorator normalizes both generation and validation (Razor Pages validate
+ // through the same decorated IAntiforgery), the per-user identifier still matches.
+ var (antiforgery, serviceProvider) = CreateDecoratedAntiforgery(normalize: true);
+
+ var oidcCookiePrincipal = CreatePrincipal("Identity.Application", BearerIssuer);
+ var (cookieToken, requestToken) = GenerateToken(antiforgery, serviceProvider, oidcCookiePrincipal);
+
+ var isValid = await ValidateAsync(antiforgery, serviceProvider, oidcCookiePrincipal, cookieToken, requestToken);
+
+ isValid.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task Token_should_validate_across_schemes_when_principal_has_both_sub_and_name_identifier()
+ {
+ // The extractor picks "sub" before NameIdentifier and a principal can carry both, so the
+ // normalization must cover the claim actually picked.
+ var (antiforgery, serviceProvider) = CreateDecoratedAntiforgery(normalize: true);
+
+ var cookiePrincipal = CreatePrincipalWithSubAndNameIdentifier("Identity.Application", CookieIssuer);
+ var (cookieToken, requestToken) = GenerateToken(antiforgery, serviceProvider, cookiePrincipal);
+
+ var bearerPrincipal = CreatePrincipalWithSubAndNameIdentifier("AuthenticationTypes.Federation", BearerIssuer);
+ var isValid = await ValidateAsync(antiforgery, serviceProvider, bearerPrincipal, cookieToken, requestToken);
+
+ isValid.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task Decorator_should_restore_the_original_principal_after_each_call()
+ {
+ var (antiforgery, serviceProvider) = CreateDecoratedAntiforgery(normalize: true);
+
+ var originalPrincipal = CreatePrincipal("AuthenticationTypes.Federation", BearerIssuer);
+ var httpContext = new DefaultHttpContext { User = originalPrincipal, RequestServices = serviceProvider };
+
+ antiforgery.GetAndStoreTokens(httpContext);
+
+ httpContext.User.ShouldBeSameAs(originalPrincipal);
+ httpContext.User.FindFirst(AbpClaimTypes.UserId)!.Issuer.ShouldBe(BearerIssuer);
+
+ httpContext.Request.Headers["Cookie"] = $"{AntiForgeryCookieName}=invalid";
+ await antiforgery.IsRequestValidAsync(httpContext);
+
+ httpContext.User.ShouldBeSameAs(originalPrincipal);
+ httpContext.User.FindFirst(AbpClaimTypes.UserId)!.Issuer.ShouldBe(BearerIssuer);
+ }
+
+ [Fact]
+ public async Task Decorator_should_not_normalize_when_disabled()
+ {
+ var (antiforgery, serviceProvider) = CreateDecoratedAntiforgery(normalize: false);
+
+ var principal = CreatePrincipal("Identity.Application", CookieIssuer);
+ var httpContext = new DefaultHttpContext { User = principal, RequestServices = serviceProvider };
+
+ antiforgery.GetAndStoreTokens(httpContext);
+ httpContext.User.ShouldBeSameAs(principal);
+
+ httpContext.Request.Headers["Cookie"] = $"{AntiForgeryCookieName}=invalid";
+ await antiforgery.IsRequestValidAsync(httpContext);
+ httpContext.User.ShouldBeSameAs(principal);
+ }
+
+ private static ClaimsPrincipal CreatePrincipal(string authenticationType, string userIdClaimIssuer)
+ {
+ return new ClaimsPrincipal(new ClaimsIdentity(
+ new[]
+ {
+ new Claim(AbpClaimTypes.UserId, UserId, ClaimValueTypes.String, userIdClaimIssuer),
+ new Claim("preferred_username", "admin", ClaimValueTypes.String, userIdClaimIssuer)
+ },
+ authenticationType,
+ "preferred_username",
+ AbpClaimTypes.Role));
+ }
+
+ private static ClaimsPrincipal CreatePrincipalWithSubAndNameIdentifier(string authenticationType, string issuer)
+ {
+ return new ClaimsPrincipal(new ClaimsIdentity(
+ new[]
+ {
+ new Claim("sub", UserId, ClaimValueTypes.String, issuer),
+ new Claim(ClaimTypes.NameIdentifier, UserId, ClaimValueTypes.String, issuer),
+ new Claim("preferred_username", "admin", ClaimValueTypes.String, issuer)
+ },
+ authenticationType,
+ "preferred_username",
+ AbpClaimTypes.Role));
+ }
+
+ private static (IAntiforgery antiforgery, IServiceProvider serviceProvider) CreateDecoratedAntiforgery(bool normalize)
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddDataProtection();
+ services.AddAntiforgery(options =>
+ {
+ options.Cookie.Name = AntiForgeryCookieName;
+ options.HeaderName = AntiForgeryHeaderName;
+ });
+ services.AddTransient();
+
+ var serviceProvider = services.BuildServiceProvider();
+
+ var antiforgery = new AbpAntiforgery(
+ serviceProvider.GetRequiredService(),
+ Microsoft.Extensions.Options.Options.Create(new AbpAntiForgeryOptions { NormalizeUserIdClaimIssuer = normalize }));
+
+ return (antiforgery, serviceProvider);
+ }
+
+ private static (string cookieToken, string requestToken) GenerateToken(
+ IAntiforgery antiforgery, IServiceProvider serviceProvider, ClaimsPrincipal user)
+ {
+ var httpContext = new DefaultHttpContext { User = user, RequestServices = serviceProvider };
+ var tokenSet = antiforgery.GetAndStoreTokens(httpContext);
+ return (ExtractCookieToken(httpContext), tokenSet.RequestToken!);
+ }
+
+ private static async Task ValidateAsync(
+ IAntiforgery antiforgery, IServiceProvider serviceProvider, ClaimsPrincipal user, string cookieToken, string requestToken)
+ {
+ var httpContext = new DefaultHttpContext { User = user, RequestServices = serviceProvider };
+ httpContext.Request.Headers["Cookie"] = $"{AntiForgeryCookieName}={cookieToken}";
+ httpContext.Request.Headers[AntiForgeryHeaderName] = requestToken;
+
+ return await antiforgery.IsRequestValidAsync(httpContext);
+ }
+
+ private static string ExtractCookieToken(HttpContext httpContext)
+ {
+ var setCookie = httpContext.Response.Headers.SetCookie.ToString();
+ var prefix = AntiForgeryCookieName + "=";
+ var start = setCookie.IndexOf(prefix, StringComparison.Ordinal) + prefix.Length;
+ var end = setCookie.IndexOf(';', start);
+ return end < 0 ? setCookie.Substring(start) : setCookie.Substring(start, end - start);
+ }
+}
diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiforgery_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiforgery_Tests.cs
new file mode 100644
index 0000000000..9ac20ed4d6
--- /dev/null
+++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AntiForgery/AbpAntiforgery_Tests.cs
@@ -0,0 +1,182 @@
+using System;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Antiforgery;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+using Volo.Abp.Security.Claims;
+using Xunit;
+
+namespace Volo.Abp.AspNetCore.Mvc.AntiForgery;
+
+public class AbpAntiforgery_Tests
+{
+ private const string BearerIssuer = "https://localhost:44361/";
+ private const string UserId = "3a0e6f1c-1111-2222-3333-444455556666";
+
+ [Fact]
+ public Task GetAndStoreTokens_should_normalize_the_user_and_restore_it() =>
+ Should_normalize_then_restore(
+ (antiforgery, httpContext) => { antiforgery.GetAndStoreTokens(httpContext); return Task.CompletedTask; },
+ inner => inner.UserSeenByGetAndStoreTokens);
+
+ [Fact]
+ public Task GetTokens_should_normalize_the_user_and_restore_it() =>
+ Should_normalize_then_restore(
+ (antiforgery, httpContext) => { antiforgery.GetTokens(httpContext); return Task.CompletedTask; },
+ inner => inner.UserSeenByGetTokens);
+
+ [Fact]
+ public Task IsRequestValidAsync_should_normalize_the_user_and_restore_it() =>
+ Should_normalize_then_restore(
+ (antiforgery, httpContext) => antiforgery.IsRequestValidAsync(httpContext),
+ inner => inner.UserSeenByIsRequestValid);
+
+ [Fact]
+ public Task ValidateRequestAsync_should_normalize_the_user_and_restore_it() =>
+ Should_normalize_then_restore(
+ (antiforgery, httpContext) => antiforgery.ValidateRequestAsync(httpContext),
+ inner => inner.UserSeenByValidateRequest);
+
+ [Fact]
+ public Task SetCookieTokenAndHeader_should_normalize_the_user_and_restore_it() =>
+ Should_normalize_then_restore(
+ (antiforgery, httpContext) => { antiforgery.SetCookieTokenAndHeader(httpContext); return Task.CompletedTask; },
+ inner => inner.UserSeenBySetCookieTokenAndHeader);
+
+ [Fact]
+ public void Should_delegate_the_result_to_the_inner_antiforgery()
+ {
+ var inner = new RecordingAntiforgery();
+ var antiforgery = new AbpAntiforgery(inner, CreateOptions(normalize: true));
+ var httpContext = CreateHttpContext(CreatePrincipal(BearerIssuer), withNormalizer: true);
+
+ var tokenSet = antiforgery.GetAndStoreTokens(httpContext);
+
+ tokenSet.RequestToken.ShouldBe(RecordingAntiforgery.RequestToken);
+ tokenSet.CookieToken.ShouldBe(RecordingAntiforgery.CookieToken);
+ }
+
+ [Fact]
+ public void Should_not_normalize_when_disabled()
+ {
+ var inner = new RecordingAntiforgery();
+ var antiforgery = new AbpAntiforgery(inner, CreateOptions(normalize: false));
+ var original = CreatePrincipal(BearerIssuer);
+ var httpContext = CreateHttpContext(original, withNormalizer: true);
+
+ antiforgery.GetAndStoreTokens(httpContext);
+
+ // the inner saw the original (un-normalized) principal
+ inner.UserSeenByGetAndStoreTokens.ShouldBeSameAs(original);
+ inner.UserSeenByGetAndStoreTokens!.FindFirst(AbpClaimTypes.UserId)!.Issuer.ShouldBe(BearerIssuer);
+ httpContext.User.ShouldBeSameAs(original);
+ }
+
+ [Fact]
+ public void Should_not_resolve_the_normalizer_service_when_disabled()
+ {
+ // the normalizer is intentionally not registered; the disabled fast-path must not touch RequestServices
+ var inner = new RecordingAntiforgery();
+ var antiforgery = new AbpAntiforgery(inner, CreateOptions(normalize: false));
+ var httpContext = CreateHttpContext(CreatePrincipal(BearerIssuer), withNormalizer: false);
+
+ Should.NotThrow(() => antiforgery.GetAndStoreTokens(httpContext));
+ }
+
+ private static async Task Should_normalize_then_restore(
+ Func invoke,
+ Func userSeenByInner)
+ {
+ var inner = new RecordingAntiforgery();
+ var antiforgery = new AbpAntiforgery(inner, CreateOptions(normalize: true));
+ var original = CreatePrincipal(BearerIssuer);
+ var httpContext = CreateHttpContext(original, withNormalizer: true);
+
+ await invoke(antiforgery, httpContext);
+
+ // the inner ran against the normalized principal
+ userSeenByInner(inner)!.FindFirst(AbpClaimTypes.UserId)!.Issuer
+ .ShouldBe(AbpAntiForgeryClaimsPrincipalNormalizer.UserIdClaimIssuer);
+ // the original principal is restored after the call
+ httpContext.User.ShouldBeSameAs(original);
+ }
+
+ private static Microsoft.Extensions.Options.IOptions CreateOptions(bool normalize)
+ {
+ return Microsoft.Extensions.Options.Options.Create(
+ new AbpAntiForgeryOptions { NormalizeUserIdClaimIssuer = normalize });
+ }
+
+ private static HttpContext CreateHttpContext(ClaimsPrincipal user, bool withNormalizer)
+ {
+ var services = new ServiceCollection();
+ if (withNormalizer)
+ {
+ services.AddTransient();
+ }
+
+ return new DefaultHttpContext
+ {
+ User = user,
+ RequestServices = services.BuildServiceProvider()
+ };
+ }
+
+ private static ClaimsPrincipal CreatePrincipal(string userIdClaimIssuer)
+ {
+ return new ClaimsPrincipal(new ClaimsIdentity(
+ new[]
+ {
+ new Claim(AbpClaimTypes.UserId, UserId, ClaimValueTypes.String, userIdClaimIssuer)
+ },
+ "AuthenticationTypes.Federation"));
+ }
+
+ private sealed class RecordingAntiforgery : IAntiforgery
+ {
+ public const string RequestToken = "test-request-token";
+ public const string CookieToken = "test-cookie-token";
+
+ public ClaimsPrincipal? UserSeenByGetAndStoreTokens { get; private set; }
+ public ClaimsPrincipal? UserSeenByGetTokens { get; private set; }
+ public ClaimsPrincipal? UserSeenByIsRequestValid { get; private set; }
+ public ClaimsPrincipal? UserSeenByValidateRequest { get; private set; }
+ public ClaimsPrincipal? UserSeenBySetCookieTokenAndHeader { get; private set; }
+
+ public AntiforgeryTokenSet GetAndStoreTokens(HttpContext httpContext)
+ {
+ UserSeenByGetAndStoreTokens = httpContext.User;
+ return CreateTokenSet();
+ }
+
+ public AntiforgeryTokenSet GetTokens(HttpContext httpContext)
+ {
+ UserSeenByGetTokens = httpContext.User;
+ return CreateTokenSet();
+ }
+
+ public Task IsRequestValidAsync(HttpContext httpContext)
+ {
+ UserSeenByIsRequestValid = httpContext.User;
+ return Task.FromResult(true);
+ }
+
+ public Task ValidateRequestAsync(HttpContext httpContext)
+ {
+ UserSeenByValidateRequest = httpContext.User;
+ return Task.CompletedTask;
+ }
+
+ public void SetCookieTokenAndHeader(HttpContext httpContext)
+ {
+ UserSeenBySetCookieTokenAndHeader = httpContext.User;
+ }
+
+ private static AntiforgeryTokenSet CreateTokenSet()
+ {
+ return new AntiforgeryTokenSet(RequestToken, CookieToken, "RequestVerificationToken", "RequestVerificationToken");
+ }
+ }
+}
diff --git a/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/HubConfigList_Tests.cs b/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/HubConfigList_Tests.cs
new file mode 100644
index 0000000000..0265388ab2
--- /dev/null
+++ b/framework/test/Volo.Abp.AspNetCore.SignalR.Tests/Volo/Abp/AspNetCore/SignalR/HubConfigList_Tests.cs
@@ -0,0 +1,32 @@
+using System.Linq;
+using Shouldly;
+using Volo.Abp.AspNetCore.SignalR.SampleHubs;
+using Xunit;
+
+namespace Volo.Abp.AspNetCore.SignalR;
+
+public class HubConfigList_Tests
+{
+ [Fact]
+ public void Should_Apply_ConfigAction_For_Generic_Overload()
+ {
+ var list = new HubConfigList();
+
+ list.AddOrUpdate(config => config.RoutePattern = "/custom-route");
+
+ var hubConfig = list.Single(c => c.HubType == typeof(RegularHub));
+ hubConfig.RoutePattern.ShouldBe("/custom-route");
+ }
+
+ [Fact]
+ public void Should_Update_Existing_Hub_For_Generic_Overload()
+ {
+ var list = new HubConfigList();
+
+ list.AddOrUpdate();
+ list.AddOrUpdate(config => config.RoutePattern = "/custom-route");
+
+ list.Count.ShouldBe(1);
+ list.Single(c => c.HubType == typeof(RegularHub)).RoutePattern.ShouldBe("/custom-route");
+ }
+}
diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase_ContentTypes_Tests.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase_ContentTypes_Tests.cs
new file mode 100644
index 0000000000..c30fbb0d43
--- /dev/null
+++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase_ContentTypes_Tests.cs
@@ -0,0 +1,275 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using Shouldly;
+using Volo.Abp.Content;
+using Volo.Abp.Http.Modeling;
+using Xunit;
+
+namespace Volo.Abp.Http.Client.ClientProxying;
+
+public class ClientProxyBase_GetAcceptForActionReturn_Tests
+{
+ [Fact]
+ public void IRemoteStreamContent_Should_Pick_OctetStream_Even_When_ContentTypes_Include_Json()
+ {
+ var action = BuildAction(
+ returnType: typeof(IRemoteStreamContent).FullName!,
+ contentTypes: new[] { "application/json", "text/plain", "text/json" });
+
+ InvokeGetAcceptForActionReturn(action).ShouldBe("application/octet-stream");
+ }
+
+ [Fact]
+ public void RemoteStreamContent_Concrete_Type_Should_Pick_OctetStream()
+ {
+ var action = BuildAction(
+ returnType: typeof(RemoteStreamContent).FullName!,
+ contentTypes: null);
+
+ InvokeGetAcceptForActionReturn(action).ShouldBe("application/octet-stream");
+ }
+
+ [Fact]
+ public void Json_In_ContentTypes_Should_Pick_Json()
+ {
+ var action = BuildAction(
+ returnType: "System.String",
+ contentTypes: new[] { "text/plain", "application/json", "text/json" });
+
+ InvokeGetAcceptForActionReturn(action).ShouldBe("application/json");
+ }
+
+ [Fact]
+ public void Only_Text_ContentTypes_Should_Pick_TextPlain()
+ {
+ var action = BuildAction(
+ returnType: "System.String",
+ contentTypes: new[] { "text/plain", "text/csv" });
+
+ InvokeGetAcceptForActionReturn(action).ShouldBe("text/plain");
+ }
+
+ [Fact]
+ public void Empty_Or_Null_ContentTypes_Should_Return_Null()
+ {
+ InvokeGetAcceptForActionReturn(BuildAction("System.Int32", null)).ShouldBeNull();
+ InvokeGetAcceptForActionReturn(BuildAction("System.Int32", new string[0])).ShouldBeNull();
+ }
+
+ [Fact]
+ public void Mixed_Text_And_Octet_Stream_Should_Echo_First_Content_Type()
+ {
+ var action = BuildAction(
+ returnType: "System.String",
+ contentTypes: new[] { "text/plain", "application/octet-stream" });
+
+ InvokeGetAcceptForActionReturn(action).ShouldBe("text/plain");
+ }
+
+ [Fact]
+ public void JsonV2_Variant_Should_Still_Pick_Json()
+ {
+ var action = BuildAction(
+ returnType: "System.String",
+ contentTypes: new[] { "application/json; charset=utf-8" });
+
+ InvokeGetAcceptForActionReturn(action).ShouldBe("application/json");
+ }
+
+ [Fact]
+ public void Single_TextHtml_Should_Echo_Back_TextHtml()
+ {
+ var action = BuildAction(
+ returnType: "System.String",
+ contentTypes: new[] { "text/html" });
+
+ InvokeGetAcceptForActionReturn(action).ShouldBe("text/html");
+ }
+
+ [Fact]
+ public void OctetStream_Only_With_ObjectReturn_Should_Echo_OctetStream()
+ {
+ var action = BuildAction(
+ returnType: "My.Project.UserDto",
+ contentTypes: new[] { "application/octet-stream" });
+
+ InvokeGetAcceptForActionReturn(action).ShouldBe("application/octet-stream");
+ }
+
+ [Fact]
+ public void ApplicationXml_Only_Should_Echo_Back_Xml_Instead_Of_Legacy_Pair()
+ {
+ var action = BuildAction(
+ returnType: "My.Project.SoapEnvelope",
+ contentTypes: new[] { "application/xml" });
+
+ InvokeGetAcceptForActionReturn(action).ShouldBe("application/xml");
+ }
+
+ [Fact]
+ public void Case_Insensitive_Json_Match()
+ {
+ var action = BuildAction(
+ returnType: "System.String",
+ contentTypes: new[] { "APPLICATION/JSON" });
+
+ InvokeGetAcceptForActionReturn(action).ShouldBe("application/json");
+ }
+
+ [Fact]
+ public void Json_With_Charset_Parameter_Should_Still_Pick_Json()
+ {
+ var action = BuildAction(
+ returnType: "System.String",
+ contentTypes: new[] { "application/json; charset=utf-8" });
+
+ InvokeGetAcceptForActionReturn(action).ShouldBe("application/json");
+ }
+
+ [Fact]
+ public void Text_With_Charset_Parameter_Should_Still_Pick_TextPlain()
+ {
+ var action = BuildAction(
+ returnType: "System.String",
+ contentTypes: new[] { "text/plain ; charset=utf-8 " });
+
+ InvokeGetAcceptForActionReturn(action).ShouldBe("text/plain");
+ }
+
+ [Fact]
+ public void Text_Json_Should_Echo_Back_Text_Json()
+ {
+ var action = BuildAction(
+ returnType: "System.String",
+ contentTypes: new[] { "text/json" });
+
+ InvokeGetAcceptForActionReturn(action).ShouldBe("text/json");
+ }
+
+ [Fact]
+ public void Application_Problem_Json_Should_Echo_Back_The_Plus_Json_Variant()
+ {
+ var action = BuildAction(
+ returnType: "System.String",
+ contentTypes: new[] { "application/problem+json" });
+
+ InvokeGetAcceptForActionReturn(action).ShouldBe("application/problem+json");
+ }
+
+ [Fact]
+ public void Vendor_Plus_Json_Should_Echo_Back_The_Plus_Json_Variant()
+ {
+ var action = BuildAction(
+ returnType: "System.String",
+ contentTypes: new[] { "application/vnd.api+json" });
+
+ InvokeGetAcceptForActionReturn(action).ShouldBe("application/vnd.api+json");
+ }
+
+ [Fact]
+ public void IsRemoteStream_Flag_True_Should_Pick_OctetStream_Regardless_Of_TypeName()
+ {
+ var action = BuildAction(
+ returnType: "My.Project.CustomStream",
+ contentTypes: new[] { "application/json" });
+ action.ReturnValue.IsRemoteStream = true;
+
+ InvokeGetAcceptForActionReturn(action).ShouldBe("application/octet-stream");
+ }
+
+ private static string? InvokeGetAcceptForActionReturn(ActionApiDescriptionModel action)
+ {
+ var proxy = new TestableClientProxy();
+ return proxy.PublicGetAcceptForActionReturn(action);
+ }
+
+ private static ActionApiDescriptionModel BuildAction(string returnType, IList? contentTypes)
+ {
+ return new ActionApiDescriptionModel
+ {
+ UniqueName = "Sample",
+ Name = "Sample",
+ HttpMethod = "GET",
+ Url = "api/test",
+ SupportedVersions = new List(),
+ ParametersOnMethod = new List(),
+ Parameters = new List(),
+ ReturnValue = new ReturnValueApiDescriptionModel
+ {
+ Type = returnType,
+ TypeSimple = returnType,
+ ContentTypes = contentTypes
+ },
+ AuthorizeDatas = new List()
+ };
+ }
+
+ [Fact]
+ public void AddHeaders_With_ApiVersion_Should_Combine_OctetStream_Accept_With_Version_Suffix()
+ {
+ var action = BuildAction(
+ returnType: typeof(IRemoteStreamContent).FullName!,
+ contentTypes: new[] { "application/json", "text/plain" });
+
+ var headers = InvokeAddHeadersAndCollectAccept(action, version: "2.0");
+
+ headers.ShouldContain("application/octet-stream; v=2.0");
+ headers.ShouldNotContain(h => h == "text/plain; v=2.0");
+ headers.ShouldNotContain(h => h == "application/json; v=2.0");
+ }
+
+ [Fact]
+ public void AddHeaders_Without_ApiVersion_Should_Emit_OctetStream_For_Stream_Returns()
+ {
+ var action = BuildAction(
+ returnType: typeof(IRemoteStreamContent).FullName!,
+ contentTypes: new[] { "application/json" });
+
+ var headers = InvokeAddHeadersAndCollectAccept(action, version: null);
+
+ headers.ShouldContain("application/octet-stream");
+ }
+
+ [Fact]
+ public void AddHeaders_Without_ContentType_Metadata_Should_Fall_Back_To_Text_And_Json_Pair()
+ {
+ var action = BuildAction(returnType: "System.Int32", contentTypes: null);
+
+ var headers = InvokeAddHeadersAndCollectAccept(action, version: "1.0");
+
+ headers.ShouldContain("text/plain; v=1.0");
+ headers.ShouldContain("application/json; v=1.0");
+ }
+
+ [Fact]
+ public void AddHeaders_Without_ApiVersion_And_Without_ContentType_Metadata_Should_Emit_Unversioned_Text_Json_Pair()
+ {
+ var action = BuildAction(returnType: "System.Int32", contentTypes: null);
+
+ var headers = InvokeAddHeadersAndCollectAccept(action, version: null);
+
+ headers.ShouldContain("text/plain");
+ headers.ShouldContain("application/json");
+ headers.ShouldNotContain(h => h.Contains("; v="));
+ }
+
+ private static IList InvokeAddHeadersAndCollectAccept(ActionApiDescriptionModel action, string? version)
+ {
+ var proxy = new TestableClientProxy();
+ var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/x");
+ var apiVersion = new ApiVersionInfo("HeaderModelBinding", version ?? string.Empty);
+ proxy.PublicAddAcceptHeaders(action, message, apiVersion);
+ return message.Headers.Accept.Select(a => a.ToString()).ToList();
+ }
+
+ private sealed class TestableClientProxy : ClientProxyBase