test(user-tasks): add a persistence conformance suite with fault injection (#7986)

Runs one suite unchanged against every implementation of IUserTaskRepository,
IUserTaskGuestSessionIssuer, and IUserTaskInvitationOutbox, plus a fault-injection
suite driving the real DefaultUserTaskManager and DefaultUserTaskInvitationService
against a real store. Gated providers report as skipped with a reason rather than
passing vacuously; ConformanceCoverageTests fails when a provider that must run is
unreachable or its variable is set but empty.

The suite found three defects, fixed here:

- VNextUserTaskRepository supplied no index values for WorkflowDefinitionId,
  WorkflowInstanceId, ActivityInstanceId, CreatedAt, or CompletedAt, all declared by
  its own schema provider, so every write through the VNext provider threw.
- The same provider resolved invitation token hashes by scanning on Status alone,
  which matched no declared index, so anonymous invitation verification always threw.
- EFCoreUserTaskInvitationOutbox persisted the delivery recipient but never read it
  back, so durably queued invitations reached the dispatcher with no address.

Also switches new ADRs to date-prefixed identifiers and generates doc/adr/toc.md via
scripts/adr/generate-toc.sh, with a --check mode and pull-request workflow so the
index is never hand-edited again.
This commit is contained in:
Sipke Schoorstra 2026-08-25 04:36:18 +02:00 committed by GitHub
parent 9e079b27db
commit f6d2d38536
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 2202 additions and 5 deletions

27
.github/workflows/adr-toc.yml vendored Normal file
View file

@ -0,0 +1,27 @@
# Keeps doc/adr/toc.md generated rather than hand-edited. The index drifted from the records it indexes
# because every ADR merge conflict asked a human to retype it; this fails the pull request instead.
name: ADR index
on:
pull_request:
paths:
- 'doc/adr/**'
- 'scripts/adr/**'
- '.github/workflows/adr-toc.yml'
permissions:
contents: read
jobs:
check-toc:
name: doc/adr/toc.md is up to date
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
# Pinned to an immutable commit so retargeting the v4 tag upstream cannot change what runs
# here, matching how update-wiki.yml already pins this action.
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check the generated index
run: ./scripts/adr/generate-toc.sh --check

View file

@ -58,6 +58,17 @@ Prefer targeted `dotnet test <project>` commands while iterating, then run a bro
- `build/`: NUKE build project.
- `doc/`, `design/`, and `specs/`: documentation, design assets, and feature specifications.
## Architecture Decision Records
- ADRs live in `doc/adr/`. New records are named `YYYY-MM-DD-slug.md`, and the `# ` heading carries the
title alone with no numeric prefix. Do not continue the legacy `NNNN-` sequence: it collided on every
concurrent branch, which is what the date prefix exists to stop.
- Records `0001`-`0027` keep their existing names and headings. Do not renumber or rename them.
- `doc/adr/toc.md` is generated. Never edit it by hand: run `scripts/adr/generate-toc.sh` and commit the
result. `scripts/adr/generate-toc.sh --check` verifies it is current, and CI runs the same check.
- The rationale is recorded in
[Identify new ADRs by date instead of a sequential number](doc/adr/2026-08-25-date-prefixed-adr-identifiers.md).
## Testing Guidance
- Place tests near the relevant existing test project rather than creating a new project by default.

View file

@ -27,6 +27,22 @@ All changes happen through Pull Requests targeting the `main` branch.
4. Ensure the test suite passes.
5. Open a Pull Request.
### Architecture Decision Records
Records live in `doc/adr/`. Name a new one `YYYY-MM-DD-slug.md` and give it a `# ` heading with the title
alone — no numeric prefix. The older `NNNN-` records stay as they are; do not renumber them, and do not
continue the sequence. Sequential numbering had no reservation step, so two branches in flight always
picked the same number and one of them had to be renumbered on merge.
`doc/adr/toc.md` is generated. After adding or retitling a record, run:
```bash
scripts/adr/generate-toc.sh
```
and commit the result. CI runs `scripts/adr/generate-toc.sh --check` and fails a pull request whose index
is out of date, so the index is never hand-edited.
---
## Pull Requests

View file

@ -513,6 +513,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.UserTasks.UnitTests",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.UserTasks.Persistence.EFCore.UnitTests", "test\unit\Elsa.UserTasks.Persistence.EFCore.UnitTests\Elsa.UserTasks.Persistence.EFCore.UnitTests.csproj", "{A4F3CB08-759D-4FE6-B715-E8D91E3E3F3E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.UserTasks.Persistence.ConformanceTests", "test\unit\Elsa.UserTasks.Persistence.ConformanceTests\Elsa.UserTasks.Persistence.ConformanceTests.csproj", "{0C16ED4D-2483-488D-9CA1-D269ED5BEB9F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -2441,6 +2443,18 @@ Global
{A4F3CB08-759D-4FE6-B715-E8D91E3E3F3E}.Release|x64.Build.0 = Release|Any CPU
{A4F3CB08-759D-4FE6-B715-E8D91E3E3F3E}.Release|x86.ActiveCfg = Release|Any CPU
{A4F3CB08-759D-4FE6-B715-E8D91E3E3F3E}.Release|x86.Build.0 = Release|Any CPU
{0C16ED4D-2483-488D-9CA1-D269ED5BEB9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0C16ED4D-2483-488D-9CA1-D269ED5BEB9F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0C16ED4D-2483-488D-9CA1-D269ED5BEB9F}.Debug|x64.ActiveCfg = Debug|Any CPU
{0C16ED4D-2483-488D-9CA1-D269ED5BEB9F}.Debug|x64.Build.0 = Debug|Any CPU
{0C16ED4D-2483-488D-9CA1-D269ED5BEB9F}.Debug|x86.ActiveCfg = Debug|Any CPU
{0C16ED4D-2483-488D-9CA1-D269ED5BEB9F}.Debug|x86.Build.0 = Debug|Any CPU
{0C16ED4D-2483-488D-9CA1-D269ED5BEB9F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0C16ED4D-2483-488D-9CA1-D269ED5BEB9F}.Release|Any CPU.Build.0 = Release|Any CPU
{0C16ED4D-2483-488D-9CA1-D269ED5BEB9F}.Release|x64.ActiveCfg = Release|Any CPU
{0C16ED4D-2483-488D-9CA1-D269ED5BEB9F}.Release|x64.Build.0 = Release|Any CPU
{0C16ED4D-2483-488D-9CA1-D269ED5BEB9F}.Release|x86.ActiveCfg = Release|Any CPU
{0C16ED4D-2483-488D-9CA1-D269ED5BEB9F}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -2644,6 +2658,7 @@ Global
{385BB2E8-33F3-4127-BBD6-33F06938EE1C} = {CA253913-39DE-BFD0-C9A3-4B7EC6FBDF17}
{FB045674-6D63-4B49-A060-73D580E0FAC0} = {AB07AAEB-2C7A-1088-880A-08DB82DA218D}
{A4F3CB08-759D-4FE6-B715-E8D91E3E3F3E} = {AB07AAEB-2C7A-1088-880A-08DB82DA218D}
{0C16ED4D-2483-488D-9CA1-D269ED5BEB9F} = {18453B51-25EB-4317-A4B3-B10518252E92}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E}

View file

@ -0,0 +1,27 @@
# Identify new ADRs by date instead of a sequential number
Date: 2026-08-25
## Status
Accepted
## Context
`doc/adr/` numbered records sequentially with no reservation step, so every branch that wrote an ADR guessed the same next number as every other branch in flight. The User Tasks records were renumbered twice across two consecutive merges from `main``0011`/`0012` to `0014`/`0015`, then to `0026`/`0027` — as the output-converter and external-authentication records landed ahead of them. Each renumber meant renaming files, editing the `# NN.` heading inside each one, rebuilding `toc.md` by hand, and re-checking for stale links. None of that work said anything about the decisions themselves.
The index made it worse. Because `toc.md` was retyped by hand on every collision, it drifted from the documents it indexed: the titles recorded for records 11 through 27 no longer matched their own headings.
Renaming all 27 existing records to a new scheme would remove the mixed convention, but it would also break every existing link and conflict with any ADR branch currently open — paying the merge-friction cost one more time to stop paying it.
## Decision
New ADRs are named `YYYY-MM-DD-slug.md` and their heading carries the title alone, with no numeric prefix. Records `0001` through `0027` keep their existing names, headings, and links; they are not renumbered.
`doc/adr/toc.md` is generated by `scripts/adr/generate-toc.sh` and is never hand-edited. It takes each title from that document's own `# ` heading, lists the numbered records first and the dated ones after — every dated record postdates every numbered one, so one flat list stays chronological — and supports `--check` so CI can fail a pull request whose index is stale.
## Consequences
Two branches can now add an ADR on the same day without colliding, and a same-day collision resolves by renaming one file rather than renumbering a chain of them. ADRs are no longer referable by a short ordinal; they are referred to by filename or title, and cross-references between records must use the filename.
The directory carries two identifier styles until the numbered records are superseded, which is a visible seam but a self-limiting one. Regenerating the index corrected the titles of records 11 through 27 to match their documents, which is the last hand-edit `toc.md` should ever need.

View file

@ -1,5 +1,7 @@
# Architecture Decision Records
<!-- Generated by scripts/adr/generate-toc.sh. Do not edit by hand. -->
* [1. Record architecture decisions](0001-record-architecture-decisions.md)
* [2. Fault Propagation from Child to Parent Activities](0002-fault-propagation-from-child-to-parent-activities.md)
* [3. Direct Bookmark Management in WorkflowExecutionContext](0003-direct-bookmark-management-in-workflowexecutioncontext.md)
@ -10,9 +12,9 @@
* [8. Empty String as Default Tenant ID](0008-empty-string-as-default-tenant-id.md)
* [9. Asterisk Sentinel Value for Tenant-Agnostic Entities](0009-asterisk-sentinel-value-for-tenant-agnostic-entities.md)
* [10. Default Admin User Bootstrap for Initial Identity Access](0010-default-admin-user-bootstrap-for-initial-identity-access.md)
* [11. Output Conversion Occurs Synchronously at the Binding Boundary](0011-output-conversion-at-binding-is-synchronous.md)
* [12. Output Converters Use Explicit Stable Identities](0012-output-converters-use-explicit-stable-identities.md)
* [13. Output Converter Discovery Is Server-Owned](0013-output-converter-discovery-is-server-owned.md)
* [11. Output conversion occurs synchronously at the binding boundary](0011-output-conversion-at-binding-is-synchronous.md)
* [12. Output converters use explicit stable identities](0012-output-converters-use-explicit-stable-identities.md)
* [13. Output converter discovery is server-owned](0013-output-converter-discovery-is-server-owned.md)
* [14. Broker external sign-in through Elsa Server](0014-broker-external-sign-in-through-elsa-server.md)
* [15. Compose a scoped connection registry](0015-compose-a-scoped-connection-registry.md)
* [16. Extend authentication through deployed descriptor providers](0016-extend-authentication-through-deployed-descriptor-providers.md)
@ -25,5 +27,6 @@
* [23. Separate authentication UI composition from security administration](0023-separate-authentication-ui-composition-from-security-administration.md)
* [24. Use exact OIDC discovery and deployment-derived callbacks](0024-use-exact-oidc-discovery-and-deployment-derived-callbacks.md)
* [25. Two-axis authorization model with open resources and open verbs](0025-two-axis-authorization-model.md)
* [26. Use Identity-Neutral Participant References for User Tasks](0026-identity-neutral-user-task-participants.md)
* [27. Project User Tasks from Committed Workflow Bookmarks](0027-project-user-tasks-from-committed-bookmarks.md)
* [26. Use identity-neutral participant references for User Tasks](0026-identity-neutral-user-task-participants.md)
* [27. Project User Tasks from committed workflow bookmarks](0027-project-user-tasks-from-committed-bookmarks.md)
* [2026-08-25. Identify new ADRs by date instead of a sequential number](2026-08-25-date-prefixed-adr-identifiers.md)

91
scripts/adr/generate-toc.sh Executable file
View file

