Compare commits

...
5 changed files with 688 additions and 22 deletions
+9
View File
@@ -360,6 +360,15 @@ public interface IMyService
```
- Update ALL internal callers to use the new API - no internal code should use obsolete members
### 5.5 Public Enums — Append New Members
Enum members get implicit, sequential underlying values (0, 1, 2, …). Inserting a new member in the middle — or before a trailing value like `NotImplemented`/`Unknown` — silently shifts the numeric value of every later member. For a `public` enum this is a **breaking change**: consumers may persist the integer value (database, distributed cache, serialized payloads, API contracts) and would then read back the wrong member.
**Rules**:
- Add new members at the **end** of a public enum so existing values stay stable. Don't reorder existing members.
- If a meaningful grouping requires a particular declaration order, assign **explicit numeric values** instead, so future additions can't shift them.
- This applies to all public enums, including the `*OperationStatus` enums in `Umbraco.Core/Services/OperationStatus`.
---
## 6. Project-Specific Notes
@@ -23,6 +23,7 @@ internal sealed class ExternalMemberService : RepositoryService, IExternalMember
private readonly IExternalMemberRepository _repository;
private readonly IMemberService _memberService;
private readonly IMemberGroupService _memberGroupService;
private readonly IMemberTypeService _memberTypeService;
private readonly IExternalLoginWithKeyRepository _externalLoginRepository;
private readonly IOptionsMonitor<SecuritySettings> _securitySettings;
private readonly ILogger<ExternalMemberService> _logger;
@@ -37,6 +38,7 @@ internal sealed class ExternalMemberService : RepositoryService, IExternalMember
IExternalMemberRepository repository,
IMemberService memberService,
IMemberGroupService memberGroupService,
IMemberTypeService memberTypeService,
IExternalLoginWithKeyRepository externalLoginRepository,
IOptionsMonitor<SecuritySettings> securitySettings)
: base(provider, loggerFactory, eventMessagesFactory)
@@ -44,6 +46,7 @@ internal sealed class ExternalMemberService : RepositoryService, IExternalMember
_repository = repository;
_memberService = memberService;
_memberGroupService = memberGroupService;
_memberTypeService = memberTypeService;
_externalLoginRepository = externalLoginRepository;
_securitySettings = securitySettings;
_logger = loggerFactory.CreateLogger<ExternalMemberService>();
@@ -319,20 +322,56 @@ internal sealed class ExternalMemberService : RepositoryService, IExternalMember
return Attempt.SucceedWithStatus<ExternalMemberIdentity?, ExternalMemberOperationStatus>(ExternalMemberOperationStatus.Success, member);
}
/// <inheritdoc />
public async Task<ExternalMemberOperationStatus> ValidateConvertToContentMemberAsync(Guid memberKey, string memberTypeAlias)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
(ExternalMemberOperationStatus status, _) = await ValidateConvertToContentMemberInternalAsync(memberKey, memberTypeAlias);
return status;
}
/// <inheritdoc />
public async Task<Attempt<IMember?, ExternalMemberOperationStatus>> ConvertToContentMemberAsync(Guid memberKey, string memberTypeAlias, Action<IMember, string?>? mapProfileData = null)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
// Load the external member.
ExternalMemberIdentity? externalMember = await _repository.GetByKeyAsync(memberKey);
if (externalMember is null)
(ExternalMemberOperationStatus status, ExternalMemberIdentity? externalMember) = await ValidateConvertToContentMemberInternalAsync(memberKey, memberTypeAlias);
if (status != ExternalMemberOperationStatus.Success)
{
scope.Complete();
return Attempt.FailWithStatus<IMember?, ExternalMemberOperationStatus>(ExternalMemberOperationStatus.NotFound, null);
return Attempt.FailWithStatus<IMember?, ExternalMemberOperationStatus>(status, null);
}
// Create the content member entity.
// externalMember is guaranteed non-null when status is Success.
ExternalMemberIdentity source = externalMember!;
IMember contentMember = BuildContentMember(source, memberTypeAlias, mapProfileData);
// Save the content member (this assigns the node ID).
_memberService.Save(contentMember);
// Migrate group memberships: read external roles, assign to content member.
var roleNames = (await _repository.GetRolesAsync(source.Key)).ToArray();
if (roleNames.Length > 0)
{
_memberService.AssignRoles([contentMember.Id], roleNames);
}
// Delete the external record (and its group memberships) and publish the deleted notification so it
// is evicted from caches and the search index. The login links are intentionally preserved — keyed
// by the same Guid, they now belong to the content member — so the full DeleteAsync, which would
// delete them, is not used.
await _repository.DeleteAsync(source.Key);
scope.Notifications.Publish(new ExternalMemberDeletedNotification(source, EventMessagesFactory.Get()));
scope.Complete();
// Re-fetch to get the fully hydrated entity.
IMember? result = _memberService.GetById(contentMember.Key);
return Attempt.SucceedWithStatus(ExternalMemberOperationStatus.Success, result);
}
private IMember BuildContentMember(ExternalMemberIdentity externalMember, string memberTypeAlias, Action<IMember, string?>? mapProfileData)
{
IMember contentMember = _memberService.CreateMember(
externalMember.UserName,
externalMember.Email,
@@ -350,25 +389,226 @@ internal sealed class ExternalMemberService : RepositoryService, IExternalMember
// Allow the caller to map profileData fields to content properties before save.
mapProfileData?.Invoke(contentMember, externalMember.ProfileData);
// Save the content member (this assigns the node ID).
_memberService.Save(contentMember);
return contentMember;
}
// Migrate group memberships: read external roles, assign to content member.
IEnumerable<string> roles = await _repository.GetRolesAsync(externalMember.Key);
var roleNames = roles.ToArray();
if (roleNames.Length > 0)
/// <inheritdoc />
public async Task<ExternalMemberOperationStatus> ValidateConvertToExternalMemberAsync(Guid memberKey, bool requireExternalLogin = true)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
(ExternalMemberOperationStatus status, _) = await ValidateConvertToExternalMemberInternalAsync(memberKey, requireExternalLogin);
return status;
}
/// <inheritdoc />
public async Task<Attempt<ExternalMemberIdentity?, ExternalMemberOperationStatus>> ConvertToExternalMemberAsync(
Guid memberKey,
Action<ExternalMemberIdentity, IMember>? mapProfileData = null,
bool requireExternalLogin = true)
{
static Attempt<ExternalMemberIdentity?, ExternalMemberOperationStatus> Fail(ExternalMemberOperationStatus status)
=> Attempt.FailWithStatus<ExternalMemberIdentity?, ExternalMemberOperationStatus>(status, null);
ExternalMemberIdentity identity;
IReadOnlyCollection<IExternalLogin> capturedLogins;
IReadOnlyCollection<IExternalLoginToken> capturedTokens;
// Scope A — validate, capture state, delete the content member and persist the external identity.
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
_memberService.AssignRoles([contentMember.Id], roleNames);
(ExternalMemberOperationStatus status, IMember? member) = await ValidateConvertToExternalMemberInternalAsync(memberKey, requireExternalLogin);
if (status != ExternalMemberOperationStatus.Success)
{
scope.Complete();
return Fail(status);
}
// Capture group memberships and external login links before the member is deleted.
var roleNames = _memberService.GetAllRoles(member!.Username).ToArray();
capturedLogins = CaptureExternalLogins(memberKey);
capturedTokens = CaptureExternalLoginTokens(memberKey);
identity = BuildExternalIdentity(member, mapProfileData);
EventMessages evtMsgs = EventMessagesFactory.Get();
var savingNotification = new ExternalMemberSavingNotification(identity, evtMsgs);
if (scope.Notifications.PublishCancelable(savingNotification))
{
scope.Complete();
return Fail(ExternalMemberOperationStatus.CancelledByNotification);
}
// Delete the content member. This queues a deferred MemberDeletedNotification which, at the end
// of the outermost scope, deletes the external login links by key — hence the re-link in scope B.
if (_memberService.Delete(member).Success is false)
{
scope.Complete();
return Fail(ExternalMemberOperationStatus.CancelledByNotification);
}
await PersistExternalIdentityAsync(identity, roleNames);
scope.Notifications.Publish(
new ExternalMemberSavedNotification(identity, evtMsgs).WithStateFrom(savingNotification));
scope.Complete();
}
// Delete the external member record and its group memberships.
await _repository.DeleteAsync(externalMember.Key);
// Scope B — re-link the external logins/tokens that scope A's deferred delete handler removed,
// against the preserved member key. Scope A has already committed, so a failure here is a recoverable
// partial state, not a failed conversion (auto-link recreates the link on next sign-in) — log it
// rather than throwing over an otherwise-successful conversion.
try
{
RelinkExternalLogins(memberKey, capturedLogins, capturedTokens);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Converted member {MemberKey} to an external member, but failed to re-link its external logins/tokens. "
+ "The member has no login link until auto-link recreates one on next external sign-in.",
memberKey);
}
return Attempt.SucceedWithStatus<ExternalMemberIdentity?, ExternalMemberOperationStatus>(ExternalMemberOperationStatus.Success, identity);
}
private IReadOnlyCollection<IExternalLogin> CaptureExternalLogins(Guid memberKey)
=> _externalLoginRepository
.Get(Query<IIdentityUserLogin>().Where(x => x.Key == memberKey))
.Select(x => (IExternalLogin)new ExternalLogin(x.LoginProvider, x.ProviderKey, x.UserData))
.ToArray();
private IReadOnlyCollection<IExternalLoginToken> CaptureExternalLoginTokens(Guid memberKey)
=> _externalLoginRepository
.Get(Query<IIdentityUserToken>().Where(x => x.Key == memberKey))
.Select(x => (IExternalLoginToken)new ExternalLoginToken(x.LoginProvider, x.Name, x.Value))
.ToArray();
private static ExternalMemberIdentity BuildExternalIdentity(IMember member, Action<ExternalMemberIdentity, IMember>? mapProfileData)
{
var identity = new ExternalMemberIdentity
{
// Preserve the Guid key so external login links continue to resolve.
Key = member.Key,
Email = member.Email,
UserName = member.Username,
Name = member.Name,
IsApproved = member.IsApproved,
IsLockedOut = member.IsLockedOut,
CreateDate = member.CreateDate,
UpdateDate = DateTime.UtcNow,
// Invalidate active sessions by setting a new security stamp.
SecurityStamp = Guid.NewGuid().ToString(),
};
// Allow the caller to map content properties into the identity (e.g. profile data) before save.
mapProfileData?.Invoke(identity, member);
return identity;
}
private async Task PersistExternalIdentityAsync(ExternalMemberIdentity identity, string[] roleNames)
{
// The content row is deleted within this transaction, so the external store's unique indexes
// (key/username/email) no longer collide.
identity.Id = await _repository.CreateAsync(identity);
if (roleNames.Length > 0)
{
await _repository.AssignRolesAsync(identity.Id, ResolveGroupIds(roleNames));
}
}
private void RelinkExternalLogins(Guid memberKey, IReadOnlyCollection<IExternalLogin> logins, IReadOnlyCollection<IExternalLoginToken> tokens)
{
if (logins.Count == 0 && tokens.Count == 0)
{
return;
}
using ICoreScope scope = ScopeProvider.CreateCoreScope();
if (logins.Count > 0)
{
_externalLoginRepository.Save(memberKey, logins);
}
if (tokens.Count > 0)
{
_externalLoginRepository.Save(memberKey, tokens);
}
scope.Complete();
}
// Re-fetch to get the fully hydrated entity.
IMember? result = _memberService.GetById(contentMember.Key);
return Attempt.SucceedWithStatus(ExternalMemberOperationStatus.Success, result);
private async Task<(ExternalMemberOperationStatus Status, ExternalMemberIdentity? Member)> ValidateConvertToContentMemberInternalAsync(Guid memberKey, string memberTypeAlias)
{
ExternalMemberIdentity? externalMember = await _repository.GetByKeyAsync(memberKey);
if (externalMember is null)
{
return (ExternalMemberOperationStatus.NotFound, null);
}
if (_memberTypeService.Get(memberTypeAlias) is null)
{
return (ExternalMemberOperationStatus.InvalidMemberType, externalMember);
}
// The external member legitimately owns its username/email, so exclude it from the uniqueness
// checks (which span both stores) — only a *different* record clashing is a real conflict. For
// this direction the meaningful clash is a content member that already owns the username/email.
ExternalMemberOperationStatus? uniquenessResult = await ValidateUsernameUniqueAsync(externalMember.UserName, externalMember.Key);
if (uniquenessResult is not null)
{
return (uniquenessResult.Value, externalMember);
}
if (_securitySettings.CurrentValue.MemberRequireUniqueEmail)
{
uniquenessResult = await ValidateEmailUniqueAsync(externalMember.Email, externalMember.Key);
if (uniquenessResult is not null)
{
return (uniquenessResult.Value, externalMember);
}
}
return (ExternalMemberOperationStatus.Success, externalMember);
}
private async Task<(ExternalMemberOperationStatus Status, IMember? Member)> ValidateConvertToExternalMemberInternalAsync(Guid memberKey, bool requireExternalLogin)
{
IMember? member = _memberService.GetById(memberKey);
if (member is null)
{
return (ExternalMemberOperationStatus.NotFound, null);
}
if (requireExternalLogin
&& _externalLoginRepository.Get(Query<IIdentityUserLogin>().Where(x => x.Key == memberKey)).Any() is false)
{
return (ExternalMemberOperationStatus.NoExternalLogin, member);
}
// The content member legitimately owns its username/email, so exclude it from the uniqueness
// checks (which span both stores) — only a *different* record clashing is a real conflict.
ExternalMemberOperationStatus? uniquenessResult = await ValidateUsernameUniqueAsync(member.Username, memberKey);
if (uniquenessResult is not null)
{
return (uniquenessResult.Value, member);
}
if (_securitySettings.CurrentValue.MemberRequireUniqueEmail)
{
uniquenessResult = await ValidateEmailUniqueAsync(member.Email, memberKey);
if (uniquenessResult is not null)
{
return (uniquenessResult.Value, member);
}
}
return (ExternalMemberOperationStatus.Success, member);
}
private async Task<ExternalMemberOperationStatus?> ValidateUsernameUniqueAsync(string username, Guid? excludeKey)
@@ -114,5 +114,88 @@ public interface IExternalMemberService
/// (e.g. <c>member.SetValue("department", ...)</c>).
/// </param>
/// <returns>An <see cref="Attempt{TResult,TStatus}"/> with the newly created <see cref="IMember"/> on success.</returns>
/// <remarks>
/// On success this publishes <see cref="Notifications.ExternalMemberDeletedNotification"/> for the
/// removed external member, so it is evicted from caches and the search index. Handlers of that
/// notification therefore also run during a conversion. Note that the member's Guid key is
/// <em>preserved</em> on the new content member, so a handler that performs key-based cleanup
/// (e.g. deleting related data by member key) would act on a key that now belongs to the live
/// content member — such handlers should account for conversions. The external login links are
/// intentionally left intact (they belong to the content member under the same key) and are not
/// deleted as part of this notification.
/// </remarks>
Task<Attempt<IMember?, ExternalMemberOperationStatus>> ConvertToContentMemberAsync(Guid memberKey, string memberTypeAlias, Action<IMember, string?>? mapProfileData = null);
/// <summary>
/// Validates whether <see cref="ConvertToContentMemberAsync"/> would succeed for the given member,
/// without mutating any data.
/// </summary>
/// <param name="memberKey">The unique key of the external member to validate for conversion.</param>
/// <param name="memberTypeAlias">The alias of the member type the content member would use.</param>
/// <returns>
/// <see cref="ExternalMemberOperationStatus.Success"/> if the conversion would succeed; otherwise
/// the status that would cause it to fail (<see cref="ExternalMemberOperationStatus.NotFound"/>,
/// <see cref="ExternalMemberOperationStatus.InvalidMemberType"/>,
/// <see cref="ExternalMemberOperationStatus.DuplicateUsername"/> or
/// <see cref="ExternalMemberOperationStatus.DuplicateEmail"/>).
/// </returns>
// TODO (V19): remove the default implementation.
Task<ExternalMemberOperationStatus> ValidateConvertToContentMemberAsync(Guid memberKey, string memberTypeAlias)
=> Task.FromResult(ExternalMemberOperationStatus.NotImplemented);
/// <summary>
/// Converts a full content-based member into an external-only (lightweight) member,
/// preserving its key, identity fields, group memberships and external login links.
/// </summary>
/// <param name="memberKey">The unique key of the content member to convert.</param>
/// <param name="mapProfileData">
/// An optional callback invoked after the <see cref="ExternalMemberIdentity"/> is built from the
/// content member but before it is persisted. Receives the new identity and the source
/// <see cref="IMember"/>, allowing the developer to map content properties into the identity
/// (e.g. serialize values into <see cref="ExternalMemberIdentity.ProfileData"/>).
/// </param>
/// <param name="requireExternalLogin">
/// When <c>true</c> (the default) the conversion is rejected with
/// <see cref="ExternalMemberOperationStatus.NoExternalLogin"/> unless the member already has an
/// external login link — an external-only member has no password and can otherwise never
/// authenticate. Set to <c>false</c> to force the conversion of a password-only member, relying
/// on auto-linking to recreate a login link on the member's next external sign-in.
/// </param>
/// <returns>An <see cref="Attempt{TResult,TStatus}"/> with the created <see cref="ExternalMemberIdentity"/> on success.</returns>
/// <remarks>
/// The conversion is <em>not</em> performed in a single transaction. The content member is
/// deleted and the external identity created in one scope; the captured login links and tokens
/// are re-saved in a second scope, because deleting a member queues a deferred notification that
/// removes its login links at the end of the first scope. On a partial failure between the two
/// scopes the member exists without a login link, which auto-linking recreates on the next
/// external sign-in. Do not call this method within an ambient scope, as that would defer the
/// login-link deletion past the re-save and leave the member with no link.
/// </remarks>
// TODO (V19): remove the default implementation.
Task<Attempt<ExternalMemberIdentity?, ExternalMemberOperationStatus>> ConvertToExternalMemberAsync(
Guid memberKey,
Action<ExternalMemberIdentity, IMember>? mapProfileData = null,
bool requireExternalLogin = true)
=> Task.FromResult(Attempt.FailWithStatus<ExternalMemberIdentity?, ExternalMemberOperationStatus>(
ExternalMemberOperationStatus.NotImplemented, null));
/// <summary>
/// Validates whether <see cref="ConvertToExternalMemberAsync"/> would succeed for the given member,
/// without mutating any data.
/// </summary>
/// <param name="memberKey">The unique key of the content member to validate for conversion.</param>
/// <param name="requireExternalLogin">
/// When <c>true</c> (the default) a member with no external login link yields
/// <see cref="ExternalMemberOperationStatus.NoExternalLogin"/>.
/// </param>
/// <returns>
/// <see cref="ExternalMemberOperationStatus.Success"/> if the conversion would succeed; otherwise
/// the status that would cause it to fail (<see cref="ExternalMemberOperationStatus.NotFound"/>,
/// <see cref="ExternalMemberOperationStatus.NoExternalLogin"/>,
/// <see cref="ExternalMemberOperationStatus.DuplicateUsername"/> or
/// <see cref="ExternalMemberOperationStatus.DuplicateEmail"/>).
/// </returns>
// TODO (V19): remove the default implementation.
Task<ExternalMemberOperationStatus> ValidateConvertToExternalMemberAsync(Guid memberKey, bool requireExternalLogin = true)
=> Task.FromResult(ExternalMemberOperationStatus.NotImplemented);
}
@@ -37,4 +37,15 @@ public enum ExternalMemberOperationStatus
/// The operation is not yet implemented.
/// </summary>
NotImplemented,
/// <summary>
/// The operation failed because the member has no external login link, so it could not
/// authenticate as an external-only member after conversion.
/// </summary>
NoExternalLogin,
/// <summary>
/// The operation failed because the specified member type alias does not exist.
/// </summary>
InvalidMemberType,
}
@@ -1,9 +1,13 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Tests.Common.Builders;
@@ -25,6 +29,15 @@ internal sealed class ExternalMemberServiceTests : UmbracoIntegrationTest
private IMemberService MemberService => GetRequiredService<IMemberService>();
private IExternalLoginWithKeyService ExternalLoginService => GetRequiredService<IExternalLoginWithKeyService>();
protected override void CustomTestSetup(IUmbracoBuilder builder)
{
// A fresh host (and singleton recorder) is built per test, so captured keys never bleed across tests.
builder.Services.AddSingleton<ExternalMemberDeletedRecorder>();
builder.AddNotificationHandler<ExternalMemberDeletedNotification, ExternalMemberDeletedSpy>();
}
[Test]
public async Task Can_Create_And_Get_External_Member()
{
@@ -141,7 +154,7 @@ internal sealed class ExternalMemberServiceTests : UmbracoIntegrationTest
}
[Test]
public async Task Delete_Returns_NotFound_For_NonExistent()
public async Task Cannot_Delete_NonExistent_External_Member()
{
// Act
var result = await ExternalMemberService.DeleteAsync(Guid.NewGuid());
@@ -219,7 +232,7 @@ internal sealed class ExternalMemberServiceTests : UmbracoIntegrationTest
}
[Test]
public async Task GetByKey_Returns_Null_For_NonExistent()
public async Task Cannot_Get_NonExistent_External_Member_By_Key()
{
// Act
var retrieved = await ExternalMemberService.GetByKeyAsync(Guid.NewGuid());
@@ -229,7 +242,7 @@ internal sealed class ExternalMemberServiceTests : UmbracoIntegrationTest
}
[Test]
public async Task Cross_Store_Uniqueness_Rejects_Duplicate_Username()
public async Task Cannot_Create_External_Member_With_Duplicate_Username()
{
// Arrange - create a content-based member first.
IMemberType memberType = MemberTypeBuilder.CreateSimpleMemberType();
@@ -252,7 +265,7 @@ internal sealed class ExternalMemberServiceTests : UmbracoIntegrationTest
}
[Test]
public async Task Cross_Store_Uniqueness_Rejects_Duplicate_Email_When_Required()
public async Task Cannot_Create_External_Member_With_Duplicate_Email_When_Unique_Email_Required()
{
// Arrange - create a content-based member first (note: MemberRequireUniqueEmail defaults to true).
IMemberType memberType = MemberTypeBuilder.CreateSimpleMemberType();
@@ -353,7 +366,7 @@ internal sealed class ExternalMemberServiceTests : UmbracoIntegrationTest
}
[Test]
public async Task Convert_External_To_Content_Returns_NotFound_For_NonExistent()
public async Task Cannot_Convert_NonExistent_External_Member_To_Content()
{
// Act
var result = await ExternalMemberService.ConvertToContentMemberAsync(Guid.NewGuid(), "Member");
@@ -363,4 +376,314 @@ internal sealed class ExternalMemberServiceTests : UmbracoIntegrationTest
Assert.AreEqual(ExternalMemberOperationStatus.NotFound, result.Status);
}
[Test]
public async Task Cannot_Convert_External_To_Content_Member_With_Unknown_Member_Type()
{
// Arrange
var identity = ExternalMemberIdentityBuilder.CreateSimple("invalid-type@test.com", "Invalid Type Test");
var createResult = await ExternalMemberService.CreateAsync(identity);
Assert.IsTrue(createResult.Success);
// Act — a member type alias that does not exist must yield a status, not throw.
var result = await ExternalMemberService.ConvertToContentMemberAsync(createResult.Result.Key, "thisAliasDoesNotExist");
// Assert
Assert.IsFalse(result.Success);
Assert.AreEqual(ExternalMemberOperationStatus.InvalidMemberType, result.Status);
// Assert — the external member is untouched.
Assert.IsNotNull(await ExternalMemberService.GetByKeyAsync(createResult.Result.Key));
}
[Test]
public async Task Cannot_Convert_External_To_Content_Member_When_Username_Taken_By_Content_Member()
{
// Arrange — create the external member first (so cross-store uniqueness lets it through), then a
// *different* content member that already owns the same username.
var identity = new ExternalMemberIdentityBuilder()
.WithEmail("fwd-dup-external@test.com")
.WithUserName("fwd-dup")
.WithName("Forward Duplicate Test")
.Build();
var createResult = await ExternalMemberService.CreateAsync(identity);
Assert.IsTrue(createResult.Success);
IMemberType memberType = MemberTypeBuilder.CreateSimpleMemberType();
await MemberTypeService.CreateAsync(memberType, Constants.Security.SuperUserKey);
IMember contentMember = MemberBuilder.CreateSimpleMember(memberType, "Forward Dup Content", "fwd-dup-content@test.com", "password", "fwd-dup");
MemberService.Save(contentMember);
// Act
var result = await ExternalMemberService.ConvertToContentMemberAsync(createResult.Result.Key, memberType.Alias);
// Assert
Assert.IsFalse(result.Success);
Assert.AreEqual(ExternalMemberOperationStatus.DuplicateUsername, result.Status);
// Assert — the external member is untouched.
Assert.IsNotNull(await ExternalMemberService.GetByKeyAsync(createResult.Result.Key));
}
[Test]
public async Task Can_Convert_External_To_Content_Member_Publishing_Deleted_Notification()
{
// Arrange
ExternalMemberDeletedRecorder recorder = GetRequiredService<ExternalMemberDeletedRecorder>();
var identity = ExternalMemberIdentityBuilder.CreateSimple("deindex@test.com", "Deindex Test");
var createResult = await ExternalMemberService.CreateAsync(identity);
Assert.IsTrue(createResult.Success);
var key = createResult.Result.Key;
IMemberType memberType = MemberTypeBuilder.CreateSimpleMemberType();
await MemberTypeService.CreateAsync(memberType, Constants.Security.SuperUserKey);
// Act
var result = await ExternalMemberService.ConvertToContentMemberAsync(key, memberType.Alias);
// Assert — the deleted notification fired for the converted member, so it is evicted from the
// search index and distributed caches.
Assert.IsTrue(result.Success);
CollectionAssert.Contains(recorder.DeletedKeys, key);
}
[Test]
public async Task Can_Validate_Convert_To_Content_Member_Without_Mutating()
{
// Arrange
var identity = ExternalMemberIdentityBuilder.CreateSimple("validate-to-content@test.com", "Validate To Content");
var createResult = await ExternalMemberService.CreateAsync(identity);
Assert.IsTrue(createResult.Success);
IMemberType memberType = MemberTypeBuilder.CreateSimpleMemberType();
await MemberTypeService.CreateAsync(memberType, Constants.Security.SuperUserKey);
// Act
var validStatus = await ExternalMemberService.ValidateConvertToContentMemberAsync(createResult.Result.Key, memberType.Alias);
var invalidTypeStatus = await ExternalMemberService.ValidateConvertToContentMemberAsync(createResult.Result.Key, "thisAliasDoesNotExist");
var missingStatus = await ExternalMemberService.ValidateConvertToContentMemberAsync(Guid.NewGuid(), memberType.Alias);
// Assert — correct statuses reported.
Assert.AreEqual(ExternalMemberOperationStatus.Success, validStatus);
Assert.AreEqual(ExternalMemberOperationStatus.InvalidMemberType, invalidTypeStatus);
Assert.AreEqual(ExternalMemberOperationStatus.NotFound, missingStatus);
// Assert — validation mutated nothing: the external member still exists and was not converted.
Assert.IsNotNull(await ExternalMemberService.GetByKeyAsync(createResult.Result.Key));
Assert.IsNull(MemberService.GetById(createResult.Result.Key));
}
[Test]
public async Task Can_Convert_Content_To_External_Member()
{
// Arrange — content member with a group, a property value and an external login link.
IMember member = await CreateContentMemberAsync("convert-to-external@test.com", "convert-to-external", title: "Engineer");
MemberService.AddRole("ToExternalGroup");
MemberService.AssignRoles([member.Id], ["ToExternalGroup"]);
ExternalLoginService.Save(member.Key, new[] { new ExternalLogin("TestProvider", "provider-key-123") });
var originalKey = member.Key;
// Act — map the content "title" property into the external member's profile data.
string? capturedTitle = null;
var result = await ExternalMemberService.ConvertToExternalMemberAsync(
originalKey,
(identity, source) =>
{
capturedTitle = source.GetValue<string>("title");
identity.ProfileData = $$"""{"title":"{{capturedTitle}}"}""";
});
// Assert — external member created with the same key and identity fields.
Assert.IsTrue(result.Success);
Assert.AreEqual(ExternalMemberOperationStatus.Success, result.Status);
Assert.IsNotNull(result.Result);
Assert.AreEqual(originalKey, result.Result!.Key);
Assert.AreEqual("convert-to-external@test.com", result.Result.Email);
Assert.AreEqual("convert-to-external", result.Result.UserName);
// Assert — callback ran against the source member and profile data was persisted.
Assert.AreEqual("Engineer", capturedTitle);
var externalMember = await ExternalMemberService.GetByKeyAsync(originalKey);
Assert.IsNotNull(externalMember);
Assert.AreEqual("""{"title":"Engineer"}""", externalMember!.ProfileData);
// Assert — the content member is gone.
Assert.IsNull(MemberService.GetById(originalKey));
// Assert — group memberships migrated to the external store.
IEnumerable<string> externalRoles = await ExternalMemberService.GetRolesAsync(originalKey);
CollectionAssert.Contains(externalRoles.ToList(), "ToExternalGroup");
}
[Test]
public async Task Can_Convert_Content_To_External_Member_Preserving_External_Login()
{
// Arrange — content member with an external login link and a token.
IMember member = await CreateContentMemberAsync("preserve-login@test.com", "preserve-login");
var originalKey = member.Key;
ExternalLoginService.Save(originalKey, new[] { new ExternalLogin("TestProvider", "provider-key-abc", "user-data") });
ExternalLoginService.Save(originalKey, new[] { new ExternalLoginToken("TestProvider", "access_token", "token-value") });
// Act
var result = await ExternalMemberService.ConvertToExternalMemberAsync(originalKey);
// Assert — the login link and token survive the conversion (deleting the content member queues a
// deferred handler that wipes the links; the second scope must re-link them).
Assert.IsTrue(result.Success);
var logins = ExternalLoginService.GetExternalLogins(originalKey).ToList();
Assert.AreEqual(1, logins.Count);
Assert.AreEqual("TestProvider", logins[0].LoginProvider);
Assert.AreEqual("provider-key-abc", logins[0].ProviderKey);
Assert.AreEqual("user-data", logins[0].UserData);
var tokens = ExternalLoginService.GetExternalLoginTokens(originalKey).ToList();
Assert.AreEqual(1, tokens.Count);
Assert.AreEqual("access_token", tokens[0].Name);
Assert.AreEqual("token-value", tokens[0].Value);
}
[Test]
public async Task Cannot_Convert_Content_To_External_Member_Without_External_Login_Unless_Forced()
{
// Arrange — a password-only content member with no external login link.
IMember member = await CreateContentMemberAsync("no-link@test.com", "no-link");
var originalKey = member.Key;
// Act + Assert — by default the conversion is rejected and the member is left untouched.
var guardedResult = await ExternalMemberService.ConvertToExternalMemberAsync(originalKey);
Assert.IsFalse(guardedResult.Success);
Assert.AreEqual(ExternalMemberOperationStatus.NoExternalLogin, guardedResult.Status);
Assert.IsNotNull(MemberService.GetById(originalKey), "The failed guard must not mutate the member.");
// Act + Assert — forcing the conversion succeeds (auto-link recreates a link on next sign-in).
var forcedResult = await ExternalMemberService.ConvertToExternalMemberAsync(originalKey, requireExternalLogin: false);
Assert.IsTrue(forcedResult.Success);
Assert.AreEqual(ExternalMemberOperationStatus.Success, forcedResult.Status);
Assert.IsNull(MemberService.GetById(originalKey));
Assert.IsNotNull(await ExternalMemberService.GetByKeyAsync(originalKey));
}
[Test]
public async Task Cannot_Convert_Content_To_External_Member_When_Username_Taken_By_External_Member()
{
// Arrange — an existing external member owns the username; then a *different* content member with
// the same username (the content store doesn't check the external store, so this can exist).
var existingExternal = new ExternalMemberIdentityBuilder()
.WithEmail("rev-dup-username-external@test.com")
.WithUserName("rev-dup-username")
.WithName("Existing External")
.Build();
var createResult = await ExternalMemberService.CreateAsync(existingExternal);
Assert.IsTrue(createResult.Success);
IMember contentMember = await CreateContentMemberAsync("rev-dup-username-content@test.com", "rev-dup-username");
// Act — requireExternalLogin: false isolates the uniqueness guard from the no-login guard.
var result = await ExternalMemberService.ConvertToExternalMemberAsync(contentMember.Key, requireExternalLogin: false);
// Assert — a different external record owns the username, so conversion is rejected.
Assert.IsFalse(result.Success);
Assert.AreEqual(ExternalMemberOperationStatus.DuplicateUsername, result.Status);
// Assert — the content member is untouched.
Assert.IsNotNull(MemberService.GetById(contentMember.Key));
}
[Test]
public async Task Cannot_Convert_Content_To_External_Member_When_Email_Taken_By_External_Member()
{
// Arrange — an existing external member owns the email; a *different* content member shares it.
// Usernames differ so the username check passes and the email check is what rejects the conversion.
var existingExternal = new ExternalMemberIdentityBuilder()
.WithEmail("rev-dup-email@test.com")
.WithUserName("rev-dup-email-external")
.WithName("Existing External")
.Build();
var createResult = await ExternalMemberService.CreateAsync(existingExternal);
Assert.IsTrue(createResult.Success);
IMember contentMember = await CreateContentMemberAsync("rev-dup-email@test.com", "rev-dup-email-content");
// Act
var result = await ExternalMemberService.ConvertToExternalMemberAsync(contentMember.Key, requireExternalLogin: false);
// Assert — a different external record owns the email (MemberRequireUniqueEmail defaults to true).
Assert.IsFalse(result.Success);
Assert.AreEqual(ExternalMemberOperationStatus.DuplicateEmail, result.Status);
// Assert — the content member is untouched.
Assert.IsNotNull(MemberService.GetById(contentMember.Key));
}
[Test]
public async Task Cannot_Convert_NonExistent_Content_Member_To_External()
{
// Act
var result = await ExternalMemberService.ConvertToExternalMemberAsync(Guid.NewGuid());
// Assert
Assert.IsFalse(result.Success);
Assert.AreEqual(ExternalMemberOperationStatus.NotFound, result.Status);
}
[Test]
public async Task Can_Validate_Convert_To_External_Member_Without_Mutating()
{
// Arrange — member with a link (valid) and a member without one (invalid under the guard).
IMember linkedMember = await CreateContentMemberAsync("validate-linked@test.com", "validate-linked");
ExternalLoginService.Save(linkedMember.Key, new[] { new ExternalLogin("TestProvider", "provider-key-xyz") });
IMember unlinkedMember = await CreateContentMemberAsync("validate-unlinked@test.com", "validate-unlinked");
// Act
var linkedStatus = await ExternalMemberService.ValidateConvertToExternalMemberAsync(linkedMember.Key);
var unlinkedStatus = await ExternalMemberService.ValidateConvertToExternalMemberAsync(unlinkedMember.Key);
var missingStatus = await ExternalMemberService.ValidateConvertToExternalMemberAsync(Guid.NewGuid());
// Assert — correct statuses reported.
Assert.AreEqual(ExternalMemberOperationStatus.Success, linkedStatus);
Assert.AreEqual(ExternalMemberOperationStatus.NoExternalLogin, unlinkedStatus);
Assert.AreEqual(ExternalMemberOperationStatus.NotFound, missingStatus);
// Assert — validation mutated nothing: both members still exist as content members.
Assert.IsNotNull(MemberService.GetById(linkedMember.Key));
Assert.IsNotNull(MemberService.GetById(unlinkedMember.Key));
Assert.IsNull(await ExternalMemberService.GetByKeyAsync(linkedMember.Key));
}
private async Task<IMember> CreateContentMemberAsync(string email, string username, string? title = null)
{
// A distinct member type alias per call, since several members may be created within one test.
var alias = "memberType" + username.Replace("-", string.Empty);
IMemberType memberType = MemberTypeBuilder.CreateSimpleMemberType(alias, alias);
await MemberTypeService.CreateAsync(memberType, Constants.Security.SuperUserKey);
IMember member = MemberBuilder.CreateSimpleMember(memberType, username, email, "password", username);
if (title is not null)
{
member.SetValue("title", title);
}
MemberService.Save(member);
return member;
}
private sealed class ExternalMemberDeletedRecorder
{
public List<Guid> DeletedKeys { get; } = [];
}
private sealed class ExternalMemberDeletedSpy : INotificationHandler<ExternalMemberDeletedNotification>
{
private readonly ExternalMemberDeletedRecorder _recorder;
public ExternalMemberDeletedSpy(ExternalMemberDeletedRecorder recorder) => _recorder = recorder;
public void Handle(ExternalMemberDeletedNotification notification)
=> _recorder.DeletedKeys.AddRange(notification.DeletedEntities.Select(x => x.Key));
}
}