Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a6ebb0f56 | ||
|
|
d284485cea | ||
|
|
6ee3f22c5c | ||
|
|
a935ac854f | ||
|
|
690a0d8f88 | ||
|
|
8a9cbebea9 | ||
|
|
e894c48ba1 |
@@ -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,
|
||||
}
|
||||
|
||||
+328
-5
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user