@ -0,0 +1,91 @@
#!/usr/bin/env bash
#
# Regenerates doc/adr/toc.md from the ADR files themselves.
#
# The index is generated, never hand-edited: it drifted from the documents it indexes precisely because
# every ADR merge asked a human to retype it. Run with --check to verify it is current without writing.
#
# scripts/adr/generate-toc.sh # rewrite doc/adr/toc.md
# scripts/adr/generate-toc.sh --check # exit 1 if doc/adr/toc.md is out of date
#
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
adr_dir="$repo_root/doc/adr"
toc_path="$adr_dir/toc.md"
check_only=false
if [[ "${1:-}" == "--check" ]]; then
check_only=true
elif [[ $# -gt 0 ]]; then
echo "usage: $(basename "$0") [--check]" >&2
exit 2
fi
# The heading is the source of truth for the title. A legacy "NN. " prefix is stripped so the identifier
# is rendered once, from the filename, rather than depending on whether an author remembered to type it.
title_of() {
local heading
heading="$(grep -m 1 '^# ' "$1" || true)"
[[ -n "$heading" ]] || return 1
heading="${heading#\# }"
sed -E 's/^[0-9]+\.[[:space:]]+//' <<<"$heading"
}
entries=()
add_entry() {
local file="$1" prefix="$2" title
if ! title="$(title_of "$file")"; then
echo "error: $(basename "$file") has no '# ' heading to take a title from." >&2
exit 1
fi
entries+=("$(printf '* [%s. %s](%s)' "$prefix" "$title" "$(basename "$file")")")
}
numbered=()
dated=()
while IFS= read -r file; do
case "$(basename "$file")" in
toc.md) continue ;;
# Dated first: a date also opens with four digits, so testing NNNN- first would swallow it.
[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-*.md) dated+=("$file") ;;
[0-9][0-9][0-9][0-9]-*.md) numbered+=("$file") ;;
*)
echo "error: $(basename "$file") matches neither the NNNN- nor the YYYY-MM-DD- naming convention." >&2
exit 1
;;
esac
done < <(LC_ALL=C find "$adr_dir" -maxdepth 1 -name '*.md' | LC_ALL=C sort)
# Numbered records first, then dated ones: every dated record postdates every numbered one, so one flat
# list stays chronological across the change of convention.
for file in ${numbered[@]+"${numbered[@]}"}; do
identifier="$(basename "$file")"
identifier="${identifier%%-*}"
# 10# so a zero-padded identifier is never read as octal.
add_entry "$file" "$((10#$identifier))"
done
for file in ${dated[@]+"${dated[@]}"}; do
identifier="$(basename "$file")"
add_entry "$file" "${identifier:0:10}"
done
generated="$(
echo '# Architecture Decision Records'
echo
echo '<!-- Generated by scripts/adr/generate-toc.sh. Do not edit by hand. -->'
echo
printf '%s\n' ${entries[@]+"${entries[@]}"}
)"
if [[ "$check_only" == true ]]; then
if ! diff -u "$toc_path" <(printf '%s\n' "$generated"); then
echo "doc/adr/toc.md is out of date. Run scripts/adr/generate-toc.sh and commit the result." >&2
exit 1
fi
echo "doc/adr/toc.md is up to date."
else
printf '%s\n' "$generated" > "$toc_path"
echo "Wrote doc/adr/toc.md."
fi

View file

@ -103,6 +103,8 @@ public sealed class EFCoreUserTaskInvitationOutbox(
ISystemClock clock,
IOptions<UserTasksOptions> options) : IUserTaskInvitationOutbox
{
private static readonly JsonSerializerOptions MetadataJsonOptions = new() { PropertyNameCaseInsensitive = true };
private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector("Elsa.UserTasks.InvitationDelivery.v1");
public async Task EnqueueAsync(UserTaskInvitationDelivery delivery, CancellationToken cancellationToken = default)
@ -155,6 +157,9 @@ public sealed class EFCoreUserTaskInvitationOutbox(
deliveries.Add(new(row.Id, row.TenantId, row.TaskId, row.InvitationId, row.DispatcherProvider, token, row.ExpiresAt)
{
// The recipient is the only address the host's dispatcher has to send the link to. Dropping
// it here made every durably queued invitation undeliverable while still reporting success.
Recipient = ReadRecipient(row.DeliveryMetadataJson),
Attempt = row.Attempts,
NotBefore = row.AvailableAt
});
@ -163,6 +168,24 @@ public sealed class EFCoreUserTaskInvitationOutbox(
return deliveries;
}
private static string? ReadRecipient(string? metadataJson)
{
if (string.IsNullOrWhiteSpace(metadataJson))
return null;
try
{
return JsonSerializer.Deserialize<DeliveryMetadata>(metadataJson, MetadataJsonOptions)?.Recipient;
}
catch (JsonException)
{
// Metadata is routing information, not the secret. Unreadable metadata must not block a
// delivery the dispatcher may still be able to route on its own.
return null;
}
}
private sealed record DeliveryMetadata(string? Recipient);
public async Task CompleteAsync(string deliveryId, CancellationToken cancellationToken = default)
{
await using var dbContext = await store.CreateDbContextAsync(cancellationToken);

View file

@ -194,6 +194,14 @@ public sealed class VNextUserTaskRepository(IDocumentStore documentStore) : IUse
["MaterializationKey"] = task.MaterializationKey,
["BookmarkId"] = task.BookmarkId,
["TaskType"] = task.TaskType,
// Every index the schema provider declares must be supplied on save; the store rejects the
// write outright when one is absent, so an omission here disables the provider entirely
// rather than merely losing an index.
["WorkflowDefinitionId"] = task.WorkflowDefinitionId,
["WorkflowInstanceId"] = task.WorkflowInstanceId,
["ActivityInstanceId"] = task.ActivityInstanceId,
["CreatedAt"] = task.CreatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
["CompletedAt"] = task.CompletedAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
["AssigneeProvider"] = task.Assignee?.Provider,
["AssigneeType"] = task.Assignee?.Type.ToString(),
["AssigneeId"] = task.Assignee?.Id,

View file

@ -32,6 +32,9 @@ public sealed class UserTaskPersistenceSchemaProvider : IPersistenceSchemaProvid
.Field("AssignedAt", PersistenceColumnType.DateTimeOffset).Field("CompletedAt", PersistenceColumnType.DateTimeOffset).Field("CreatedFromBookmarkRevision", PersistenceColumnType.Int64)
.Key("PK_UserTasks", "Id").Index("IX_UserTasks_Tenant_MaterializationKey", ["TenantId", "MaterializationKey"], unique: true)
.Index("IX_UserTasks_Tenant_BookmarkId", ["TenantId", "BookmarkId"], unique: true).Index("IX_UserTasks_Tenant_Status", ["TenantId", "Status"])
// Status alone, deliberately: resolving an invitation token hash is tenant-agnostic by
// design, and the store only serves a query whose filter set exactly matches an index.
.Index("IX_UserTasks_Status", ["Status"])
.Index("IX_UserTasks_Tenant_Assignee", ["TenantId", "AssigneeProvider", "AssigneeType", "AssigneeId"]).Index("IX_UserTasks_Tenant_Priority", ["TenantId", "Priority"])
.Index("IX_UserTasks_Tenant_DueAt", ["TenantId", "DueAt"]).Index("IX_UserTasks_Tenant_WorkflowDefinition", ["TenantId", "WorkflowDefinitionId"])
.Index("IX_UserTasks_Tenant_WorkflowInstance", ["TenantId", "WorkflowInstanceId"]).Index("IX_UserTasks_Tenant_ActivityInstance", ["TenantId", "ActivityInstanceId"])

View file

@ -0,0 +1,96 @@
using System.Text;
using Elsa.UserTasks.Persistence.ConformanceTests.Infrastructure;
using Xunit.Abstractions;
namespace Elsa.UserTasks.Persistence.ConformanceTests;
/// <summary>
/// States, in one always-running place, which providers this run actually covered.
///
/// A silently skipped provider reads as covered when it is not, which is how a persistence defect reaches
/// production behind a provider nobody ran. These tests fail when a provider that should have run did not,
/// and otherwise write the full matrix to the test output and to an artifact file.
/// </summary>
public sealed class ConformanceCoverageTests(ITestOutputHelper output)
{
/// <summary>Providers that must run everywhere, including on a developer machine with no containers.</summary>
private static readonly string[] RequiredProviders =
[
ConformanceProviders.InMemory,
ConformanceProviders.Sqlite,
ConformanceProviders.VNext
];
[Fact]
public void EveryProviderThatMustRunIsReachable()
{
var unreachable = RequiredProviders
.Select(ConformanceProviders.Get)
.Where(x => !x.IsAvailable)
.Select(x => $"{x.Name}: {x.SkipReason}")
.ToList();
Assert.True(unreachable.Count == 0,
"These providers must run in every conformance run but did not:" + Environment.NewLine + string.Join(Environment.NewLine, unreachable));
}
[Fact]
public void AProviderRequestedByTheEnvironmentIsNotQuietlyIgnored()
{
// Setting the variable to whitespace is the failure mode worth catching: it looks configured on the
// CI job and gates nothing, so the provider reports as skipped while the operator believes it ran.
var misconfigured = ConformanceProviders.All
.Where(x => x.ConnectionStringVariable is not null && !x.IsAvailable)
.Where(x => Environment.GetEnvironmentVariable(x.ConnectionStringVariable!) is not null)
.Select(x => $"{x.Name}: {x.ConnectionStringVariable} is set but empty.")
.ToList();
Assert.True(misconfigured.Count == 0, string.Join(Environment.NewLine, misconfigured));
}
[Fact]
public void TheCoverageMatrixIsReported()
{
var report = BuildReport();
output.WriteLine(report);
var path = Path.Join(AppContext.BaseDirectory, "user-task-conformance-coverage.md");
File.WriteAllText(path, report);
Assert.Contains("| Provider |", report, StringComparison.Ordinal);
}
private static string BuildReport()
{
var builder = new StringBuilder();
builder.AppendLine("# User Tasks persistence conformance coverage").AppendLine();
builder.AppendLine("| Provider | Repository | Guest sessions | Outbox | Status |");
builder.AppendLine("| --- | --- | --- | --- | --- |");
foreach (var provider in ConformanceProviders.All)
{
var status = provider.IsAvailable ? "covered" : $"**not covered** — {provider.SkipReason}";
builder.AppendLine($"| {provider.Name} | {Mark(provider.Name, Contract.Repository)} | {Mark(provider.Name, Contract.GuestSessions)} | {Mark(provider.Name, Contract.Outbox)} | {status} |");
}
builder.AppendLine();
builder.AppendLine("`yes` means the suite ran against that contract in this run. A blank cell means the provider");
builder.AppendLine("ships no implementation of it; `no` means it has one that this run did not exercise.");
return builder.ToString();
}
private static string Mark(string providerName, Contract contract)
{
// VNext ships a repository only. Saying so beats leaving it to be inferred from an absent class.
if (providerName == ConformanceProviders.VNext && contract != Contract.Repository)
return "n/a";
return ConformanceProviders.Get(providerName).IsAvailable ? "yes" : "no";
}
private enum Contract
{
Repository,
GuestSessions,
Outbox
}
}

View file

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Include>[Elsa.UserTasks]*,[Elsa.UserTasks.Persistence.EFCore]*,[Elsa.UserTasks.Persistence.VNext]*</Include>
<Threshold>0</Threshold>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite"/>
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\modules\Elsa.UserTasks.Persistence.EFCore.Oracle\Elsa.UserTasks.Persistence.EFCore.Oracle.csproj"/>
<ProjectReference Include="..\..\..\src\modules\Elsa.UserTasks.Persistence.EFCore.PostgreSql\Elsa.UserTasks.Persistence.EFCore.PostgreSql.csproj"/>
<ProjectReference Include="..\..\..\src\modules\Elsa.UserTasks.Persistence.EFCore.Sqlite\Elsa.UserTasks.Persistence.EFCore.Sqlite.csproj"/>
<ProjectReference Include="..\..\..\src\modules\Elsa.UserTasks.Persistence.EFCore.SqlServer\Elsa.UserTasks.Persistence.EFCore.SqlServer.csproj"/>
<ProjectReference Include="..\..\..\src\modules\Elsa.UserTasks.Persistence.VNext\Elsa.UserTasks.Persistence.VNext.csproj"/>
<ProjectReference Include="..\..\..\src\modules\Elsa.Persistence.VNext.Sqlite\Elsa.Persistence.VNext.Sqlite.csproj"/>
</ItemGroup>
</Project>

View file

@ -0,0 +1,131 @@
using Elsa.UserTasks.Contracts;
using Elsa.UserTasks.Models;
namespace Elsa.UserTasks.Persistence.ConformanceTests.Faults;
/// <summary>
/// Thrown by the fault decorators. A distinct type so a test can tell an injected failure apart from a
/// genuine one and never accidentally assert on the wrong exception.
/// </summary>
public sealed class InjectedStoreFaultException(string operation)
: Exception($"Injected store failure on '{operation}'.");
/// <summary>
/// Wraps a real repository and fails the first N calls to a chosen operation, so a test can assert that the
/// system converges on retry rather than committing half a change.
///
/// Every defect this suite exists to pin was found by injecting a failure, not by a happy-path test, so the
/// decorators are part of the suite's surface rather than a helper hidden in one test file.
/// </summary>
public sealed class FaultingUserTaskRepository(IUserTaskRepository inner) : IUserTaskRepository
{
/// <summary>Number of leading <see cref="SaveAsync"/> calls that throw before the real one runs.</summary>
public int FailSaveCalls { get; set; }
/// <summary>Number of leading <see cref="TryMutateAsync"/> calls that throw before the real one runs.</summary>
public int FailTryMutateCalls { get; set; }
/// <summary>Number of leading <see cref="AppendEventAsync"/> calls that throw before the real one runs.</summary>
public int FailAppendEventCalls { get; set; }
public int SaveCallCount { get; private set; }
public int TryMutateCallCount { get; private set; }
public int AppendEventCallCount { get; private set; }
public Task<UserTask?> GetAsync(string tenantId, string taskId, CancellationToken cancellationToken = default) =>
inner.GetAsync(tenantId, taskId, cancellationToken);
public Task<UserTaskQueryResult> QueryAsync(UserTaskQuery query, CancellationToken cancellationToken = default) =>
inner.QueryAsync(query, cancellationToken);
public Task<UserTask?> FindByMaterializationKeyAsync(string tenantId, string key, CancellationToken cancellationToken = default) =>
inner.FindByMaterializationKeyAsync(tenantId, key, cancellationToken);
public Task<UserTask?> FindByBookmarkIdAsync(string tenantId, string bookmarkId, CancellationToken cancellationToken = default) =>
inner.FindByBookmarkIdAsync(tenantId, bookmarkId, cancellationToken);
public Task<(UserTask Task, UserTaskInvitation Invitation)?> FindByInvitationTokenHashAsync(string tokenHash, CancellationToken cancellationToken = default) =>
inner.FindByInvitationTokenHashAsync(tokenHash, cancellationToken);
public Task AddProjectionAsync(UserTask task, CancellationToken cancellationToken = default) =>
inner.AddProjectionAsync(task, cancellationToken);
public Task SaveAsync(UserTask task, int expectedRevision, CancellationToken cancellationToken = default)
{
SaveCallCount++;
if (FailSaveCalls-- > 0)
throw new InjectedStoreFaultException(nameof(SaveAsync));
return inner.SaveAsync(task, expectedRevision, cancellationToken);
}
public Task AppendEventAsync(string tenantId, string taskId, UserTaskEvent @event, CancellationToken cancellationToken = default)
{
AppendEventCallCount++;
if (FailAppendEventCalls-- > 0)
throw new InjectedStoreFaultException(nameof(AppendEventAsync));
return inner.AppendEventAsync(tenantId, taskId, @event, cancellationToken);
}
public Task<bool> TryMutateAsync(string tenantId, string taskId, int expectedRevision, Func<UserTask, bool> mutation, CancellationToken cancellationToken = default)
{
TryMutateCallCount++;
if (FailTryMutateCalls-- > 0)
throw new InjectedStoreFaultException(nameof(TryMutateAsync));
return inner.TryMutateAsync(tenantId, taskId, expectedRevision, mutation, cancellationToken);
}
}
/// <summary>
/// Wraps a real guest session issuer to drive the two failure shapes that produced live-credential defects:
/// a session store that fails during revocation, and a session issued inside the revoke commit window.
/// </summary>
public sealed class FaultingGuestSessionIssuer(IUserTaskGuestSessionIssuer inner) : IUserTaskGuestSessionIssuer
{
/// <summary>
/// Decides, from the 1-based revocation-sweep ordinal, whether that sweep throws instead of running.
/// Revocation deliberately sweeps on both sides of its commit, and the two sides fail differently, so a
/// test has to be able to name which one it is breaking.
/// </summary>
public Func<int, bool>? FailRevokeForInvitationWhen { get; set; }
/// <summary>Runs immediately after a session is issued, to interleave a revoke against a live verify.</summary>
public Func<Task>? AfterIssue { get; set; }
/// <summary>Runs before a revocation sweep, given its 1-based ordinal, to interleave against it.</summary>
public Func<int, Task>? BeforeRevokeForInvitation { get; set; }
public int IssueCallCount { get; private set; }
public int RevokeForInvitationCallCount { get; private set; }
public int RevokeForTaskCallCount { get; private set; }
/// <summary>Zeroes the counters so a test can express its expectations relative to its own arrangement.</summary>
public void ResetCounters() => IssueCallCount = RevokeForInvitationCallCount = RevokeForTaskCallCount = 0;
public async Task<GuestSessionResult> IssueAsync(UserTaskInvitation invitation, ParticipantReference subject, CancellationToken cancellationToken = default)
{
IssueCallCount++;
var result = await inner.IssueAsync(invitation, subject, cancellationToken);
if (AfterIssue is { } callback)
await callback();
return result;
}
public Task<UserTaskGuestSession?> ResolveAsync(string credential, CancellationToken cancellationToken = default) =>
inner.ResolveAsync(credential, cancellationToken);
public Task RevokeForTaskAsync(string tenantId, string taskId, CancellationToken cancellationToken = default)
{
RevokeForTaskCallCount++;
return inner.RevokeForTaskAsync(tenantId, taskId, cancellationToken);
}
public async Task RevokeForInvitationAsync(string tenantId, string invitationId, CancellationToken cancellationToken = default)
{
var ordinal = ++RevokeForInvitationCallCount;
if (BeforeRevokeForInvitation is { } callback)
await callback(ordinal);
if (FailRevokeForInvitationWhen?.Invoke(ordinal) == true)
throw new InjectedStoreFaultException(nameof(RevokeForInvitationAsync));
await inner.RevokeForInvitationAsync(tenantId, invitationId, cancellationToken);
}
}

View file

@ -0,0 +1,86 @@
using System.Reflection;
using Xunit.Abstractions;
using Xunit.Sdk;
namespace Elsa.UserTasks.Persistence.ConformanceTests.Infrastructure;
/// <summary>
/// Names the provider a conformance test class runs against. The provider key drives both the coverage
/// report and the skip decision, so a class can never claim coverage it did not exercise.
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = true)]
public sealed class ConformanceProviderAttribute(string providerName) : Attribute
{
public string ProviderName { get; } = providerName;
}
/// <summary>
/// A <see cref="FactAttribute"/> that reports as <em>skipped, with a reason</em> when the declaring class's
/// provider is unreachable, rather than passing vacuously.
///
/// The conformance tests live on shared abstract base classes, so a plain <c>Skip</c> string cannot vary by
/// provider; the discoverers below resolve the provider from the concrete test class instead.
/// </summary>
[XunitTestCaseDiscoverer(
"Elsa.UserTasks.Persistence.ConformanceTests.Infrastructure.ConformanceFactDiscoverer",
"Elsa.UserTasks.Persistence.ConformanceTests")]
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public sealed class ConformanceFactAttribute : FactAttribute;
/// <summary>The <see cref="TheoryAttribute"/> counterpart of <see cref="ConformanceFactAttribute"/>.</summary>
[XunitTestCaseDiscoverer(
"Elsa.UserTasks.Persistence.ConformanceTests.Infrastructure.ConformanceTheoryDiscoverer",
"Elsa.UserTasks.Persistence.ConformanceTests")]
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public sealed class ConformanceTheoryAttribute : TheoryAttribute;
public sealed class ConformanceFactDiscoverer(IMessageSink diagnosticMessageSink) : IXunitTestCaseDiscoverer
{
public IEnumerable<IXunitTestCase> Discover(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo factAttribute)
{
var display = discoveryOptions.MethodDisplayOrDefault();
var displayOptions = discoveryOptions.MethodDisplayOptionsOrDefault();
yield return ConformanceSkip.Resolve(testMethod.TestClass.Class) is { } reason
? new XunitSkippedDataRowTestCase(diagnosticMessageSink, display, displayOptions, testMethod, reason)
: new XunitTestCase(diagnosticMessageSink, display, displayOptions, testMethod);
}
}
public sealed class ConformanceTheoryDiscoverer(IMessageSink diagnosticMessageSink) : TheoryDiscoverer(diagnosticMessageSink)
{
public override IEnumerable<IXunitTestCase> Discover(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo theoryAttribute)
{
if (ConformanceSkip.Resolve(testMethod.TestClass.Class) is not { } reason)
return base.Discover(discoveryOptions, testMethod, theoryAttribute);
// Enumerating the data rows would touch the provider, so an unavailable provider collapses to one
// skipped case carrying the reason.
return
[
new XunitSkippedDataRowTestCase(
DiagnosticMessageSink,
discoveryOptions.MethodDisplayOrDefault(),
discoveryOptions.MethodDisplayOptionsOrDefault(),
testMethod,
reason)
];
}
}
internal static class ConformanceSkip
{
/// <summary>Returns the reason this class cannot run, or null when it must.</summary>
public static string? Resolve(ITypeInfo testClass)
{
if (testClass is not IReflectionTypeInfo reflected)
return null;
var attribute = reflected.Type.GetCustomAttribute<ConformanceProviderAttribute>(inherit: true);
// A conformance class with no provider attribute is a wiring mistake. Report it loudly rather than
// letting it run against an unknown provider and be counted as coverage.
return attribute is null
? $"{reflected.Type.Name} is missing [ConformanceProvider]; the suite cannot tell which provider it covers."
: ConformanceProviders.Get(attribute.ProviderName).SkipReason;
}
}

View file

@ -0,0 +1,62 @@
namespace Elsa.UserTasks.Persistence.ConformanceTests.Infrastructure;
/// <summary>
/// The persistence providers the conformance suite knows about, and whether this run can reach them.
///
/// A provider that cannot be reached is never silently omitted: it is reported by
/// <see cref="ConformanceCoverageTests"/> and every one of its tests is reported as skipped, with the
/// reason, by <see cref="ConformanceFactAttribute"/>. A provider that reads as "passed" must actually
/// have run.
/// </summary>
public static class ConformanceProviders
{
public const string InMemory = "InMemory";
public const string Sqlite = "EFCore.Sqlite";
public const string SqlServer = "EFCore.SqlServer";
public const string PostgreSql = "EFCore.PostgreSql";
public const string Oracle = "EFCore.Oracle";
public const string MySql = "EFCore.MySql";
public const string VNext = "VNext.Sqlite";
/// <summary>Every provider, in report order. Availability is resolved once per test run.</summary>
public static IReadOnlyList<ConformanceProvider> All { get; } =
[
Always(InMemory, "Elsa.UserTasks in-process stores"),
Always(Sqlite, "Elsa.UserTasks.Persistence.EFCore over SQLite"),
Always(VNext, "Elsa.UserTasks.Persistence.VNext over the SQLite document store"),
Gated(SqlServer, "Elsa.UserTasks.Persistence.EFCore over SQL Server", "ELSA_USERTASKS_TEST_SQLSERVER"),
Gated(PostgreSql, "Elsa.UserTasks.Persistence.EFCore over PostgreSQL", "ELSA_USERTASKS_TEST_POSTGRES"),
Gated(Oracle, "Elsa.UserTasks.Persistence.EFCore over Oracle", "ELSA_USERTASKS_TEST_ORACLE"),
Blocked(MySql, "Elsa.UserTasks.Persistence.EFCore over MySQL",
"Pomelo.EntityFrameworkCore.MySql 9.0.0 caps Microsoft.EntityFrameworkCore.Relational at 9.0.x while this " +
"repository targets 10.0.9, so the module cannot be referenced from a test project at all (NU1107). " +
"This provider is uncovered until the Pomelo pin moves to an EF Core 10 release.")
];
public static ConformanceProvider Get(string name) =>
All.FirstOrDefault(x => x.Name == name) ?? throw new ArgumentOutOfRangeException(nameof(name), name, "Unknown conformance provider.");
private static ConformanceProvider Always(string name, string description) => new(name, description, null, null);
private static ConformanceProvider Gated(string name, string description, string variable) => new(
name, description, variable,
Environment.GetEnvironmentVariable(variable) is { Length: > 0 }
? null
: $"{description} is not covered by this run: set {variable} to a connection string to include it.");
private static ConformanceProvider Blocked(string name, string description, string reason) => new(name, description, null, reason);
}
/// <param name="Name">The stable provider key used by <see cref="ConformanceProviderAttribute"/>.</param>
/// <param name="Description">Human-readable description used in the coverage report.</param>
/// <param name="ConnectionStringVariable">The environment variable carrying the connection string, when gated.</param>
/// <param name="SkipReason">Null when the provider runs; otherwise the reason it does not.</param>
public sealed record ConformanceProvider(string Name, string Description, string? ConnectionStringVariable, string? SkipReason)
{
public bool IsAvailable => SkipReason is null;
public string ConnectionString => ConnectionStringVariable is null
? throw new InvalidOperationException($"Provider '{Name}' is not configured by a connection string.")
: Environment.GetEnvironmentVariable(ConnectionStringVariable)
?? throw new InvalidOperationException($"Provider '{Name}' requires {ConnectionStringVariable} to be set.");
}

View file

@ -0,0 +1,145 @@
using Elsa.UserTasks.Persistence.ConformanceTests.Infrastructure;
using Elsa.UserTasks.Persistence.ConformanceTests.Providers;
namespace Elsa.UserTasks.Persistence.ConformanceTests;
// One collection per provider. Classes in a collection run sequentially and share the provider's stores,
// so a container-backed provider is migrated once per run; each test still isolates itself by tenant.
// Different providers remain free to run in parallel with each other.
[CollectionDefinition(Name)]
public sealed class InMemoryCollection : ICollectionFixture<InMemoryUserTaskStoreFixture>
{
public const string Name = "UserTasks:InMemory";
}
[CollectionDefinition(Name)]
public sealed class SqliteCollection : ICollectionFixture<SqliteUserTaskStoreFixture>
{
public const string Name = "UserTasks:EFCore.Sqlite";
}
[CollectionDefinition(Name)]
public sealed class SqlServerCollection : ICollectionFixture<SqlServerUserTaskStoreFixture>
{
public const string Name = "UserTasks:EFCore.SqlServer";
}
[CollectionDefinition(Name)]
public sealed class PostgreSqlCollection : ICollectionFixture<PostgreSqlUserTaskStoreFixture>
{
public const string Name = "UserTasks:EFCore.PostgreSql";
}
[CollectionDefinition(Name)]
public sealed class OracleCollection : ICollectionFixture<OracleUserTaskStoreFixture>
{
public const string Name = "UserTasks:EFCore.Oracle";
}
[CollectionDefinition(Name)]
public sealed class VNextCollection : ICollectionFixture<VNextUserTaskStoreFixture>
{
public const string Name = "UserTasks:VNext.Sqlite";
}
// ---------------------------------------------------------------------------------------------------
// In-memory: the reference implementation, held to the same contract as the durable providers.
// ---------------------------------------------------------------------------------------------------
[Collection(InMemoryCollection.Name), ConformanceProvider(ConformanceProviders.InMemory)]
public sealed class InMemoryUserTaskRepositoryConformanceTests(InMemoryUserTaskStoreFixture fixture)
: UserTaskRepositoryConformanceTests(fixture);
[Collection(InMemoryCollection.Name), ConformanceProvider(ConformanceProviders.InMemory)]
public sealed class InMemoryUserTaskGuestSessionConformanceTests(InMemoryUserTaskStoreFixture fixture)
: UserTaskGuestSessionConformanceTests(fixture);
[Collection(InMemoryCollection.Name), ConformanceProvider(ConformanceProviders.InMemory)]
public sealed class InMemoryUserTaskInvitationOutboxConformanceTests(InMemoryUserTaskStoreFixture fixture)
: UserTaskInvitationOutboxConformanceTests(fixture);
[Collection(InMemoryCollection.Name), ConformanceProvider(ConformanceProviders.InMemory)]
public sealed class InMemoryUserTaskFaultInjectionConformanceTests(InMemoryUserTaskStoreFixture fixture)
: UserTaskFaultInjectionConformanceTests(fixture);
// ---------------------------------------------------------------------------------------------------
// EF Core over SQLite: the durable provider CI covers on every pull request.
// ---------------------------------------------------------------------------------------------------
[Collection(SqliteCollection.Name), ConformanceProvider(ConformanceProviders.Sqlite)]
public sealed class SqliteUserTaskRepositoryConformanceTests(SqliteUserTaskStoreFixture fixture)
: UserTaskRepositoryConformanceTests(fixture);
[Collection(SqliteCollection.Name), ConformanceProvider(ConformanceProviders.Sqlite)]
public sealed class SqliteUserTaskGuestSessionConformanceTests(SqliteUserTaskStoreFixture fixture)
: UserTaskGuestSessionConformanceTests(fixture);
[Collection(SqliteCollection.Name), ConformanceProvider(ConformanceProviders.Sqlite)]
public sealed class SqliteUserTaskInvitationOutboxConformanceTests(SqliteUserTaskStoreFixture fixture)
: UserTaskInvitationOutboxConformanceTests(fixture);
[Collection(SqliteCollection.Name), ConformanceProvider(ConformanceProviders.Sqlite)]
public sealed class SqliteUserTaskFaultInjectionConformanceTests(SqliteUserTaskStoreFixture fixture)
: UserTaskFaultInjectionConformanceTests(fixture);
// ---------------------------------------------------------------------------------------------------
// VNext ships a repository only, so the guest-session and outbox suites deliberately do not run here.
// ---------------------------------------------------------------------------------------------------
[Collection(VNextCollection.Name), ConformanceProvider(ConformanceProviders.VNext)]
public sealed class VNextUserTaskRepositoryConformanceTests(VNextUserTaskStoreFixture fixture)
: UserTaskRepositoryConformanceTests(fixture);
// ---------------------------------------------------------------------------------------------------
// Container-backed providers. Every test below reports as skipped, with the reason, unless the matching
// environment variable names a disposable database.
// ---------------------------------------------------------------------------------------------------
[Collection(SqlServerCollection.Name), ConformanceProvider(ConformanceProviders.SqlServer)]
public sealed class SqlServerUserTaskRepositoryConformanceTests(SqlServerUserTaskStoreFixture fixture)
: UserTaskRepositoryConformanceTests(fixture);
[Collection(SqlServerCollection.Name), ConformanceProvider(ConformanceProviders.SqlServer)]
public sealed class SqlServerUserTaskGuestSessionConformanceTests(SqlServerUserTaskStoreFixture fixture)
: UserTaskGuestSessionConformanceTests(fixture);
[Collection(SqlServerCollection.Name), ConformanceProvider(ConformanceProviders.SqlServer)]
public sealed class SqlServerUserTaskInvitationOutboxConformanceTests(SqlServerUserTaskStoreFixture fixture)
: UserTaskInvitationOutboxConformanceTests(fixture);
[Collection(SqlServerCollection.Name), ConformanceProvider(ConformanceProviders.SqlServer)]
public sealed class SqlServerUserTaskFaultInjectionConformanceTests(SqlServerUserTaskStoreFixture fixture)
: UserTaskFaultInjectionConformanceTests(fixture);
[Collection(PostgreSqlCollection.Name), ConformanceProvider(ConformanceProviders.PostgreSql)]
public sealed class PostgreSqlUserTaskRepositoryConformanceTests(PostgreSqlUserTaskStoreFixture fixture)
: UserTaskRepositoryConformanceTests(fixture);
[Collection(PostgreSqlCollection.Name), ConformanceProvider(ConformanceProviders.PostgreSql)]
public sealed class PostgreSqlUserTaskGuestSessionConformanceTests(PostgreSqlUserTaskStoreFixture fixture)
: UserTaskGuestSessionConformanceTests(fixture);
[Collection(PostgreSqlCollection.Name), ConformanceProvider(ConformanceProviders.PostgreSql)]
public sealed class PostgreSqlUserTaskInvitationOutboxConformanceTests(PostgreSqlUserTaskStoreFixture fixture)
: UserTaskInvitationOutboxConformanceTests(fixture);
[Collection(PostgreSqlCollection.Name), ConformanceProvider(ConformanceProviders.PostgreSql)]
public sealed class PostgreSqlUserTaskFaultInjectionConformanceTests(PostgreSqlUserTaskStoreFixture fixture)
: UserTaskFaultInjectionConformanceTests(fixture);
[Collection(OracleCollection.Name), ConformanceProvider(ConformanceProviders.Oracle)]
public sealed class OracleUserTaskRepositoryConformanceTests(OracleUserTaskStoreFixture fixture)
: UserTaskRepositoryConformanceTests(fixture);
[Collection(OracleCollection.Name), ConformanceProvider(ConformanceProviders.Oracle)]
public sealed class OracleUserTaskGuestSessionConformanceTests(OracleUserTaskStoreFixture fixture)
: UserTaskGuestSessionConformanceTests(fixture);
[Collection(OracleCollection.Name), ConformanceProvider(ConformanceProviders.Oracle)]
public sealed class OracleUserTaskInvitationOutboxConformanceTests(OracleUserTaskStoreFixture fixture)
: UserTaskInvitationOutboxConformanceTests(fixture);
[Collection(OracleCollection.Name), ConformanceProvider(ConformanceProviders.Oracle)]
public sealed class OracleUserTaskFaultInjectionConformanceTests(OracleUserTaskStoreFixture fixture)
: UserTaskFaultInjectionConformanceTests(fixture);

View file

@ -0,0 +1,104 @@
using System.Reflection;
using Elsa.Persistence.EFCore;
using Elsa.UserTasks.Contracts;
using Elsa.UserTasks.Persistence.EFCore;
using Elsa.UserTasks.Persistence.EFCore.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.UserTasks.Persistence.ConformanceTests.Providers;
/// <summary>
/// Shared wiring for every relational provider. Only the connection string, the migrations assembly, and
/// the <c>UseElsa*</c> call differ, so the suite runs against real SQL rather than an in-memory EF
/// provider — the revision-conflict defect the suite exists to pin only reproduces against real SQL.
/// </summary>
public abstract class EFCoreUserTaskStoreFixture : UserTaskStoreFixture
{
private ServiceProvider? _serviceProvider;
private AsyncServiceScope _scope;
protected EFCoreUserTaskStoreFixture(string providerName) : base(providerName)
{
}
public override IUserTaskRepository Repository => Resolve<EFCoreUserTaskRepository>();
public override IUserTaskGuestSessionIssuer GuestSessions => Resolve<EFCoreUserTaskGuestSessionIssuer>();
public override IUserTaskInvitationOutbox Outbox => Resolve<EFCoreUserTaskInvitationOutbox>();
private T Resolve<T>() where T : notnull
{
if (_serviceProvider is null)
throw NotActivated();
return _scope.ServiceProvider.GetRequiredService<T>();
}
/// <summary>
/// A repository on its own scope, and therefore its own change tracker and connection. Concurrency
/// tests need two genuinely independent writers; two calls on one scope would share EF state and
/// quietly agree with each other.
/// </summary>
public override IUserTaskRepository CreateSecondRepository() =>
(_serviceProvider ?? throw NotActivated()).CreateScope().ServiceProvider.GetRequiredService<EFCoreUserTaskRepository>();
protected abstract Assembly MigrationsAssembly { get; }
protected abstract void ConfigureProvider(DbContextOptionsBuilder builder, string connectionString);
protected virtual void ConfigureServices(IServiceCollection services)
{
}
/// <summary>The connection string for this run. Relational providers get a uniquely named database.</summary>
protected abstract string ResolveConnectionString();
protected override async Task ActivateCoreAsync()
{
var services = new ServiceCollection();
ConfigureServices(services);
var connectionString = ResolveConnectionString();
services.AddDbContextFactory<UserTasksElsaDbContext>(builder => ConfigureProvider(builder, connectionString));
services.AddScoped<Store<UserTasksElsaDbContext, UserTaskRecord>>();
services.AddScoped<Store<UserTasksElsaDbContext, UserTaskGuestSessionRecord>>();
services.AddScoped<Store<UserTasksElsaDbContext, UserTaskInvitationDeliveryRecord>>();
services.AddScoped<EFCoreUserTaskRepository>();
services.AddScoped<EFCoreUserTaskGuestSessionIssuer>();
services.AddScoped<EFCoreUserTaskInvitationOutbox>();
services.AddSingleton(Clock);
services.AddSingleton<Elsa.Common.ISystemClock>(Clock);
services.AddSingleton(Options);
services.AddSingleton(DataProtection);
_serviceProvider = services.BuildServiceProvider();
_scope = _serviceProvider.CreateAsyncScope();
var factory = _scope.ServiceProvider.GetRequiredService<IDbContextFactory<UserTasksElsaDbContext>>();
await using var dbContext = await factory.CreateDbContextAsync();
await dbContext.Database.MigrateAsync();
}
protected override async Task DisposeCoreAsync()
{
if (_serviceProvider is null)
return;
if (DropsOwnDatabase)
{
var factory = _scope.ServiceProvider.GetRequiredService<IDbContextFactory<UserTasksElsaDbContext>>();
await using var dbContext = await factory.CreateDbContextAsync();
await dbContext.Database.EnsureDeletedAsync();
}
await _scope.DisposeAsync();
await _serviceProvider.DisposeAsync();
}
/// <summary>
/// True where the fixture created the database itself and may remove it. The container-backed providers
/// run against an operator-supplied connection string and must never drop it; they rely on the suite's
/// per-test tenant isolation instead.
/// </summary>
protected virtual bool DropsOwnDatabase => false;
private static InvalidOperationException NotActivated() =>
new("The fixture was used before ActivateAsync ran. Every conformance test must await ActivateAsync first.");
}

View file

@ -0,0 +1,31 @@
using Elsa.UserTasks.Contracts;
using Elsa.UserTasks.Persistence.ConformanceTests.Infrastructure;
using Elsa.UserTasks.Repositories;
using Elsa.UserTasks.Services;
namespace Elsa.UserTasks.Persistence.ConformanceTests.Providers;
/// <summary>
/// The in-process stores. They are the reference implementation the durable providers are held against,
/// so they run the same suite rather than a reduced one.
/// </summary>
public sealed class InMemoryUserTaskStoreFixture : UserTaskStoreFixture
{
private readonly InMemoryUserTaskRepository _repository = new();
public InMemoryUserTaskStoreFixture() : base(ConformanceProviders.InMemory)
{
GuestSessions = new InMemoryUserTaskGuestSessionIssuer(Clock, Options);
Outbox = new InMemoryUserTaskInvitationOutbox(DataProtection, Clock, Options);
}
public override IUserTaskRepository Repository => _repository;
public override IUserTaskGuestSessionIssuer GuestSessions { get; }
public override IUserTaskInvitationOutbox Outbox { get; }
// One dictionary backs the store, so a "second" repository is the same instance. Concurrency here is
// enforced by the revision compare-and-swap, not by separate connections.
public override IUserTaskRepository CreateSecondRepository() => _repository;
protected override Task ActivateCoreAsync() => Task.CompletedTask;
}

View file

@ -0,0 +1,61 @@
using System.Reflection;
using Elsa.Persistence.EFCore.Extensions;
using Elsa.UserTasks.Persistence.ConformanceTests.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.UserTasks.Persistence.ConformanceTests.Providers;
/// <summary>SQLite runs unconditionally: it is the relational provider CI covers on every pull request.</summary>
public sealed class SqliteUserTaskStoreFixture : EFCoreUserTaskStoreFixture
{
private readonly string _databasePath = Path.Join(Path.GetTempPath(), $"elsa-user-tasks-conformance-{Guid.NewGuid():N}.db");
public SqliteUserTaskStoreFixture() : base(ConformanceProviders.Sqlite)
{
}
protected override bool DropsOwnDatabase => true;
protected override Assembly MigrationsAssembly => typeof(UserTasks.Persistence.EFCore.Sqlite.Extensions.SqliteUserTasksPersistenceFeatureExtensions).Assembly;
protected override string ResolveConnectionString() => $"Data Source={_databasePath}";
protected override void ConfigureServices(IServiceCollection services) => services.AddSqliteEntityModelCreatingHandlers();
protected override void ConfigureProvider(DbContextOptionsBuilder builder, string connectionString) => builder.UseElsaSqlite(MigrationsAssembly, connectionString);
}
/// <summary>
/// SQL Server, PostgreSQL, and Oracle run only when an operator points the matching environment variable at
/// a disposable database. The suite migrates that database and isolates itself by tenant; it never drops it.
/// </summary>
public sealed class SqlServerUserTaskStoreFixture : EFCoreUserTaskStoreFixture
{
public SqlServerUserTaskStoreFixture() : base(ConformanceProviders.SqlServer)
{
}
protected override Assembly MigrationsAssembly => typeof(UserTasks.Persistence.EFCore.SqlServer.Extensions.SqlServerUserTasksPersistenceFeatureExtensions).Assembly;
protected override string ResolveConnectionString() => Provider.ConnectionString;
protected override void ConfigureProvider(DbContextOptionsBuilder builder, string connectionString) => builder.UseElsaSqlServer(MigrationsAssembly, connectionString);
}
public sealed class PostgreSqlUserTaskStoreFixture : EFCoreUserTaskStoreFixture
{
public PostgreSqlUserTaskStoreFixture() : base(ConformanceProviders.PostgreSql)
{
}
protected override Assembly MigrationsAssembly => typeof(UserTasks.Persistence.EFCore.PostgreSql.Extensions.PostgreSqlUserTasksPersistenceFeatureExtensions).Assembly;
protected override string ResolveConnectionString() => Provider.ConnectionString;
protected override void ConfigureServices(IServiceCollection services) => services.AddPostgreSqlEntityModelCreatingHandlers();
protected override void ConfigureProvider(DbContextOptionsBuilder builder, string connectionString) => builder.UseElsaPostgreSql(MigrationsAssembly, connectionString);
}
public sealed class OracleUserTaskStoreFixture : EFCoreUserTaskStoreFixture
{
public OracleUserTaskStoreFixture() : base(ConformanceProviders.Oracle)
{
}
protected override Assembly MigrationsAssembly => typeof(UserTasks.Persistence.EFCore.Oracle.Extensions.OracleUserTasksPersistenceFeatureExtensions).Assembly;
protected override string ResolveConnectionString() => Provider.ConnectionString;
protected override void ConfigureProvider(DbContextOptionsBuilder builder, string connectionString) => builder.UseElsaOracle(MigrationsAssembly, connectionString);
}

View file

@ -0,0 +1,91 @@
using Elsa.Common;
using Elsa.UserTasks.Contracts;
using Elsa.UserTasks.Options;
using Elsa.UserTasks.Persistence.ConformanceTests.Infrastructure;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Options;
namespace Elsa.UserTasks.Persistence.ConformanceTests.Providers;
/// <summary>
/// One provider's live stores, shared by every conformance class in that provider's collection.
///
/// Construction is deliberately lazy: xUnit builds a collection fixture even when every test in the
/// collection is skipped, so an unreachable provider must not try to connect here or a clean skip would
/// surface as an error.
/// </summary>
public abstract class UserTaskStoreFixture : IAsyncLifetime
{
private readonly Lazy<Task> _activation;
protected UserTaskStoreFixture(string providerName)
{
Provider = ConformanceProviders.Get(providerName);
Options = Microsoft.Extensions.Options.Options.Create(Settings);
_activation = new(ActivateCoreAsync);
}
public ConformanceProvider Provider { get; }
public TestClock Clock { get; } = new();
public UserTasksOptions Settings { get; } = new();
public IOptions<UserTasksOptions> Options { get; }
public IDataProtectionProvider DataProtection { get; } = new PassthroughDataProtectionProvider();
/// <summary>Called by each test before it touches a store. Runs the real activation exactly once.</summary>
public Task ActivateAsync()
{
if (!Provider.IsAvailable)
throw new InvalidOperationException($"Provider '{Provider.Name}' is unavailable: {Provider.SkipReason}");
return _activation.Value;
}
public abstract IUserTaskRepository Repository { get; }
/// <summary>Overridden by providers that ship a guest session store. VNext deliberately does not.</summary>
public virtual IUserTaskGuestSessionIssuer GuestSessions =>
throw new NotSupportedException($"Provider '{Provider.Name}' has no guest session store.");
/// <summary>Overridden by providers that ship an invitation outbox. VNext deliberately does not.</summary>
public virtual IUserTaskInvitationOutbox Outbox =>
throw new NotSupportedException($"Provider '{Provider.Name}' has no invitation outbox.");
/// <summary>Creates a second repository over the same underlying store, for concurrent-writer tests.</summary>
public abstract IUserTaskRepository CreateSecondRepository();
protected abstract Task ActivateCoreAsync();
Task IAsyncLifetime.InitializeAsync() => Task.CompletedTask;
Task IAsyncLifetime.DisposeAsync() => DisposeCoreAsync();
protected virtual Task DisposeCoreAsync() => Task.CompletedTask;
public sealed class TestClock : ISystemClock
{
public DateTimeOffset UtcNow { get; set; } = new(2026, 8, 25, 12, 0, 0, TimeSpan.Zero);
public DateTimeOffset Advance(TimeSpan amount) => UtcNow = UtcNow.Add(amount);
}
/// <summary>
/// Data Protection stand-in. The outbox's contract is only that the ciphertext round-trips and that an
/// unreadable payload is dropped, so the double marks the payload instead of pulling the full
/// key-management stack into a unit test.
/// </summary>
private sealed class PassthroughDataProtectionProvider : IDataProtectionProvider, IDataProtector
{
private const string Marker = "protected:";
public IDataProtector CreateProtector(string purpose) => this;
public byte[] Protect(byte[] plaintext) => System.Text.Encoding.UTF8.GetBytes(Marker + Convert.ToBase64String(plaintext));
public byte[] Unprotect(byte[] protectedData)
{
var value = System.Text.Encoding.UTF8.GetString(protectedData);
if (!value.StartsWith(Marker, StringComparison.Ordinal))
throw new System.Security.Cryptography.CryptographicException("The payload was not protected by this provider.");
return Convert.FromBase64String(value[Marker.Length..]);
}
}
}

View file

@ -0,0 +1,39 @@
using Elsa.Persistence.VNext.Sqlite;
using Elsa.UserTasks.Contracts;
using Elsa.UserTasks.Persistence.ConformanceTests.Infrastructure;
using Elsa.UserTasks.Persistence.VNext;
using Elsa.UserTasks.Persistence.VNext.Repositories;
using Microsoft.Data.Sqlite;
namespace Elsa.UserTasks.Persistence.ConformanceTests.Providers;
/// <summary>
/// The document-store provider, over the SQLite document store so it runs in CI without a container.
/// VNext ships a repository only, so the guest-session and outbox suites deliberately do not run here;
/// the coverage report states that rather than leaving it to be inferred from an absent test class.
/// </summary>
public sealed class VNextUserTaskStoreFixture : UserTaskStoreFixture
{
private readonly SqliteConnection _connection = new("Data Source=:memory:");
private readonly SqliteDocumentStore _store;
private readonly VNextUserTaskRepository _repository;
public VNextUserTaskStoreFixture() : base(ConformanceProviders.VNext)
{
_store = new(_connection, new UserTaskPersistenceSchemaProvider().DescribeSchema());
_repository = new(_store);
}
public override IUserTaskRepository Repository => _repository;
// One in-memory SQLite connection backs the document store, so both handles address the same data.
public override IUserTaskRepository CreateSecondRepository() => new VNextUserTaskRepository(_store);
protected override async Task ActivateCoreAsync()
{
await _connection.OpenAsync();
await _store.MaterializeAsync();
}
protected override async Task DisposeCoreAsync() => await _connection.DisposeAsync();
}

View file

@ -0,0 +1,54 @@
# User Tasks persistence conformance suite
One suite, run unchanged against every implementation of the User Tasks persistence contracts. It exists
because the User Tasks build shipped four P1 defects that a single-threaded, happy-path, in-memory test
suite could not see — every one of them was found by injecting a failure against a real store.
## What it covers
| Contract | Suite | Implementations |
| --- | --- | --- |
| `IUserTaskRepository` | `UserTaskRepositoryConformanceTests` | InMemory, EF Core, VNext |
| `IUserTaskGuestSessionIssuer` | `UserTaskGuestSessionConformanceTests` | InMemory, EF Core |
| `IUserTaskInvitationOutbox` | `UserTaskInvitationOutboxConformanceTests` | InMemory, EF Core |
| The services above them | `UserTaskFaultInjectionConformanceTests` | InMemory, EF Core |
`UserTaskFaultInjectionConformanceTests` runs the real `DefaultUserTaskManager` and
`DefaultUserTaskInvitationService` against a real store and breaks the seams between them with the
decorators in `Faults/`. A cross-store operation must either commit fully or leave the caller able to
retry, and the retry must converge.
## Providers
Availability is resolved once per run by `ConformanceProviders`, and nothing is ever skipped quietly:
- **Always run**: in-memory, EF Core over SQLite, VNext over the SQLite document store.
- **Opt in with a connection string**: SQL Server, PostgreSQL, Oracle. Each test reports as *skipped, with
the reason*, when the variable is unset — not as passed.
- **Not coverable**: MySQL. `Pomelo.EntityFrameworkCore.MySql` 9.0.0 caps
`Microsoft.EntityFrameworkCore.Relational` at 9.0.x while this repository targets 10.0.9, so
`Elsa.UserTasks.Persistence.EFCore.MySql` cannot be referenced from a test project at all (NU1107).
```bash
ELSA_USERTASKS_TEST_POSTGRES="Host=localhost;Database=elsa_conformance;Username=elsa;Password=elsa" dotnet test test/unit/Elsa.UserTasks.Persistence.ConformanceTests
```
The remaining variables are `ELSA_USERTASKS_TEST_SQLSERVER` and `ELSA_USERTASKS_TEST_ORACLE`.
Point them at a **disposable** database. The suite migrates the schema and isolates each test with its own
tenant, but it never drops the database — that is deliberate, so it can never delete something an operator
cared about.
`ConformanceCoverageTests` always runs. It fails if a provider that must run is unreachable, fails if a
variable is set but empty (configured on the CI job, gating nothing), and writes the full matrix to the
test output and to `user-task-conformance-coverage.md` in the output directory.
## Adding a provider
1. Add a `ConformanceProvider` entry to `ConformanceProviders.All`.
2. Add a fixture under `Providers/`.
3. Add a collection and one concrete class per contract in `ProviderConformanceSuites.cs`, each carrying
`[ConformanceProvider(...)]`.
A conformance class without `[ConformanceProvider]` is skipped with a wiring error rather than counted as
coverage — the suite refuses to guess which provider a class exercised.

View file

@ -0,0 +1,108 @@
using Elsa.UserTasks.Contracts;
using Elsa.UserTasks.Models;
using Elsa.UserTasks.Persistence.ConformanceTests.Providers;
namespace Elsa.UserTasks.Persistence.ConformanceTests;
/// <summary>
/// Shared arrangement for every conformance class.
///
/// The stores are shared for the whole provider collection so a container-backed provider is migrated once,
/// and each test isolates itself with its own tenant instead. Every contract except the deliberately
/// tenant-agnostic invitation-hash lookup is tenant-scoped, so this is isolation, not a shortcut.
/// </summary>
public abstract class UserTaskConformanceTestBase(UserTaskStoreFixture fixture)
{
private int _sequence;
protected UserTaskStoreFixture Fixture { get; } = fixture;
protected IUserTaskRepository Repository => Fixture.Repository;
protected UserTaskStoreFixture.TestClock Clock => Fixture.Clock;
/// <summary>This test's private tenant. Never reused, so a shared store still gives per-test isolation.</summary>
protected string TenantId { get; } = $"tenant-{Guid.NewGuid():N}";
protected Task ActivateAsync() => Fixture.ActivateAsync();
protected ParticipantReference Subject(string id = "user-1") => new(TenantId, "oidc", UserTaskParticipantType.User, id);
protected ParticipantReference Group(string id) => new(TenantId, "oidc", UserTaskParticipantType.Group, id);
/// <summary>Builds a task in this test's tenant with store-unique keys, ready for <c>AddProjectionAsync</c>.</summary>
protected UserTask CreateTask(
ParticipantReference? candidate = null,
string title = "Approve request",
int priority = 50,
DateTimeOffset? dueAt = null,
DateTimeOffset? createdAt = null)
{
var ordinal = ++_sequence;
var created = createdAt ?? Clock.UtcNow.AddMinutes(ordinal);
return new()
{
// Ordinal-prefixed so the identity tiebreaker is predictable and a failure is readable.
Id = $"task-{ordinal:D4}-{Guid.NewGuid():N}",
TenantId = TenantId,
WorkflowDefinitionId = "definition-1",
WorkflowInstanceId = "instance-1",
ActivityInstanceId = "activity-1",
BookmarkId = $"bookmark-{Guid.NewGuid():N}",
MaterializationKey = $"materialization-{Guid.NewGuid():N}",
Title = title,
Summary = "Review the request",
Tags = ["finance"],
Priority = priority,
DueAt = dueAt,
CandidateUsers = candidate is null ? [] : [candidate],
InvitationDefinitions = [new UserTaskInvitationDefinition("bearer", ["Complete"], BearerOnly: true)],
CreatedAt = created,
UpdatedAt = created
};
}
/// <summary>Projects a task and returns the stored copy, so a test starts from committed state in one line.</summary>
protected async Task<UserTask> ProjectAsync(UserTask task)
{
await Repository.AddProjectionAsync(task);
return await Repository.GetAsync(task.TenantId, task.Id)
?? throw new InvalidOperationException($"The projection of '{task.Id}' was not readable afterwards.");
}
protected async Task<UserTask> GetAsync(string taskId) =>
await Repository.GetAsync(TenantId, taskId) ?? throw new InvalidOperationException($"Task '{taskId}' was not found.");
protected UserTaskQuery Query(
UserTaskQueryScopeKind kind = UserTaskQueryScopeKind.Available,
ParticipantReference? subject = null,
int limit = 50,
string sort = "created",
bool descending = false,
bool includeTotalCount = false,
string? cursor = null) => new()
{
TenantId = TenantId,
Limit = limit,
Sort = sort,
Descending = descending,
IncludeTotalCount = includeTotalCount,
Cursor = cursor,
Scope = new(TenantId, subject ?? Subject(), [], Kind: kind)
};
/// <summary>Pages a query to exhaustion through its cursors and returns the ids in the order seen.</summary>
protected async Task<IReadOnlyList<string>> PageThroughAsync(UserTaskQuery query, int pageSize)
{
var seen = new List<string>();
string? cursor = null;
for (var page = 0; page < 100; page++)
{
var result = await Repository.QueryAsync(query with { Limit = pageSize, Cursor = cursor });
seen.AddRange(result.Items.Select(x => x.Id));
if (result.NextCursor is null)
return seen;
cursor = result.NextCursor;
}
throw new InvalidOperationException("The cursor never terminated; paging looped past 100 pages.");
}
}

View file

@ -0,0 +1,306 @@
using Elsa.UserTasks.Contracts;
using Elsa.UserTasks.Models;
using Elsa.UserTasks.Persistence.ConformanceTests.Faults;
using Elsa.UserTasks.Persistence.ConformanceTests.Infrastructure;
using Elsa.UserTasks.Persistence.ConformanceTests.Providers;
using Elsa.UserTasks.Services;
using Elsa.Workflows;
namespace Elsa.UserTasks.Persistence.ConformanceTests;
/// <summary>
/// Runs the real <see cref="DefaultUserTaskManager"/> and <see cref="DefaultUserTaskInvitationService"/>
/// against a real store, with failures injected at the seams between them.
///
/// Every P1 defect in the User Tasks build was found this way and none by a happy-path test, so injecting
/// faults is treated here as a first-class part of the contract rather than a special case: a cross-store
/// operation must either commit fully or leave the caller able to retry, and a retry must converge.
/// </summary>
public abstract class UserTaskFaultInjectionConformanceTests(UserTaskStoreFixture fixture) : UserTaskConformanceTestBase(fixture)
{
private readonly TestIdentityGenerator _identity = new();
private readonly TestSink _sink = new();
private readonly DefaultUserTaskAccessPolicy _policy = new();
[ConformanceFact]
public async Task AConcurrentEditMakesTheManagerReportAConflictInsteadOfFaulting()
{
await ActivateAsync();
var task = await ProjectAsync(CreateTask(Subject()));
var manager = CreateManager(Repository);
// Someone else moves the task on, so the revision the caller holds is now stale.
var concurrent = await GetAsync(task.Id);
concurrent.Priority = 90;
await Repository.SaveAsync(concurrent, concurrent.Revision);
var result = await manager.ClaimAsync(TenantId, task.Id, new(task.Revision, "claim-1"), Actor());
// The store raises its own concurrency exception underneath. It must surface as the documented
// conflict result rather than escaping to the unhandled-error middleware as a 500.
Assert.False(result.Accepted);
Assert.Equal("revision-conflict", result.ConflictCode);
}
[ConformanceFact]
public async Task TwoCompletionsOnOneRevisionLeaveExactlyOneWinnerAndOneConflict()
{
await ActivateAsync();
var subject = Subject();
var task = await ProjectAsync(CreateTask(subject));
var actor = Actor();
var manager = CreateManager(Repository);
var contender = CreateManager(Fixture.CreateSecondRepository());
var claimed = await manager.ClaimAsync(TenantId, task.Id, new(task.Revision, "claim-1"), actor);
Assert.True(claimed.Accepted);
var revision = claimed.Task!.Revision;
var first = await manager.CompleteAsync(TenantId, task.Id, new(revision, "op-first", "Complete"), actor);
var second = await contender.CompleteAsync(TenantId, task.Id, new(revision, "op-second", "Complete"), actor);
Assert.True(first.Accepted);
Assert.False(second.Accepted);
// The loser gets a conflict it can act on, never an unhandled exception and never a silent success.
Assert.Equal("revision-conflict", second.ConflictCode);
// Completion is two-phase: the manager records the intent and the workflow resumption settles it.
// With no resumer attached, Completing is the committed state, and only one of the two got there.
Assert.Equal(UserTaskStatus.Completing, (await GetAsync(task.Id)).Status);
}
[ConformanceFact]
public async Task AFailureInThePreCommitSweepLeavesTheInvitationRevocableAndTheRetryRepairsIt()
{
await ActivateAsync();
var (task, invitationId, credential, sessions) = await IssueGuestSessionAsync();
var invitations = CreateInvitationService(Repository, sessions);
sessions.ResetCounters();
// Fail the sweep that runs before anything is committed.
sessions.FailRevokeForInvitationWhen = ordinal => ordinal == 1;
var current = await GetAsync(task.Id);
await Assert.ThrowsAsync<InjectedStoreFaultException>(() =>
invitations.RevokeAsync(TenantId, task.Id, invitationId, current.Revision, ManagerActor()));
// Nothing was committed, so the invitation is still open and the credential is still live. Failing
// closed here is the point: committing the terminal state first would strand a live credential
// behind a guard that rejects the retry.
var afterFailure = await GetAsync(task.Id);
Assert.Equal(UserTaskInvitationStatus.Consumed, Invitation(afterFailure, invitationId).Status);
Assert.NotNull(await sessions.ResolveAsync(credential));
sessions.FailRevokeForInvitationWhen = null;
Assert.True(await invitations.RevokeAsync(TenantId, task.Id, invitationId, afterFailure.Revision, ManagerActor()));
Assert.Equal(UserTaskInvitationStatus.Revoked, Invitation(await GetAsync(task.Id), invitationId).Status);
Assert.Null(await sessions.ResolveAsync(credential));
}
[ConformanceFact]
public async Task AFailureInThePostCommitSweepIsRepairedIdempotentlyByARetry()
{
await ActivateAsync();
var (task, invitationId, credential, sessions) = await IssueGuestSessionAsync();
var invitations = CreateInvitationService(Repository, sessions);
sessions.ResetCounters();
// Let the pre-commit sweep run and fail the one after the commit, so the aggregate reads Revoked
// while a session issued in the commit window could still be live.
sessions.FailRevokeForInvitationWhen = ordinal => ordinal == 2;
var current = await GetAsync(task.Id);
await Assert.ThrowsAsync<InjectedStoreFaultException>(() =>
invitations.RevokeAsync(TenantId, task.Id, invitationId, current.Revision, ManagerActor()));
Assert.Equal(UserTaskInvitationStatus.Revoked, Invitation(await GetAsync(task.Id), invitationId).Status);
// The retry finds an already-revoked invitation. It must report success and sweep again rather than
// reporting a failure the caller cannot act on and leaving the credential behind.
sessions.FailRevokeForInvitationWhen = null;
var sweepsBefore = sessions.RevokeForInvitationCallCount;
var afterFailure = await GetAsync(task.Id);
Assert.True(await invitations.RevokeAsync(TenantId, task.Id, invitationId, afterFailure.Revision, ManagerActor()));
Assert.True(sessions.RevokeForInvitationCallCount > sweepsBefore);
Assert.Null(await sessions.ResolveAsync(credential));
}
[ConformanceFact]
public async Task ASuccessfulRevocationSweepsSessionsOnBothSidesOfTheCommit()
{
await ActivateAsync();
var (task, invitationId, credential, sessions) = await IssueGuestSessionAsync();
var invitations = CreateInvitationService(Repository, sessions);
sessions.ResetCounters();
var current = await GetAsync(task.Id);
Assert.True(await invitations.RevokeAsync(TenantId, task.Id, invitationId, current.Revision, ManagerActor()));
// Both sweeps are load-bearing: the first keeps a store failure from committing, the second catches
// a session a concurrent verification issued between the first sweep and the commit.
Assert.Equal(2, sessions.RevokeForInvitationCallCount);
Assert.Null(await sessions.ResolveAsync(credential));
}
[ConformanceFact]
public async Task ACredentialIssuedInsideTheRevokeCommitWindowIsDeadEitherWay()
{
await ActivateAsync();
var task = await ProjectAsync(CreateTask(Subject()));
var sessions = new FaultingGuestSessionIssuer(Fixture.GuestSessions);
var dispatcher = new CapturingDispatcher();
var invitations = CreateInvitationService(Repository, sessions);
var issued = await invitations.IssueAsync(TenantId, task.Id, new(task.Revision, "bearer", ["Complete"]), ManagerActor());
Assert.NotNull(issued);
await DrainOutboxAsync(dispatcher);
// Revoke from inside IssueAsync, so the revocation runs after the guest session lands in the store
// but before verification re-reads the settled invitation state.
sessions.AfterIssue = async () =>
{
var current = await GetAsync(task.Id);
await invitations.RevokeAsync(TenantId, task.Id, issued!.Invitation.Id, current.Revision, ManagerActor());
};
var verified = await invitations.VerifyAsync(new(dispatcher.Token!));
// Whichever side wins, no live credential may survive a successful revoke.
Assert.False(verified.Succeeded);
Assert.Equal("invitation-unavailable", verified.FailureCode);
Assert.Null(verified.SessionToken);
}
[ConformanceFact]
public async Task AFailedSaveCommitsNothingAndTheRetrySucceedsOnTheSameRevision()
{
await ActivateAsync();
var task = await ProjectAsync(CreateTask(Subject()));
var faulting = new FaultingUserTaskRepository(Repository) { FailSaveCalls = 1 };
var attempt = await GetAsync(task.Id);
attempt.Priority = 77;
await Assert.ThrowsAsync<InjectedStoreFaultException>(() => faulting.SaveAsync(attempt, task.Revision));
// A store that fails must leave the aggregate exactly as it was, revision included, or the retry
// the caller is about to make would come back as a conflict it cannot explain.
var afterFailure = await GetAsync(task.Id);
Assert.Equal(task.Priority, afterFailure.Priority);
Assert.Equal(task.Revision, afterFailure.Revision);
await faulting.SaveAsync(attempt, task.Revision);
Assert.Equal(2, faulting.SaveCallCount);
Assert.Equal(77, (await GetAsync(task.Id)).Priority);
}
[ConformanceFact]
public async Task AFailedCompareAndSwapCommitsNothingAndTheRetryConverges()
{
await ActivateAsync();
var task = await ProjectAsync(CreateTask(Subject()));
var faulting = new FaultingUserTaskRepository(Repository) { FailTryMutateCalls = 1 };
var mutation = (UserTask current) =>
{
current.Status = UserTaskStatus.Cancelled;
return true;
};
await Assert.ThrowsAsync<InjectedStoreFaultException>(() => faulting.TryMutateAsync(TenantId, task.Id, task.Revision, mutation));
Assert.Equal(task.Status, (await GetAsync(task.Id)).Status);
Assert.True(await faulting.TryMutateAsync(TenantId, task.Id, task.Revision, mutation));
Assert.Equal(2, faulting.TryMutateCallCount);
Assert.Equal(UserTaskStatus.Cancelled, (await GetAsync(task.Id)).Status);
}
[ConformanceFact]
public async Task AnAuditWriteFailureDoesNotConsumeTheCallersRevision()
{
await ActivateAsync();
var task = await ProjectAsync(CreateTask(Subject()));
var faulting = new FaultingUserTaskRepository(Repository) { FailAppendEventCalls = 1 };
await Assert.ThrowsAsync<InjectedStoreFaultException>(() =>
faulting.AppendEventAsync(TenantId, task.Id, new($"event-{Guid.NewGuid():N}", TenantId, task.Id, task.Revision, "Viewed", Clock.UtcNow)));
// The command the caller was already holding a revision for still commits.
var held = await GetAsync(task.Id);
held.Priority = 33;
await Repository.SaveAsync(held, task.Revision);
Assert.Equal(33, (await GetAsync(task.Id)).Priority);
}
private async Task<(UserTask Task, string InvitationId, string Credential, FaultingGuestSessionIssuer Sessions)> IssueGuestSessionAsync()
{
var task = await ProjectAsync(CreateTask(Subject()));
var sessions = new FaultingGuestSessionIssuer(Fixture.GuestSessions);
var dispatcher = new CapturingDispatcher();
var invitations = CreateInvitationService(Repository, sessions);
var issued = await invitations.IssueAsync(TenantId, task.Id, new(task.Revision, "bearer", ["Complete"]), ManagerActor())
?? throw new InvalidOperationException("The invitation could not be issued.");
await DrainOutboxAsync(dispatcher);
var verified = await invitations.VerifyAsync(new(dispatcher.Token!));
if (!verified.Succeeded)
throw new InvalidOperationException($"The invitation could not be verified: {verified.FailureCode}.");
return (await GetAsync(task.Id), issued.Invitation.Id, verified.SessionToken!, sessions);
}
private async Task DrainOutboxAsync(CapturingDispatcher dispatcher)
{
foreach (var delivery in await Fixture.Outbox.DequeueDueAsync(100))
{
await dispatcher.DispatchAsync(delivery);
await Fixture.Outbox.CompleteAsync(delivery.Id);
}
}
private static UserTaskInvitation Invitation(UserTask task, string invitationId) =>
task.Invitations.Single(x => x.Id == invitationId);
private DefaultUserTaskManager CreateManager(IUserTaskRepository repository) =>
new(repository, _policy, [], new NoOpResumer(), _sink, _identity, Clock, Fixture.Options);
private DefaultUserTaskInvitationService CreateInvitationService(IUserTaskRepository repository, IUserTaskGuestSessionIssuer sessions) =>
new(repository, _policy, Fixture.Outbox, new DefaultUserTaskInvitationVerifier(), sessions, _sink, _identity, Clock, Fixture.Options);
private UserTaskActor Actor(string id = "user-1") => new(Subject(id), [])
{
Permissions = new HashSet<string>(["read:user-tasks", "claim:user-tasks", "complete:user-tasks"], StringComparer.OrdinalIgnoreCase)
};
private UserTaskActor ManagerActor() => Actor("manager-1") with
{
IsManager = true,
Permissions = new HashSet<string>([
"read:user-tasks", "claim:user-tasks", "complete:user-tasks", "assign:user-tasks",
"update:user-tasks", "cancel:user-tasks", "invite:user-tasks", "manage:user-tasks"
], StringComparer.OrdinalIgnoreCase)
};
private sealed class TestIdentityGenerator : IIdentityGenerator
{
private int _counter;
public string GenerateId() => $"id-{Interlocked.Increment(ref _counter)}-{Guid.NewGuid():N}";
}
private sealed class NoOpResumer : IUserTaskWorkflowResumer
{
public Task ResumeAsync(UserTask task, UserTaskStimulus stimulus, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
private sealed class TestSink : IUserTaskNotificationSink
{
public Task PublishAsync(UserTaskLifecycleNotification notification, CancellationToken cancellationToken = default) => Task.CompletedTask;
}
private sealed class CapturingDispatcher : IUserTaskInvitationDispatcher
{
public string? Token { get; private set; }
public Task DispatchAsync(UserTaskInvitationDelivery delivery, CancellationToken cancellationToken = default)
{
Token = delivery.Token;
return Task.CompletedTask;
}
}
}

View file

@ -0,0 +1,158 @@
using Elsa.UserTasks.Contracts;
using Elsa.UserTasks.Models;
using Elsa.UserTasks.Persistence.ConformanceTests.Infrastructure;
using Elsa.UserTasks.Persistence.ConformanceTests.Providers;
namespace Elsa.UserTasks.Persistence.ConformanceTests;
/// <summary>
/// The behaviour every <see cref="IUserTaskGuestSessionIssuer"/> owes its callers. A guest credential is a
/// bearer secret with no identity behind it, so "revoked" has to mean revoked in every store, immediately.
/// </summary>
public abstract class UserTaskGuestSessionConformanceTests(UserTaskStoreFixture fixture) : UserTaskConformanceTestBase(fixture)
{
private IUserTaskGuestSessionIssuer Sessions => Fixture.GuestSessions;
[ConformanceFact]
public async Task AnIssuedCredentialResolvesToItsInvitationsSubjectAndActions()
{
await ActivateAsync();
var subject = Subject("guest-1");
var issued = await IssueAsync(Invitation(), subject);
var session = await Sessions.ResolveAsync(issued.Token!);
Assert.True(issued.Succeeded);
Assert.NotNull(session);
Assert.Equal(TenantId, session!.TenantId);
Assert.Equal("Complete", Assert.Single(session.AllowedActions));
Assert.True(subject.Matches(session.Subject));
}
[ConformanceFact]
public async Task AnUnknownOrEmptyCredentialResolvesToNothing()
{
await ActivateAsync();
await IssueAsync(Invitation(), Subject("guest-1"));
Assert.Null(await Sessions.ResolveAsync($"not-a-credential-{Guid.NewGuid():N}"));
Assert.Null(await Sessions.ResolveAsync(""));
Assert.Null(await Sessions.ResolveAsync(" "));
}
[ConformanceFact]
public async Task RevokingForATaskKillsEveryCredentialIssuedForIt()
{
await ActivateAsync();
var taskId = $"task-{Guid.NewGuid():N}";
var first = await IssueAsync(Invitation(taskId: taskId, id: "invitation-a"), Subject("guest-1"));
var second = await IssueAsync(Invitation(taskId: taskId, id: "invitation-b"), Subject("guest-2"));
await Sessions.RevokeForTaskAsync(TenantId, taskId);
Assert.Null(await Sessions.ResolveAsync(first.Token!));
Assert.Null(await Sessions.ResolveAsync(second.Token!));
}
[ConformanceFact]
public async Task RevokingForOneInvitationLeavesAnotherInvitationsSessionAlive()
{
await ActivateAsync();
var taskId = $"task-{Guid.NewGuid():N}";
var revoked = await IssueAsync(Invitation(taskId: taskId, id: "invitation-a"), Subject("guest-1"));
var survivor = await IssueAsync(Invitation(taskId: taskId, id: "invitation-b"), Subject("guest-2"));
await Sessions.RevokeForInvitationAsync(TenantId, "invitation-a");
// Scoped revocation is the whole point: withdrawing one guest link must not sign the other guest
// out, and must not leave the withdrawn one usable either.
Assert.Null(await Sessions.ResolveAsync(revoked.Token!));
Assert.NotNull(await Sessions.ResolveAsync(survivor.Token!));
}
[ConformanceFact]
public async Task RevocationIsScopedByTenant()
{
await ActivateAsync();
var issued = await IssueAsync(Invitation(id: "invitation-a"), Subject("guest-1"));
await Sessions.RevokeForInvitationAsync("other-tenant", "invitation-a");
Assert.NotNull(await Sessions.ResolveAsync(issued.Token!));
await Sessions.RevokeForTaskAsync("other-tenant", "task-1");
Assert.NotNull(await Sessions.ResolveAsync(issued.Token!));
}
[ConformanceFact]
public async Task RevokingTwiceIsHarmless()
{
await ActivateAsync();
var issued = await IssueAsync(Invitation(id: "invitation-a"), Subject("guest-1"));
await Sessions.RevokeForInvitationAsync(TenantId, "invitation-a");
await Sessions.RevokeForInvitationAsync(TenantId, "invitation-a");
Assert.Null(await Sessions.ResolveAsync(issued.Token!));
}
[ConformanceFact]
public async Task ASessionStopsResolvingOnceItExpiresWithoutAnExplicitRevoke()
{
await ActivateAsync();
Fixture.Settings.GuestSessionLifetime = TimeSpan.FromMinutes(30);
var issued = await IssueAsync(Invitation(expiresAt: Clock.UtcNow.AddDays(1)), Subject("guest-1"));
Assert.NotNull(await Sessions.ResolveAsync(issued.Token!));
Clock.Advance(TimeSpan.FromMinutes(31));
Assert.Null(await Sessions.ResolveAsync(issued.Token!));
}
[ConformanceFact]
public async Task ASessionNeverOutlivesTheInvitationItCameFrom()
{
await ActivateAsync();
Fixture.Settings.GuestSessionLifetime = TimeSpan.FromDays(7);
var invitationExpiry = Clock.UtcNow.AddMinutes(10);
var issued = await IssueAsync(Invitation(expiresAt: invitationExpiry), Subject("guest-1"));
Assert.Equal(invitationExpiry, issued.ExpiresAt);
Clock.Advance(TimeSpan.FromMinutes(11));
Assert.Null(await Sessions.ResolveAsync(issued.Token!));
}
[ConformanceFact]
public async Task AnAlreadyExpiredInvitationIssuesNothingAtAll()
{
await ActivateAsync();
var issued = await IssueAsync(Invitation(expiresAt: Clock.UtcNow.AddMinutes(-1)), Subject("guest-1"));
Assert.False(issued.Succeeded);
Assert.Null(issued.Token);
Assert.Equal("session-unavailable", issued.FailureCode);
}
[ConformanceFact]
public async Task TheRawCredentialIsNeverRecoverableFromTheStore()
{
await ActivateAsync();
var issued = await IssueAsync(Invitation(), Subject("guest-1"));
var session = await Sessions.ResolveAsync(issued.Token!);
// The credential is a bearer secret: the store keeps a hash, so nothing it exposes can be replayed.
Assert.NotNull(session);
Assert.DoesNotContain(issued.Token!, System.Text.Json.JsonSerializer.Serialize(session), StringComparison.Ordinal);
}
private Task<GuestSessionResult> IssueAsync(UserTaskInvitation invitation, ParticipantReference subject) =>
Sessions.IssueAsync(invitation, subject);
private UserTaskInvitation Invitation(string? taskId = null, string id = "invitation-1", DateTimeOffset? expiresAt = null) =>
new(id, TenantId, taskId ?? $"task-{Guid.NewGuid():N}", "guest@example.com", $"HASH-{Guid.NewGuid():N}",
UserTaskInvitationStatus.Consumed, Clock.UtcNow, expiresAt ?? Clock.UtcNow.AddDays(1), "bearer")
{
AllowedActions = ["Complete"]
};
}

View file

@ -0,0 +1,155 @@
using Elsa.UserTasks.Contracts;
using Elsa.UserTasks.Models;
using Elsa.UserTasks.Persistence.ConformanceTests.Infrastructure;
using Elsa.UserTasks.Persistence.ConformanceTests.Providers;
namespace Elsa.UserTasks.Persistence.ConformanceTests;
/// <summary>
/// The behaviour every <see cref="IUserTaskInvitationOutbox"/> owes its callers. The outbox holds the only
/// copy of an invitation secret between issuance and delivery, so "delivered late" and "retried forever"
/// are both security outcomes, not just reliability ones.
/// </summary>
public abstract class UserTaskInvitationOutboxConformanceTests(UserTaskStoreFixture fixture) : UserTaskConformanceTestBase(fixture)
{
private readonly HashSet<string> _mine = new(StringComparer.Ordinal);
private IUserTaskInvitationOutbox Outbox => Fixture.Outbox;
[ConformanceFact]
public async Task ADeliveryRoundTripsItsSecretAndItsRoutingMetadata()
{
await ActivateAsync();
var delivery = Delivery(token: "s3cret-token", recipient: "guest@example.com");
await Outbox.EnqueueAsync(delivery);
var dequeued = Assert.Single(await DequeueMineAsync());
Assert.Equal(delivery.Id, dequeued.Id);
Assert.Equal("s3cret-token", dequeued.Token);
Assert.Equal("guest@example.com", dequeued.Recipient);
Assert.Equal(delivery.TaskId, dequeued.TaskId);
Assert.Equal(delivery.InvitationId, dequeued.InvitationId);
Assert.Equal(delivery.DispatcherName, dequeued.DispatcherName);
}
[ConformanceFact]
public async Task ACompletedDeliveryIsRemovedSoTheSecretStopsExisting()
{
await ActivateAsync();
var delivery = Delivery();
await Outbox.EnqueueAsync(delivery);
await Outbox.CompleteAsync(delivery.Id);
Assert.Empty(await DequeueMineAsync());
}
[ConformanceFact]
public async Task ADeliveryIsNotDueBeforeItsScheduledTime()
{
await ActivateAsync();
var delivery = Delivery(notBefore: Clock.UtcNow.AddMinutes(10));
await Outbox.EnqueueAsync(delivery);
Assert.Empty(await DequeueMineAsync());
Clock.Advance(TimeSpan.FromMinutes(11));
Assert.Single(await DequeueMineAsync());
}
[ConformanceFact]
public async Task ReschedulingAdvancesTheAttemptCountAndDefersTheDelivery()
{
await ActivateAsync();
Fixture.Settings.InvitationDeliveryRetryDelays = [TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(5)];
var delivery = Delivery();
await Outbox.EnqueueAsync(delivery);
await Outbox.RescheduleAsync(delivery.Id, Clock.UtcNow.AddMinutes(1));
Assert.Empty(await DequeueMineAsync());
Clock.Advance(TimeSpan.FromMinutes(2));
var retried = Assert.Single(await DequeueMineAsync());
Assert.Equal(1, retried.Attempt);
}
[ConformanceFact]
public async Task DeliveryIsAbandonedOnceTheRetryScheduleIsExhausted()
{
await ActivateAsync();
Fixture.Settings.InvitationDeliveryRetryDelays = [TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(5)];
var delivery = Delivery();
await Outbox.EnqueueAsync(delivery);
// One reschedule per configured delay is still retryable; the one past the end abandons.
foreach (var _ in Fixture.Settings.InvitationDeliveryRetryDelays)
await Outbox.RescheduleAsync(delivery.Id, Clock.UtcNow);
Assert.Single(await DequeueMineAsync());
await Outbox.RescheduleAsync(delivery.Id, Clock.UtcNow);
// An undeliverable secret expires rather than being retried forever; a manager reissues instead.
Assert.Empty(await DequeueMineAsync());
}
[ConformanceFact]
public async Task ReschedulingAnUnknownDeliveryIsHarmless()
{
await ActivateAsync();
await Outbox.RescheduleAsync($"delivery-{Guid.NewGuid():N}", Clock.UtcNow);
Assert.Empty(await DequeueMineAsync());
}
[ConformanceFact]
public async Task AnExpiredDeliveryIsDroppedRatherThanDeliveredLate()
{
await ActivateAsync();
var delivery = Delivery(expiresAt: Clock.UtcNow.AddMinutes(5));
await Outbox.EnqueueAsync(delivery);
Clock.Advance(TimeSpan.FromMinutes(6));
Assert.Empty(await DequeueMineAsync());
// And it stays gone: a later sweep must not resurrect a secret whose invitation has expired.
Clock.Advance(TimeSpan.FromMinutes(-6));
Assert.Empty(await DequeueMineAsync());
}
[ConformanceFact]
public async Task TheDueBatchIsBoundedByTheRequestedCount()
{
await ActivateAsync();
foreach (var _ in Enumerable.Range(0, 3))
await Outbox.EnqueueAsync(Delivery());
var batch = await Outbox.DequeueDueAsync(1);
Assert.Single(batch);
}
/// <summary>
/// Dequeues and keeps only this test's own entries. <c>DequeueDueAsync</c> is deliberately not
/// tenant-scoped — the worker drains the whole host — so filtering here is what isolates the test.
/// </summary>
private async Task<IReadOnlyList<UserTaskInvitationDelivery>> DequeueMineAsync() =>
(await Outbox.DequeueDueAsync(500)).Where(x => _mine.Contains(x.Id)).ToList();
private UserTaskInvitationDelivery Delivery(
string token = "invitation-token",
string? recipient = null,
DateTimeOffset? expiresAt = null,
DateTimeOffset? notBefore = null)
{
var id = $"delivery-{Guid.NewGuid():N}";
_mine.Add(id);
return new(id, TenantId, $"task-{Guid.NewGuid():N}", $"invitation-{Guid.NewGuid():N}", "bearer", token,
expiresAt ?? Clock.UtcNow.AddDays(1))
{
Recipient = recipient,
NotBefore = notBefore
};
}
}

View file

@ -0,0 +1,328 @@
using Elsa.UserTasks.Contracts;
using Elsa.UserTasks.Models;
using Elsa.UserTasks.Persistence.ConformanceTests.Infrastructure;
using Elsa.UserTasks.Persistence.ConformanceTests.Providers;
namespace Elsa.UserTasks.Persistence.ConformanceTests;
/// <summary>
/// The behaviour every <see cref="IUserTaskRepository"/> owes its callers, run unchanged against each
/// provider. The contract is what callers depend on; a provider that satisfies it only in memory is a
/// provider that fails in production, which is exactly how the revision-conflict defect reached review.
/// </summary>
public abstract class UserTaskRepositoryConformanceTests(UserTaskStoreFixture fixture) : UserTaskConformanceTestBase(fixture)
{
[ConformanceFact]
public async Task AStaleSaveThrowsTheContractsConflictNotTheStoresNativeConcurrencyType()
{
await ActivateAsync();
var task = await ProjectAsync(CreateTask(Subject()));
var first = await GetAsync(task.Id);
var second = await GetAsync(task.Id);
first.Priority = 90;
await Repository.SaveAsync(first, first.Revision);
second.Priority = 10;
// Assert.ThrowsAsync matches the exact type, so this also pins that the store's own concurrency
// exception does not escape: DbUpdateConcurrencyException and DocumentStoreConcurrencyException
// would both fail here, which is precisely the defect that shipped a 500 instead of a 409.
var conflict = await Assert.ThrowsAsync<UserTaskRevisionConflictException>(() => Repository.SaveAsync(second, second.Revision));
Assert.Equal(task.Id, conflict.TaskId);
Assert.Equal(second.Revision, conflict.ExpectedRevision);
}
[ConformanceFact]
public async Task TwoWritersOnSeparateConnectionsLeaveExactlyOneWinnerAndOneConflict()
{
await ActivateAsync();
var task = await ProjectAsync(CreateTask(Subject()));
var other = Fixture.CreateSecondRepository();
var mine = await GetAsync(task.Id);
var theirs = await other.GetAsync(TenantId, task.Id) ?? throw new InvalidOperationException("The second connection could not read the task.");
mine.Status = UserTaskStatus.Assigned;
theirs.Status = UserTaskStatus.Cancelled;
await Repository.SaveAsync(mine, mine.Revision);
await Assert.ThrowsAsync<UserTaskRevisionConflictException>(() => other.SaveAsync(theirs, theirs.Revision));
var settled = await GetAsync(task.Id);
Assert.Equal(UserTaskStatus.Assigned, settled.Status);
Assert.Equal(task.Revision + 1, settled.Revision);
}
[ConformanceFact]
public async Task TryMutateReturnsFalseOnALostRaceRatherThanThrowing()
{
await ActivateAsync();
var task = await ProjectAsync(CreateTask(Subject()));
var staleRevision = task.Revision;
var winner = await GetAsync(task.Id);
winner.Priority = 1;
await Repository.SaveAsync(winner, winner.Revision);
var mutated = await Repository.TryMutateAsync(TenantId, task.Id, staleRevision, current =>
{
current.Priority = 99;
return true;
});
Assert.False(mutated);
Assert.Equal(1, (await GetAsync(task.Id)).Priority);
}
[ConformanceFact]
public async Task TryMutateCommitsNothingWhenTheMutationDeclines()
{
await ActivateAsync();
var task = await ProjectAsync(CreateTask(Subject()));
var mutated = await Repository.TryMutateAsync(TenantId, task.Id, task.Revision, current =>
{
current.Priority = 7;
return false;
});
var settled = await GetAsync(task.Id);
Assert.False(mutated);
Assert.Equal(task.Priority, settled.Priority);
// A declined mutation must not consume the revision either, or the caller's next command would
// fail with a conflict it has no way to explain.
Assert.Equal(task.Revision, settled.Revision);
}
[ConformanceFact]
public async Task AppendEventDoesNotConsumeTheConcurrencyToken()
{
await ActivateAsync();
var task = await ProjectAsync(CreateTask(Subject()));
var revision = task.Revision;
// Several entries deliberately share one revision: audit is append-only and an audited read must
// not invalidate an expected revision a client is already holding.
await Repository.AppendEventAsync(TenantId, task.Id, Event(task, revision, "Viewed", "event-a"));
await Repository.AppendEventAsync(TenantId, task.Id, Event(task, revision, "FieldRevealed", "event-b"));
var audited = await GetAsync(task.Id);
Assert.Equal(revision, audited.Revision);
Assert.Equal(2, audited.Events.Count(x => x.Revision == revision));
// The revision the caller was already holding still commits.
audited.Priority = 42;
await Repository.SaveAsync(audited, revision);
Assert.Equal(42, (await GetAsync(task.Id)).Priority);
}
[ConformanceFact]
public async Task AppendEventIgnoresAnUnknownTask()
{
await ActivateAsync();
var task = CreateTask(Subject());
// Never projected. Auditing something that no longer exists is a lost race, not a fault.
await Repository.AppendEventAsync(TenantId, task.Id, Event(task, 1, "Viewed", "event-orphan"));
Assert.Null(await Repository.GetAsync(TenantId, task.Id));
}
[ConformanceFact]
public async Task AddProjectionIsIdempotentOnTheMaterializationKey()
{
await ActivateAsync();
var task = CreateTask(Subject());
await Repository.AddProjectionAsync(task);
// A redelivered bookmark commit replays the same materialization key under a different task id.
var replay = CreateTask(Subject());
replay.MaterializationKey = task.MaterializationKey;
await Repository.AddProjectionAsync(replay);
var all = await Repository.QueryAsync(Query(UserTaskQueryScopeKind.Available, includeTotalCount: true));
Assert.Equal(1, all.TotalCount);
Assert.Equal(task.Id, Assert.Single(all.Items).Id);
Assert.Null(await Repository.GetAsync(TenantId, replay.Id));
}
[ConformanceFact]
public async Task LookupsByMaterializationKeyAndBookmarkAreTenantScoped()
{
await ActivateAsync();
var task = await ProjectAsync(CreateTask(Subject()));
Assert.Equal(task.Id, (await Repository.FindByMaterializationKeyAsync(TenantId, task.MaterializationKey))?.Id);
Assert.Equal(task.Id, (await Repository.FindByBookmarkIdAsync(TenantId, task.BookmarkId))?.Id);
Assert.Null(await Repository.FindByMaterializationKeyAsync("other-tenant", task.MaterializationKey));
Assert.Null(await Repository.FindByBookmarkIdAsync("other-tenant", task.BookmarkId));
Assert.Null(await Repository.GetAsync("other-tenant", task.Id));
}
[ConformanceFact]
public async Task InvitationLookupResolvesFromATokenHashAloneAndReturnsNullForUnknown()
{
await ActivateAsync();
var task = CreateTask(Subject());
var tokenHash = $"HASH-{Guid.NewGuid():N}";
task.Invitations.Add(new("invitation-1", TenantId, task.Id, "guest@example.com", tokenHash,
UserTaskInvitationStatus.Pending, Clock.UtcNow, Clock.UtcNow.AddDays(1), "bearer")
{
AllowedActions = ["Complete"]
});
await Repository.AddProjectionAsync(task);
// Deliberately tenant-agnostic: an anonymous holder presents only a secret and must never be
// trusted to name its own tenant.
var match = await Repository.FindByInvitationTokenHashAsync(tokenHash);
Assert.NotNull(match);
var resolved = match!.Value;
Assert.Equal(task.Id, resolved.Task.Id);
Assert.Equal(TenantId, resolved.Task.TenantId);
Assert.Equal("Complete", Assert.Single(resolved.Invitation.AllowedActions));
Assert.Null(await Repository.FindByInvitationTokenHashAsync($"HASH-UNKNOWN-{Guid.NewGuid():N}"));
}
[ConformanceFact]
public async Task ScopeAndExclusionApplyBeforeTotalsCursorsAndPageLimits()
{
await ActivateAsync();
var subject = Subject();
var visible = await ProjectAsync(CreateTask(subject, title: "Visible one"));
var alsoVisible = await ProjectAsync(CreateTask(subject, title: "Visible two"));
var excluded = CreateTask(subject, title: "Excluded");
excluded.ExcludedUsers = [subject];
await Repository.AddProjectionAsync(excluded);
var foreign = CreateTask(Subject("someone-else"), title: "Not a candidate");
await Repository.AddProjectionAsync(foreign);
var page = await Repository.QueryAsync(Query(includeTotalCount: true));
// The unauthorized rows are absent from the total, not merely hidden on the page. A count that
// includes them leaks their existence and pushes authorized rows off the last page.
Assert.Equal(2, page.TotalCount);
Assert.Equal([visible.Id, alsoVisible.Id], page.Items.Select(x => x.Id).Order(StringComparer.Ordinal));
// The same must hold once a page limit forces a cursor: the excluded rows cannot occupy a slot.
var paged = await PageThroughAsync(Query(), pageSize: 1);
Assert.Equal([visible.Id, alsoVisible.Id], paged.Order(StringComparer.Ordinal));
}
[ConformanceFact]
public async Task AScopeFromAnotherTenantMatchesNothingEvenWhenTheQueryNamesThisOne()
{
await ActivateAsync();
await ProjectAsync(CreateTask(Subject()));
var crossTenant = Query() with
{
Scope = new("other-tenant", Subject() with { TenantId = "other-tenant" }, [], Kind: UserTaskQueryScopeKind.Available),
IncludeTotalCount = true
};
var result = await Repository.QueryAsync(crossTenant);
Assert.Empty(result.Items);
Assert.Equal(0, result.TotalCount);
}
[ConformanceTheory]
[InlineData("created", false)]
[InlineData("created", true)]
[InlineData("due", false)]
[InlineData("due", true)]
[InlineData("priority", false)]
[InlineData("priority", true)]
[InlineData("title", false)]
[InlineData("title", true)]
public async Task CursorsAreStableAcrossEverySupportedSortAndDirection(string sort, bool descending)
{
await ActivateAsync();
await SeedSortableTasksAsync();
var query = Query(sort: sort, descending: descending);
var unpaged = await Repository.QueryAsync(query with { Limit = 200 });
var expected = unpaged.Items.Select(x => x.Id).ToList();
// Paging must reproduce the unpaged order exactly: no row seen twice, none skipped, and no
// dependence on the page size. A cursor that only works at one limit is not a cursor.
foreach (var pageSize in new[] { 1, 2, 3 })
Assert.Equal(expected, await PageThroughAsync(query, pageSize));
}
[ConformanceFact]
public async Task TasksWithoutADueDateOrderLastInBothDirections()
{
await ActivateAsync();
await SeedSortableTasksAsync();
foreach (var descending in new[] { false, true })
{
var page = await Repository.QueryAsync(Query(sort: "due", descending: descending, limit: 200));
var dueDates = page.Items.Select(x => x.DueAt).ToList();
var firstNull = dueDates.FindIndex(x => x is null);
Assert.NotEqual(-1, firstNull);
// Once the nulls start they must not be interrupted, in either direction. A generic numeric
// comparison reorders them and the cursor then drops or repeats rows at the boundary.
Assert.All(dueDates.Skip(firstNull), x => Assert.Null(x));
}
}
[ConformanceFact]
public async Task ThePageLimitIsHonouredAndTheFinalPageReportsNoCursor()
{
await ActivateAsync();
await SeedSortableTasksAsync();
var first = await Repository.QueryAsync(Query(limit: 2));
Assert.Equal(2, first.Items.Count);
Assert.NotNull(first.NextCursor);
var last = await Repository.QueryAsync(Query(limit: 200));
Assert.Equal(SortableTaskCount, last.Items.Count);
// A cursor on a page that already returned everything sends the caller round again for nothing.
Assert.Null(last.NextCursor);
}
[ConformanceFact]
public async Task AnUnreadableCursorIsIgnoredRatherThanFailingTheRequest()
{
await ActivateAsync();
await SeedSortableTasksAsync();
var result = await Repository.QueryAsync(Query(limit: 200, cursor: "not-a-cursor"));
Assert.Equal(SortableTaskCount, result.Items.Count);
}
private const int SortableTaskCount = 6;
/// <summary>
/// Seeds a set that exercises every sort key at once: distinct titles, distinct priorities, a mix of
/// present and absent due dates, and two rows sharing a due date so the identity tiebreaker is used.
/// </summary>
private async Task SeedSortableTasksAsync()
{
var subject = Subject();
var baseline = Clock.UtcNow;
var shared = baseline.AddDays(3);
UserTask[] tasks =
[
CreateTask(subject, "Alpha", priority: 10, dueAt: baseline.AddDays(1)),
CreateTask(subject, "Bravo", priority: 90, dueAt: shared),
CreateTask(subject, "Charlie", priority: 50, dueAt: shared),
CreateTask(subject, "Delta", priority: 30, dueAt: baseline.AddDays(5)),
CreateTask(subject, "Echo", priority: 70, dueAt: null),
CreateTask(subject, "Foxtrot", priority: 20, dueAt: null)
];
foreach (var task in tasks)
await Repository.AddProjectionAsync(task);
}
private UserTaskEvent Event(UserTask task, int revision, string type, string id) =>
new($"{id}-{Guid.NewGuid():N}", TenantId, task.Id, revision, type, Clock.UtcNow);
}