From f7d2dc5ccb09c2d8c78762356e17e498bd2c4b6b Mon Sep 17 00:00:00 2001 From: Jezz Santos Date: Thu, 18 Apr 2024 22:57:38 +1200 Subject: [PATCH] Added GetCurrentProfile request, and refined organization and memberships use cases. --- docs/design-principles/0000-all-use-cases.md | 1 + docs/design-principles/0020-api-framework.md | 2 +- docs/design-principles/0160-user-lifecycle.md | 46 +- .../Api/Audits/AuditsApi.cs | 2 +- .../Api/Emails/EmailsApi.cs | 2 +- .../Api/Provisionings/ProvisioningsApi.cs | 2 +- .../Api/Usages/UsagesApi.cs | 2 +- .../UserProfile.cs | 13 +- .../IEndUsersService.cs | 3 + .../Api/Bookings/BookingsApi.cs | 2 +- src/CarsInfrastructure/Api/Cars/CarsApi.cs | 2 +- .../EndUsers/MembershipAdded.cs | 3 + .../EndUsers/MembershipDefaultChanged.cs | 8 +- .../DefaultOrganizationChanged.cs | 21 + .../EndUsersApplicationSpec.cs | 45 +- ...ionsApplication.DomainEventHandlersSpec.cs | 7 + .../EndUsersApplication.cs | 75 +- .../IEndUsersApplication.cs | 2 +- ...itationsApplication.DomainEventHandlers.cs | 6 +- .../InvitationsApplication.cs | 10 +- .../EndUserRootSpec.cs | 1470 ++++++++++------- .../MembershipSpec.cs | 5 +- .../MembershipsSpec.cs | 3 +- src/EndUsersDomain/EndUserRoot.cs | 64 +- src/EndUsersDomain/Events.cs | 20 +- src/EndUsersDomain/GuestInvitation.cs | 2 +- src/EndUsersDomain/Membership.cs | 6 + src/EndUsersDomain/Resources.Designer.cs | 18 + src/EndUsersDomain/Resources.resx | 6 + .../Api/EndUsers/EndUsersApi.cs | 4 +- .../Api/Invitations/InvitationsApi.cs | 4 +- .../EndUsersInProcessServiceClient.cs | 8 +- .../ReadModels/EndUserProjection.cs | 12 +- .../Api/APIKeys/APIKeysApi.cs | 2 +- .../Api/AuthTokens/AuthTokensApi.cs | 2 +- .../MachineCredentialsApi.cs | 5 +- .../PasswordCredentialsApi.cs | 6 +- .../Api/SSO/SingleSignOnApi.cs | 2 +- .../Api/Images/ImagesApi.cs | 6 +- .../Extensions/HandlerExtensions.cs | 7 +- .../UserProfiles/GetCurrentProfileRequest.cs | 8 + .../UserProfiles/GetCurrentProfileResponse.cs | 9 + .../AspNetCallerContext.cs | 15 +- .../OrganizationsApplicationSpec.cs | 53 +- ...izationsApplication.DomainEventHandlers.cs | 5 +- .../OrganizationsApplication.cs | 22 +- .../OrganizationRootSpec.cs | 28 +- src/OrganizationsDomain/OrganizationRoot.cs | 43 +- src/OrganizationsDomain/Resources.Designer.cs | 20 +- src/OrganizationsDomain/Resources.resx | 8 +- .../Api/Organizations/OrganizationsApi.cs | 12 +- src/SaaStack.sln.DotSettings | 6 +- src/UnitTesting.Common/ResultAssertions.cs | 6 +- ...fileApplication.DomainEventHandlersSpec.cs | 23 +- .../UserProfileApplicationSpec.cs | 48 + ...ProfilesApplication.DomainEventHandlers.cs | 4 + .../IUserProfilesApplication.cs | 3 + .../Persistence/ReadModels/UserProfile.cs | 2 + ...ProfilesApplication.DomainEventHandlers.cs | 180 ++ .../UserProfilesApplication.cs | 190 +-- src/UserProfilesDomain/Events.cs | 11 + src/UserProfilesDomain/UserProfileRoot.cs | 21 + .../UserProfileApiSpec.cs | 31 + .../Api/Profiles/UserProfilesApi.cs | 19 +- .../UserProfileNotificationConsumer.cs | 5 + .../ReadModels/UserProfileProjection.cs | 5 + .../Api/AuthN/AuthenticationApi.cs | 2 +- 67 files changed, 1782 insertions(+), 903 deletions(-) create mode 100644 src/Domain.Events.Shared/UserProfiles/DefaultOrganizationChanged.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/UserProfiles/GetCurrentProfileRequest.cs create mode 100644 src/Infrastructure.Web.Api.Operations.Shared/UserProfiles/GetCurrentProfileResponse.cs diff --git a/docs/design-principles/0000-all-use-cases.md b/docs/design-principles/0000-all-use-cases.md index cb9e3a20..425f936b 100644 --- a/docs/design-principles/0000-all-use-cases.md +++ b/docs/design-principles/0000-all-use-cases.md @@ -121,6 +121,7 @@ When a person is registered we also query the `IAvatarService` to see if we can 2. Change the address of the profile 3. Add an Avatar image the profile 4. Remove the Avatar from the profile +5. Inspect the profile of the current (Authenticated) user ## Backend for Frontend diff --git a/docs/design-principles/0020-api-framework.md b/docs/design-principles/0020-api-framework.md index ba4e6443..c083d903 100644 --- a/docs/design-principles/0020-api-framework.md +++ b/docs/design-principles/0020-api-framework.md @@ -60,7 +60,7 @@ public sealed class CarsApi : IWebApiService var car = await _carsApplication.RegisterCarAsync(_contextFactory.Create(), request.Make, request.Model, request.Year, cancellationToken); - return () => car.HandleApplicationResult(c => + return () => car.HandleApplicationResult(c => new PostResult(new GetCarResponse { Car = c }, $"/cars/{c.Id}")); } diff --git a/docs/design-principles/0160-user-lifecycle.md b/docs/design-principles/0160-user-lifecycle.md index 8acd6cdc..a98e7bb0 100644 --- a/docs/design-principles/0160-user-lifecycle.md +++ b/docs/design-principles/0160-user-lifecycle.md @@ -28,28 +28,30 @@ Users self-register in the `Identity` subdomain via either `PasswordCredentials` ### Organizations -An `Organization` has a type of either `Personal` or `Shared`. +An `Organization` has a classification of either `Personal` or `Shared`. * `Shared` organizations are intended for use by companies/workgroups/organizations/teams/etc. * `Personal` organizations are for each `EndUser` (person or machine) to use on the platform and cannot be shared with others. * A person/machine will have a membership to one `Personal` organization at all times, and can have a membership to one or more `Shared` organizations. +> Note: By default, The primary use case of a `Personal` org is for the user to still be able to login to the platform and at least use basic functionality of the product without having to upgrade to paid features. The basic plan is assumed by default to be a "free" (unpaid) plan. This is intended so that they have to have a space in the product of their own, from which they can still use the product even if they are removed from other companies organizations, and from there add other (paid) organizations if they wish to, or be invited into other organizations later. + #### Roles and Responsibilities -* Any person can have the `Member` and/or `Owner` and/or `BillingAdmin` roles in an organization. -* Only `Owner` roles can assign/unassign roles to other members. -* Any person can have the `BillingAdmin` role of an organization, but they must also have the `Owner` role. -* Every organization (`Shared` or `Personal`) will always have a person who is the "billing subscriber". This person has a fiscal responsibility to pay for billing charges for that organization (tenant). The "billing subscriber" must always have the `Owner` and `BillingAdmin` roles at all times. -* When a person creates a new organization, they automatically become an `Owner` and `BillingAdmin` of it, as well as the "billing subscriber" for it. +* Any person can have the `Member` and/or `Owner` and/or `BillingAdmin` roles in an organization. Machines can be members of other organizations too. +* Only `Owner` roles can assign/unassign the roles of other members. +* Any person (not machine) can have the `BillingAdmin` role of an organization, but they must also have the `Owner` role. +* Every organization (`Shared` or `Personal`) will always have a person who is the "billing subscriber". This person has a fiscal responsibility to pay for billing charges for that organization (tenant). The "billing subscriber" must always have the `Owner` and `BillingAdmin` roles at all times. (A "Billing Subscriber" is not a role, but it is an attribute of an organization). +* When a person (not a machine) creates a new `Shared` organization, they automatically become an `Owner` and `BillingAdmin` of it, as well as the "billing subscriber" for it. * From that point, they can assign the `Owner` and `BillingAdmin` roles to one or more other members of the organization. -* The "billing subscriber" responsibility must be transferred via a (voluntary) payment method submission from another person with the `BillingAdmin` (role). +* The "billing subscriber" responsibility can only be transferred via a (voluntary) payment method submission from another `BillingAdmin` of the organization. (and when the current 'Billing Subscriber' payment method has expired, and then an existing `BillingAdmin` can assume the position) #### Personal Organizations * Every `EndUser` (person or machine) has one `Personal` organization. * It is automatically created for them when they register on the platform. It is named after that person/machine. -* That person/machine is the only member of that organization. +* That person/machine is the only member of that organization. No other members (person or machines) can be added to it. * They have the roles of `Owner` and `BillingAdmin`, and they are also the "billing subscriber" for it. @@ -59,21 +61,35 @@ An `Organization` has a type of either `Personal` or `Shared`. #### Shared Organizations -* `Shared` organizations can be created at any time by any person on the platform (not machines). -* Any other person/machine can be invited to join them. When they are, they are created a `Membership` to that `Organization`, and each `Membership` maintains its own roles. +* `Shared` organizations can be created at any time by any person (not machine) on the platform. +* Any other person/machine can be invited to join them. When the person/machine is added, they are created a `Membership` to that `Organization` with a set of default roles (i.e. `Member`). Each `Membership` maintains its own roles. * A person (or machine) can be a `Member` (role) of any number of `Shared` organizations into which they can be invited, removed, or they can leave themselves. -* A person (not a machine) can be assigned/unassigned any number of roles in those other organizations. +* A person (not a machine) can be assigned/unassigned any number of other roles in those other organizations. A machine can only be a `Member`, and not a `Onwer` or `BillingAdmin`. * A `Shared` organization must have at least one `Owner` (role) and one `BillingAdmin` (role) at all times, and they can be the same person or different persons. Like a `Personal` organization, a `Shared` will have one and only one "billing subscriber", who is ultimately responsible for any charges for the organization. * A `Shared` organization can be deleted. However, they have to be deleted by the designated "billing subscriber" and only once all members are removed from it. +### Memberships + +When an `EndUser` joins an `Organization` they get a membership to that organization. + +Memberships belong to the `EndUser` (subdomain), not to the `Organization`. + +> Although, in practice, there is a very tight coupling between `EndUser` -> `Membership` <- `Organization`. Nonetheless, since the `EndUser` and `Organization` subdomains are presently separated, `Membership` belongs to `EndUser` subdomain. You may notice that the `Organization` keeps mementos of its `Memberships` in that subdomain also. + +A user can have one or more memberships. A user will always have at least one membership to their own `Personal` organization. Which they should never lose. + +Every user will also have a "default" membership (a.k.a. their "default organization"). That is, a membership that the system can assume is the one they are working with at any time. Of course, this default can change at any time. + +When a user is invited into another organization, or if they create a new organization themselves, this default will always change to be the last organization they joined. + ### Guest Invitations -Guest invitations are the mechanism to introduce and refer new users to the product. +Guest invitations are the mechanism to introduce (and refer) new users to the product. #### To The Platform * Any authenticated user can invite a guest to the platform (i.e., without any affiliation to any organization) -* A "guest invitation" requires only an email address and has an expiry (7 days, by default) +* A "guest invitation" requires only an email address and has an expiry (14 days, by default) * The person is contacted at that email address and given a link to register with the platform (in the web app). The link contains an `InvitationToken`. * In the web app, the `InvitationToken` is first verified to check if it is valid (and not expired), and if so, the guest is presented with a registration form, which accepts an email address and password, which is pre-populated with the email address and a "guessed" name (derived from their email address). Or they can sign up with an SSO provider. * In either case, when signing up with password credentials or signing in with SSO, the registration could include the referral `InvitationToken`. It will not include this token if they sign up on their own. @@ -83,6 +99,6 @@ Guest invitations are the mechanism to introduce and refer new users to the prod #### To An Organization * Any organization owner (role) can invite a guest to their organization (by email) or invite an existing user to their organization (by email or by ID) -* As above, a "guest invitation" requires only an email address and has an expiry (7 days, by default) +* As above, a "guest invitation" requires only an email address and has an expiry (14 days, by default) * A "guest invitation" follows the same process as above, except that when they eventually register (either by accepting the guest invitation with a different email address or by registering with the same email address that they were invited with), they will added to the organization. -* This organization (or the last one they were invited to) will become their default organization (rather than their `Personal` organization). +* This organization (or the last one they were invited to) will become their "default" organization (rather than their `Personal` organization). diff --git a/src/AncillaryInfrastructure/Api/Audits/AuditsApi.cs b/src/AncillaryInfrastructure/Api/Audits/AuditsApi.cs index e1673cd4..5690dad7 100644 --- a/src/AncillaryInfrastructure/Api/Audits/AuditsApi.cs +++ b/src/AncillaryInfrastructure/Api/Audits/AuditsApi.cs @@ -25,7 +25,7 @@ public async Task> Deliver(DeliverAu var delivered = await _ancillaryApplication.DeliverAuditAsync(_contextFactory.Create(), request.Message, cancellationToken); - return () => delivered.HandleApplicationResult(_ => + return () => delivered.HandleApplicationResult(_ => new PostResult(new DeliverMessageResponse { IsDelivered = true })); } diff --git a/src/AncillaryInfrastructure/Api/Emails/EmailsApi.cs b/src/AncillaryInfrastructure/Api/Emails/EmailsApi.cs index 0945edb1..72149299 100644 --- a/src/AncillaryInfrastructure/Api/Emails/EmailsApi.cs +++ b/src/AncillaryInfrastructure/Api/Emails/EmailsApi.cs @@ -25,7 +25,7 @@ public async Task> Deliver(DeliverEm var delivered = await _ancillaryApplication.DeliverEmailAsync(_contextFactory.Create(), request.Message, cancellationToken); - return () => delivered.HandleApplicationResult(_ => + return () => delivered.HandleApplicationResult(_ => new PostResult(new DeliverMessageResponse { IsDelivered = true })); } diff --git a/src/AncillaryInfrastructure/Api/Provisionings/ProvisioningsApi.cs b/src/AncillaryInfrastructure/Api/Provisionings/ProvisioningsApi.cs index 4c26d13d..ba35f70a 100644 --- a/src/AncillaryInfrastructure/Api/Provisionings/ProvisioningsApi.cs +++ b/src/AncillaryInfrastructure/Api/Provisionings/ProvisioningsApi.cs @@ -25,7 +25,7 @@ public async Task> Notify(NotifyProv await _ancillaryApplication.NotifyProvisioningAsync(_contextFactory.Create(), request.Message, cancellationToken); - return () => delivered.HandleApplicationResult(_ => + return () => delivered.HandleApplicationResult(_ => new PostResult(new DeliverMessageResponse { IsDelivered = true })); } diff --git a/src/AncillaryInfrastructure/Api/Usages/UsagesApi.cs b/src/AncillaryInfrastructure/Api/Usages/UsagesApi.cs index 9c55c3fc..15f69ad4 100644 --- a/src/AncillaryInfrastructure/Api/Usages/UsagesApi.cs +++ b/src/AncillaryInfrastructure/Api/Usages/UsagesApi.cs @@ -24,7 +24,7 @@ public async Task> Deliver(DeliverUs var delivered = await _ancillaryApplication.DeliverUsageAsync(_contextFactory.Create(), request.Message, cancellationToken); - return () => delivered.HandleApplicationResult(_ => + return () => delivered.HandleApplicationResult(_ => new PostResult(new DeliverMessageResponse { IsDelivered = true })); } diff --git a/src/Application.Resources.Shared/UserProfile.cs b/src/Application.Resources.Shared/UserProfile.cs index 016b0dfc..3530ff86 100644 --- a/src/Application.Resources.Shared/UserProfile.cs +++ b/src/Application.Resources.Shared/UserProfile.cs @@ -9,6 +9,8 @@ public class UserProfile : IIdentifiableResource public string? AvatarUrl { get; set; } + public UserProfileClassification Classification { get; set; } + public required string DisplayName { get; set; } public string? EmailAddress { get; set; } @@ -19,13 +21,20 @@ public class UserProfile : IIdentifiableResource public string? Timezone { get; set; } - public UserProfileClassification Classification { get; set; } - public required string UserId { get; set; } public required string Id { get; set; } } +public class UserProfileForCurrent : UserProfileWithDefaultMembership +{ + public List Features { get; set; } = new(); + + public bool IsAuthenticated { get; set; } + + public List Roles { get; set; } = new(); +} + public enum UserProfileClassification { Person = 0, diff --git a/src/Application.Services.Shared/IEndUsersService.cs b/src/Application.Services.Shared/IEndUsersService.cs index 1a36519b..c94f5ae9 100644 --- a/src/Application.Services.Shared/IEndUsersService.cs +++ b/src/Application.Services.Shared/IEndUsersService.cs @@ -12,6 +12,9 @@ Task, Error>> FindPersonByEmailPrivateAsync(ICallerCont Task> GetMembershipsPrivateAsync(ICallerContext caller, string id, CancellationToken cancellationToken); + Task> GetUserPrivateAsync(ICallerContext caller, string id, + CancellationToken cancellationToken); + Task, Error>> ListMembershipsForOrganizationAsync( ICallerContext caller, string organizationId, SearchOptions searchOptions, GetOptions getOptions, CancellationToken cancellationToken); diff --git a/src/BookingsInfrastructure/Api/Bookings/BookingsApi.cs b/src/BookingsInfrastructure/Api/Bookings/BookingsApi.cs index da60c288..4447eb6c 100644 --- a/src/BookingsInfrastructure/Api/Bookings/BookingsApi.cs +++ b/src/BookingsInfrastructure/Api/Bookings/BookingsApi.cs @@ -32,7 +32,7 @@ public async Task> Make(MakeBookingR var booking = await _bookingsApplication.MakeBookingAsync(_contextFactory.Create(), request.OrganizationId!, request.CarId, request.StartUtc, request.EndUtc, cancellationToken); - return () => booking.HandleApplicationResult(c => + return () => booking.HandleApplicationResult(c => new PostResult(new MakeBookingResponse { Booking = c })); } diff --git a/src/CarsInfrastructure/Api/Cars/CarsApi.cs b/src/CarsInfrastructure/Api/Cars/CarsApi.cs index 618bbc4d..310acbf5 100644 --- a/src/CarsInfrastructure/Api/Cars/CarsApi.cs +++ b/src/CarsInfrastructure/Api/Cars/CarsApi.cs @@ -39,7 +39,7 @@ public async Task> Register(RegisterCarReques var car = await _carsApplication.RegisterCarAsync(_contextFactory.Create(), request.OrganizationId!, request.Make, request.Model, request.Year, request.Jurisdiction, request.NumberPlate, cancellationToken); - return () => car.HandleApplicationResult(c => + return () => car.HandleApplicationResult(c => new PostResult(new GetCarResponse { Car = c }, new GetCarRequest { Id = c.Id }.ToUrl())); } diff --git a/src/Domain.Events.Shared/EndUsers/MembershipAdded.cs b/src/Domain.Events.Shared/EndUsers/MembershipAdded.cs index 5343c264..e506fecd 100644 --- a/src/Domain.Events.Shared/EndUsers/MembershipAdded.cs +++ b/src/Domain.Events.Shared/EndUsers/MembershipAdded.cs @@ -1,5 +1,6 @@ using Domain.Common; using Domain.Common.ValueObjects; +using Domain.Shared.Organizations; using JetBrains.Annotations; namespace Domain.Events.Shared.EndUsers; @@ -23,5 +24,7 @@ public MembershipAdded() public required string OrganizationId { get; set; } + public OrganizationOwnership Ownership { get; set; } + public required List Roles { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/EndUsers/MembershipDefaultChanged.cs b/src/Domain.Events.Shared/EndUsers/MembershipDefaultChanged.cs index cae190b1..dd0bb3a8 100644 --- a/src/Domain.Events.Shared/EndUsers/MembershipDefaultChanged.cs +++ b/src/Domain.Events.Shared/EndUsers/MembershipDefaultChanged.cs @@ -15,7 +15,13 @@ public MembershipDefaultChanged() { } - public required string FromMembershipId { get; set; } + public required List Features { get; set; } + + public string? FromMembershipId { get; set; } + + public required List Roles { get; set; } public required string ToMembershipId { get; set; } + + public required string ToOrganizationId { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/UserProfiles/DefaultOrganizationChanged.cs b/src/Domain.Events.Shared/UserProfiles/DefaultOrganizationChanged.cs new file mode 100644 index 00000000..8c2225a6 --- /dev/null +++ b/src/Domain.Events.Shared/UserProfiles/DefaultOrganizationChanged.cs @@ -0,0 +1,21 @@ +using Domain.Common; +using Domain.Common.ValueObjects; +using JetBrains.Annotations; + +namespace Domain.Events.Shared.UserProfiles; + +public sealed class DefaultOrganizationChanged : DomainEvent +{ + public DefaultOrganizationChanged(Identifier id) : base(id) + { + } + + [UsedImplicitly] + public DefaultOrganizationChanged() + { + } + + public string? FromOrganizationId { get; set; } + + public required string ToOrganizationId { get; set; } +} \ No newline at end of file diff --git a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs index 20e102b2..96084939 100644 --- a/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs +++ b/src/EndUsersApplication.UnitTests/EndUsersApplicationSpec.cs @@ -18,6 +18,7 @@ using UnitTesting.Common; using Xunit; using Membership = EndUsersDomain.Membership; +using OrganizationOwnership = Domain.Shared.Organizations.OrganizationOwnership; using PersonName = Application.Resources.Shared.PersonName; namespace EndUsersApplication.UnitTests; @@ -77,7 +78,7 @@ public async Task WhenGetPersonAndUnregistered_ThenReturnsUser() _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user); - var result = await _application.GetPersonAsync(_caller.Object, "anid", CancellationToken.None); + var result = await _application.GetUserAsync(_caller.Object, "anid", CancellationToken.None); result.Should().BeSuccess(); result.Value.Id.Should().Be("anid"); @@ -138,7 +139,8 @@ public async Task WhenRegisterPersonAsyncAndWasInvitedAsGuest_ThenCompletesRegis .ReturnsAsync((EndUserRoot root, bool _, CancellationToken _) => { // HACK: By this time, domain events have created the default membership - root.AddMembership("anorganizationid".ToId(), Roles.Empty, Features.Empty); + root.AddMembership(root, OrganizationOwnership.Personal, "anorganizationid".ToId(), Roles.Empty, + Features.Empty); return root; }); @@ -227,7 +229,8 @@ await invitee.InviteGuestAsync(tokensService.Object, "aninviterid".ToId(), .ReturnsAsync((EndUserRoot root, bool _, CancellationToken _) => { // HACK: By this time, domain events have created the default membership - root.AddMembership("anorganizationid".ToId(), Roles.Empty, Features.Empty); + root.AddMembership(root, OrganizationOwnership.Personal, "anorganizationid".ToId(), Roles.Empty, + Features.Empty); return root; }); @@ -304,7 +307,8 @@ public async Task WhenRegisterPersonAsyncAndAcceptingAnUnknownInvitation_ThenReg .ReturnsAsync((EndUserRoot root, bool _, CancellationToken _) => { // HACK: By this time, domain events have created the default membership - root.AddMembership("anorganizationid".ToId(), Roles.Empty, Features.Empty); + root.AddMembership(root, OrganizationOwnership.Personal, "anorganizationid".ToId(), Roles.Empty, + Features.Empty); return root; }); @@ -419,7 +423,8 @@ public async Task WhenRegisterPersonAsyncAndAlreadyRegistered_ThenSendsCourtesyE }, Timezone = "atimezone" }.ToOptional()); - endUser.AddMembership("anorganizationid".ToId(), Roles.Empty, Features.Empty); + endUser.AddMembership(endUser, OrganizationOwnership.Shared, "anorganizationid".ToId(), Roles.Empty, + Features.Empty); _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(endUser); @@ -496,7 +501,8 @@ public async Task WhenRegisterPersonAsyncAndNeverRegisteredNorInvitedAsGuest_The .ReturnsAsync((EndUserRoot root, bool _, CancellationToken _) => { // HACK: By this time, domain events have created the default membership - root.AddMembership("anorganizationid".ToId(), Roles.Empty, Features.Empty); + root.AddMembership(root, OrganizationOwnership.Personal, "anorganizationid".ToId(), Roles.Empty, + Features.Empty); return root; }); @@ -559,7 +565,8 @@ public async Task WhenRegisterMachineAsyncByAnonymousUser_ThenRegistersWithNoFea .ReturnsAsync((EndUserRoot root, bool _, CancellationToken _) => { // HACK: By this time, domain events have created the default membership - root.AddMembership("anorganizationid".ToId(), Roles.Empty, Features.Empty); + root.AddMembership(root, OrganizationOwnership.Personal, "anorganizationid".ToId(), Roles.Empty, + Features.Empty); return root; }); @@ -600,7 +607,8 @@ public async Task WhenRegisterMachineAsyncByAuthenticatedUser_ThenRegistersWithB .ReturnsAsync(adder); adder.Register(Roles.Empty, Features.Empty, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); - adder.AddMembership("anotherorganizationid".ToId(), Roles.Empty, Features.Empty); + adder.AddMembership(adder, OrganizationOwnership.Shared, "anotherorganizationid".ToId(), Roles.Empty, + Features.Empty); _userProfilesService.Setup(ups => ups.GetProfilePrivateAsync(It.IsAny(), It.IsAny(), It.IsAny())) @@ -705,20 +713,22 @@ public async Task WhenAssignTenantRolesAsync_ThenAssigns() { _caller.Setup(cc => cc.CallerId) .Returns("anassignerid"); - var assignee = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; - assignee.Register(Roles.Create(PlatformRoles.Standard).Value, Features.Create(PlatformFeatures.Basic).Value, - EndUserProfile.Create("afirstname").Value, Optional.None); - assignee.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Member).Value, - Features.Create(TenantFeatures.Basic).Value); - _endUserRepository.Setup(rep => rep.LoadAsync("anassigneeid".ToId(), It.IsAny())) - .ReturnsAsync(assignee); var assigner = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; assigner.Register(Roles.Create(PlatformRoles.Operations).Value, Features.Create(), EndUserProfile.Create("afirstname").Value, Optional.None); - assigner.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Owner).Value, + assigner.AddMembership(assigner, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Owner).Value, Features.Create(TenantFeatures.Basic).Value); _endUserRepository.Setup(rep => rep.LoadAsync("anassignerid".ToId(), It.IsAny())) .ReturnsAsync(assigner); + var assignee = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; + assignee.Register(Roles.Create(PlatformRoles.Standard).Value, Features.Create(PlatformFeatures.Basic).Value, + EndUserProfile.Create("afirstname").Value, Optional.None); + assignee.AddMembership(assignee, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Member).Value, + Features.Create(TenantFeatures.Basic).Value); + _endUserRepository.Setup(rep => rep.LoadAsync("anassigneeid".ToId(), It.IsAny())) + .ReturnsAsync(assignee); var result = await _application.AssignTenantRolesAsync(_caller.Object, "anorganizationid", "anassigneeid", [TenantRoles.TestingOnly.Name], @@ -795,7 +805,8 @@ public async Task WhenGetMembershipsAsync_ThenReturnsUser() var user = EndUserRoot.Create(_recorder.Object, _idFactory.Object, UserClassification.Person).Value; user.Register(Roles.Create(PlatformRoles.Standard).Value, Features.Create(PlatformFeatures.Basic).Value, EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); - user.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Member).Value, + user.AddMembership(user, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Member).Value, Features.Create(TenantFeatures.PaidTrial).Value); _endUserRepository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(user); diff --git a/src/EndUsersApplication.UnitTests/InvitationsApplication.DomainEventHandlersSpec.cs b/src/EndUsersApplication.UnitTests/InvitationsApplication.DomainEventHandlersSpec.cs index 668514a6..ddf7c64e 100644 --- a/src/EndUsersApplication.UnitTests/InvitationsApplication.DomainEventHandlersSpec.cs +++ b/src/EndUsersApplication.UnitTests/InvitationsApplication.DomainEventHandlersSpec.cs @@ -16,6 +16,7 @@ using UnitTesting.Common; using Xunit; using Events = OrganizationsDomain.Events; +using OrganizationOwnership = Domain.Shared.Organizations.OrganizationOwnership; using PersonName = Application.Resources.Shared.PersonName; namespace EndUsersApplication.UnitTests; @@ -76,6 +77,8 @@ public async Task WhenHandleOrganizationMembershipAddedAsyncWithRegisteredUserEm { var inviter = EndUserRoot .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value; + inviter.AddMembership(inviter, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Owner).Value, Features.Empty); _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) .ReturnsAsync(inviter); _repository.Setup(rep => @@ -132,6 +135,8 @@ public async Task WhenHandleOrganizationMembershipAddedAsyncWithGuestEmailAddres { var inviter = EndUserRoot .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value; + inviter.AddMembership(inviter, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Owner).Value, Features.Empty); _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) .ReturnsAsync(inviter); _repository.Setup(rep => @@ -185,6 +190,8 @@ public async Task WhenHandleOrganizationMembershipAddedAsyncWithUserId_ThenAddsM { var inviter = EndUserRoot .Create(_recorder.Object, "aninviterid".ToIdentifierFactory(), UserClassification.Person).Value; + inviter.AddMembership(inviter, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Owner).Value, Features.Empty); _repository.Setup(rep => rep.LoadAsync("aninviterid".ToId(), It.IsAny())) .ReturnsAsync(inviter); var invitee = EndUserRoot diff --git a/src/EndUsersApplication/EndUsersApplication.cs b/src/EndUsersApplication/EndUsersApplication.cs index 2228d578..1ccada0e 100644 --- a/src/EndUsersApplication/EndUsersApplication.cs +++ b/src/EndUsersApplication/EndUsersApplication.cs @@ -45,7 +45,7 @@ public EndUsersApplication(IRecorder recorder, IIdentifierFactory idFactory, ICo _endUserRepository = endUserRepository; } - public async Task> GetPersonAsync(ICallerContext context, string id, + public async Task> GetUserAsync(ICallerContext context, string id, CancellationToken cancellationToken) { var retrieved = await _endUserRepository.LoadAsync(id.ToId(), cancellationToken); @@ -136,33 +136,37 @@ public async Task> RegisterMachineAsync(ICaller if (context.IsAuthenticated) { - var adder = await _endUserRepository.LoadAsync(context.ToCallerId(), cancellationToken); - if (!adder.IsSuccessful) + var retrievedAdder = await _endUserRepository.LoadAsync(context.ToCallerId(), cancellationToken); + if (!retrievedAdder.IsSuccessful) { - return adder.Error; + return retrievedAdder.Error; } - var (_, _, tenantRoles2, tenantFeatures2) = - EndUserRoot.GetInitialRolesAndFeatures(RolesAndFeaturesUseCase.InvitingMachineToCreatorOrg, - context.IsAuthenticated); - var adderDefaultOrganizationId = adder.Value.DefaultMembership.OrganizationId; - var adderEnrolled = machine.AddMembership(adderDefaultOrganizationId, tenantRoles2, - tenantFeatures2); - if (!adderEnrolled.IsSuccessful) + var adder = retrievedAdder.Value; + var adderDefaultMembership = adder.DefaultMembership; + if (adderDefaultMembership.IsShared) { - return adderEnrolled.Error; - } + var (_, _, tenantRoles2, tenantFeatures2) = + EndUserRoot.GetInitialRolesAndFeatures(RolesAndFeaturesUseCase.InvitingMachineToCreatorOrg, + context.IsAuthenticated); + var adderEnrolled = machine.AddMembership(adder, adderDefaultMembership.Ownership, + adderDefaultMembership.OrganizationId, tenantRoles2, tenantFeatures2); + if (!adderEnrolled.IsSuccessful) + { + return adderEnrolled.Error; + } - saved = await _endUserRepository.SaveAsync(saved.Value, cancellationToken); - if (!saved.IsSuccessful) - { - return saved.Error; - } + saved = await _endUserRepository.SaveAsync(saved.Value, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } - machine = saved.Value; - _recorder.TraceInformation(context.ToCall(), - "Machine {Id} has become a member of {User} organization {Organization}", - machine.Id, adder.Value.Id, adderDefaultOrganizationId); + machine = saved.Value; + _recorder.TraceInformation(context.ToCall(), + "Machine {Id} has become a member of {User} organization {Organization}", + machine.Id, adder.Id, adderDefaultMembership.OrganizationId); + } } var defaultOrganizationId = machine.DefaultMembership.OrganizationId; @@ -494,37 +498,38 @@ private async Task> CreateMembershipAsync(ICallerConte Identifier createdById, Identifier organizationId, OrganizationOwnership ownership, CancellationToken cancellationToken) { - var retrieved = await _endUserRepository.LoadAsync(createdById, cancellationToken); - if (!retrieved.IsSuccessful) + var retrievedInviter = await _endUserRepository.LoadAsync(createdById, cancellationToken); + if (!retrievedInviter.IsSuccessful) { - return retrieved.Error; + return retrievedInviter.Error; } - var creator = retrieved.Value; + var inviter = retrievedInviter.Value; var useCase = ownership switch { OrganizationOwnership.Shared => RolesAndFeaturesUseCase.CreatingOrg, - OrganizationOwnership.Personal => creator.Classification == UserClassification.Person + OrganizationOwnership.Personal => inviter.Classification == UserClassification.Person ? RolesAndFeaturesUseCase.CreatingPerson : RolesAndFeaturesUseCase.CreatingMachine, _ => RolesAndFeaturesUseCase.CreatingOrg }; var (_, _, tenantRoles, tenantFeatures) = EndUserRoot.GetInitialRolesAndFeatures(useCase, context.IsAuthenticated); - var membered = creator.AddMembership(organizationId, tenantRoles, tenantFeatures); + var inviterOwnership = ownership.ToEnumOrDefault(Domain.Shared.Organizations.OrganizationOwnership.Shared); + var membered = inviter.AddMembership(inviter, inviterOwnership, organizationId, tenantRoles, tenantFeatures); if (!membered.IsSuccessful) { return membered.Error; } - var saved = await _endUserRepository.SaveAsync(creator, cancellationToken); + var saved = await _endUserRepository.SaveAsync(inviter, cancellationToken); if (!saved.IsSuccessful) { return saved.Error; } _recorder.TraceInformation(context.ToCall(), "EndUser {Id} has become a member of organization {Organization}", - creator.Id, organizationId); + inviter.Id, organizationId); var membership = saved.Value.FindMembership(organizationId); if (!membership.HasValue) @@ -603,17 +608,17 @@ private async Task, Error>> private async Task, Error>> FindProfileWithEmailAddressAsync( ICallerContext caller, EmailAddress emailAddress, CancellationToken cancellationToken) { - var retrieved = + var retrievedProfile = await _userProfilesService.FindPersonByEmailAddressPrivateAsync(caller, emailAddress, cancellationToken); - if (!retrieved.IsSuccessful) + if (!retrievedProfile.IsSuccessful) { - return retrieved.Error; + return retrievedProfile.Error; } - if (retrieved.Value.HasValue) + if (retrievedProfile.Value.HasValue) { - var profile = retrieved.Value.Value; + var profile = retrievedProfile.Value.Value; var user = await _endUserRepository.LoadAsync(profile.UserId.ToId(), cancellationToken); if (!user.IsSuccessful) { diff --git a/src/EndUsersApplication/IEndUsersApplication.cs b/src/EndUsersApplication/IEndUsersApplication.cs index a31956ff..2bfe90d8 100644 --- a/src/EndUsersApplication/IEndUsersApplication.cs +++ b/src/EndUsersApplication/IEndUsersApplication.cs @@ -19,7 +19,7 @@ Task, Error>> FindPersonByEmailAddressAsync(ICallerCont Task> GetMembershipsAsync(ICallerContext context, string id, CancellationToken cancellationToken); - Task> GetPersonAsync(ICallerContext context, string id, CancellationToken cancellationToken); + Task> GetUserAsync(ICallerContext context, string id, CancellationToken cancellationToken); Task, Error>> ListMembershipsForOrganizationAsync( ICallerContext caller, diff --git a/src/EndUsersApplication/InvitationsApplication.DomainEventHandlers.cs b/src/EndUsersApplication/InvitationsApplication.DomainEventHandlers.cs index 0bf5aa98..e6f18289 100644 --- a/src/EndUsersApplication/InvitationsApplication.DomainEventHandlers.cs +++ b/src/EndUsersApplication/InvitationsApplication.DomainEventHandlers.cs @@ -8,12 +8,10 @@ namespace EndUsersApplication; partial class InvitationsApplication { public async Task> HandleOrganizationMembershipAddedAsync(ICallerContext caller, - MembershipAdded domainEvent, - CancellationToken cancellationToken) + MembershipAdded domainEvent, CancellationToken cancellationToken) { var membership = await InviteMemberToOrganizationAsync(caller, domainEvent.RootId.ToId(), - domainEvent.InvitedById, domainEvent.UserId, - domainEvent.EmailAddress, cancellationToken); + domainEvent.InvitedById, domainEvent.UserId, domainEvent.EmailAddress, cancellationToken); if (!membership.IsSuccessful) { return membership.Error; diff --git a/src/EndUsersApplication/InvitationsApplication.cs b/src/EndUsersApplication/InvitationsApplication.cs index fee68cd3..2057c1fe 100644 --- a/src/EndUsersApplication/InvitationsApplication.cs +++ b/src/EndUsersApplication/InvitationsApplication.cs @@ -12,6 +12,7 @@ using EndUsersApplication.Persistence; using EndUsersDomain; using Membership = Application.Resources.Shared.Membership; +using OrganizationOwnership = Domain.Shared.Organizations.OrganizationOwnership; namespace EndUsersApplication; @@ -147,6 +148,12 @@ private async Task> InviteMemberToOrganizationAsync(IC .InvitationsApplication_InviteMemberToOrganization_NoUserIdNorEmailAddress); } + var inviter = await _repository.LoadAsync(invitedById.ToId(), cancellationToken); + if (!inviter.IsSuccessful) + { + return inviter.Error; + } + EndUserRoot invitee = null!; if (emailAddress.HasValue()) { @@ -173,7 +180,8 @@ private async Task> InviteMemberToOrganizationAsync(IC var (_, _, tenantRoles, tenantFeatures) = EndUserRoot.GetInitialRolesAndFeatures(RolesAndFeaturesUseCase.InvitingMemberToOrg, context.IsAuthenticated); - var enrolled = invitee.AddMembership(organizationId.ToId(), tenantRoles, tenantFeatures); + var enrolled = invitee.AddMembership(inviter.Value, OrganizationOwnership.Shared, organizationId.ToId(), + tenantRoles, tenantFeatures); if (!enrolled.IsSuccessful) { return enrolled.Error; diff --git a/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs b/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs index 7bb9f841..31e65417 100644 --- a/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs +++ b/src/EndUsersDomain.UnitTests/EndUserRootSpec.cs @@ -9,703 +9,1033 @@ using Domain.Services.Shared.DomainServices; using Domain.Shared; using Domain.Shared.EndUsers; +using Domain.Shared.Organizations; using FluentAssertions; +using JetBrains.Annotations; using Moq; using UnitTesting.Common; using Xunit; namespace EndUsersDomain.UnitTests; -[Trait("Category", "Unit")] +[UsedImplicitly] public class EndUserRootSpec { - private readonly Mock _identifierFactory; - private readonly Mock _recorder; - private readonly Mock _tokensService; - private readonly EndUserRoot _user; - - public EndUserRootSpec() - { - _recorder = new Mock(); - _identifierFactory = new Mock(); - var counter = 0; - _identifierFactory.Setup(idf => idf.Create(It.IsAny())) - .Returns((IIdentifiableEntity entity) => - { - if (entity is Membership) - { - return $"amembershipid{++counter}".ToId(); - } - - return "anid".ToId(); - }); - _tokensService = new Mock(); - _tokensService.Setup(ts => ts.CreateGuestInvitationToken()) - .Returns("aninvitationtoken"); - - _user = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person).Value; - } - - [Fact] - public void WhenConstructed_ThenAssigned() - { - _user.Access.Should().Be(UserAccess.Enabled); - _user.Status.Should().Be(UserStatus.Unregistered); - _user.Classification.Should().Be(UserClassification.Person); - _user.Roles.HasNone().Should().BeTrue(); - _user.Features.HasNone().Should().BeTrue(); - _user.GuestInvitation.IsInvited.Should().BeFalse(); - } - - [Fact] - public async Task WhenRegisterAndInvitedAsGuest_ThenAcceptsInvitationAndRegistered() + private static EndUserRoot CreateOrgOwner(Mock recorder, string organizationId, + UserClassification classification = UserClassification.Person) { - var emailAddress = EmailAddress.Create("auser@company.com").Value; - var userProfile = EndUserProfile.Create("afirstname").Value; - await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, - (_, _) => Task.FromResult(Result.Ok)); - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, userProfile, emailAddress); - - _user.Access.Should().Be(UserAccess.Enabled); - _user.Status.Should().Be(UserStatus.Registered); - _user.Classification.Should().Be(UserClassification.Person); - _user.Roles.Items.Should().ContainInOrder(Role.Create(PlatformRoles.Standard.Name).Value); - _user.Features.Items.Should().ContainInOrder(Feature.Create(PlatformFeatures.Basic.Name).Value); - _user.GuestInvitation.IsAccepted.Should().BeTrue(); - _user.GuestInvitation.AcceptedEmailAddress.Should().Be(emailAddress); - _user.Events[2].Should().BeOfType(); - _user.Events.Last().Should().BeOfType(); - } - - [Fact] - public void WhenRegister_ThenRegistered() - { - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + var owner = EndUserRoot.Create(recorder.Object, "anownerid".ToIdentifierFactory(), classification) + .Value; + owner.Register(Roles.Create(PlatformRoles.Standard.Name).Value, Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, - EmailAddress.Create("auser@company.com").Value); - - _user.Access.Should().Be(UserAccess.Enabled); - _user.Status.Should().Be(UserStatus.Registered); - _user.Classification.Should().Be(UserClassification.Person); - _user.Roles.Items.Should().ContainInOrder(Role.Create(PlatformRoles.Standard.Name).Value); - _user.Features.Items.Should().ContainInOrder(Feature.Create(PlatformFeatures.Basic.Name).Value); - _user.Events.Last().Should().BeOfType(); - } - - [Fact] - public void WhenEnsureInvariantsAndMachineIsNotRegistered_ThenReturnsError() - { - var machine = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Machine).Value; - - var result = machine.EnsureInvariants(); - - result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_MachineNotRegistered); - } - - [Fact] - public void WhenEnsureInvariantsAndRegisteredPersonDoesNotHaveADefaultRole_ThenReturnsError() - { - _user.Register(Roles.Create(), Features.Create(PlatformFeatures.Basic.Name).Value, - EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); - - var result = _user.EnsureInvariants(); - - result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_AllPersonsMustHaveDefaultRole); - } - - [Fact] - public void WhenEnsureInvariantsAndRegisteredPersonDoesNotHaveADefaultFeature_ThenReturnsError() - { - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(), EndUserProfile.Create("afirstname").Value, - EmailAddress.Create("auser@company.com").Value); - - var result = _user.EnsureInvariants(); + EmailAddress.Create("orgowner@company.com").Value); + owner.AddMembership(owner, OrganizationOwnership.Shared, organizationId.ToId(), + Roles.Create(TenantRoles.Owner).Value, Features.Empty); - result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_AllPersonsMustHaveDefaultFeature); + return owner; } - [Fact] - public void WhenEnsureInvariantsAndRegisteredPersonStillInvited_ThenReturnsError() + private static EndUserRoot CreateOrgMember(Mock recorder, string organizationId) { - var emailAddress = EmailAddress.Create("auser@company.com").Value; - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + var owner = EndUserRoot + .Create(recorder.Object, "amemberid".ToIdentifierFactory(), UserClassification.Person) + .Value; + owner.Register(Roles.Create(PlatformRoles.Standard.Name).Value, Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, - emailAddress); -#if TESTINGONLY - _user.TestingOnly_InviteGuest(emailAddress); -#endif - - var result = _user.EnsureInvariants(); + EmailAddress.Create("orgowner@company.com").Value); + owner.AddMembership(owner, OrganizationOwnership.Shared, organizationId.ToId(), + Roles.Create(TenantRoles.Member).Value, Features.Empty); - result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_GuestAlreadyRegistered); + return owner; } - [Fact] - public void WhenAddMembershipAndAlreadyMember_ThenReturns() + private static EndUserRoot CreateOperator(Mock recorder, Mock identifierFactory) { - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + var @operator = EndUserRoot.Create(recorder.Object, identifierFactory.Object, UserClassification.Person) + .Value; + @operator.Register(Roles.Create(PlatformRoles.Standard.Name, PlatformRoles.Operations.Name).Value, Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, - EmailAddress.Create("auser@company.com").Value); - _user.AddMembership("anorganizationid".ToId(), Roles.Create(), Features.Create()); - - var result = _user.AddMembership("anorganizationid".ToId(), Roles.Create(), Features.Create()); + EmailAddress.Create("operator@company.com").Value); - result.Should().BeSuccess(); + return @operator; } - [Fact] - public void WhenAddMembership_ThenAddsMembershipAsDefaultWithRolesAndFeatures() + [Trait("Category", "Unit")] + public class GivenAPerson { - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, - EmailAddress.Create("auser@company.com").Value); - var roles = Roles.Create(TenantRoles.Member).Value; - var features = Features.Create(TenantFeatures.Basic).Value; - - var result = _user.AddMembership("anorganizationid".ToId(), roles, features); - - result.Should().BeSuccess(); - _user.Memberships.Should().ContainSingle(ms => - ms.OrganizationId.Value == "anorganizationid" - && ms.IsDefault - && ms.Roles == roles - && ms.Features == features); - _user.Events.Last().Should().BeOfType(); - } + private readonly Mock _identifierFactory; + private readonly Mock _recorder; + private readonly Mock _tokensService; + private readonly EndUserRoot _user; + + public GivenAPerson() + { + _recorder = new Mock(); + var counter = 0; + _identifierFactory = new Mock(); + _identifierFactory.Setup(idf => idf.Create(It.IsAny())) + .Returns((IIdentifiableEntity entity) => + { + if (entity is Membership) + { + return $"amembershipid{++counter}".ToId(); + } + + return "anid".ToId(); + }); + _tokensService = new Mock(); + _tokensService.Setup(ts => ts.CreateGuestInvitationToken()) + .Returns("aninvitationtoken"); + + _user = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person).Value; + } + + [Fact] + public void WhenConstructed_ThenAssigned() + { + _user.Access.Should().Be(UserAccess.Enabled); + _user.Status.Should().Be(UserStatus.Unregistered); + _user.Classification.Should().Be(UserClassification.Person); + _user.Roles.HasNone().Should().BeTrue(); + _user.Features.HasNone().Should().BeTrue(); + _user.GuestInvitation.IsInvited.Should().BeFalse(); + } + + [Fact] + public async Task WhenRegisterAndInvitedAsGuest_ThenAcceptsInvitationAndRegistered() + { + var emailAddress = EmailAddress.Create("auser@company.com").Value; + var userProfile = EndUserProfile.Create("afirstname").Value; + await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, + (_, _) => Task.FromResult(Result.Ok)); + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, userProfile, emailAddress); + + _user.Access.Should().Be(UserAccess.Enabled); + _user.Status.Should().Be(UserStatus.Registered); + _user.Classification.Should().Be(UserClassification.Person); + _user.Roles.Items.Should().ContainInOrder(Role.Create(PlatformRoles.Standard.Name).Value); + _user.Features.Items.Should().ContainInOrder(Feature.Create(PlatformFeatures.Basic.Name).Value); + _user.GuestInvitation.IsAccepted.Should().BeTrue(); + _user.GuestInvitation.AcceptedEmailAddress.Should().Be(emailAddress); + _user.Events[2].Should().BeOfType(); + _user.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenRegister_ThenRegistered() + { + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + + _user.Access.Should().Be(UserAccess.Enabled); + _user.Status.Should().Be(UserStatus.Registered); + _user.Classification.Should().Be(UserClassification.Person); + _user.Roles.Items.Should().ContainInOrder(Role.Create(PlatformRoles.Standard.Name).Value); + _user.Features.Items.Should().ContainInOrder(Feature.Create(PlatformFeatures.Basic.Name).Value); + _user.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenEnsureInvariantsAndRegisteredPersonDoesNotHaveADefaultRole_ThenReturnsError() + { + _user.Register(Roles.Empty, Features.Create(PlatformFeatures.Basic.Name).Value, + EndUserProfile.Create("afirstname").Value, EmailAddress.Create("auser@company.com").Value); + + var result = _user.EnsureInvariants(); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_AllPersonsMustHaveDefaultRole); + } + + [Fact] + public void WhenEnsureInvariantsAndRegisteredPersonDoesNotHaveADefaultFeature_ThenReturnsError() + { + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Empty, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + + var result = _user.EnsureInvariants(); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_AllPersonsMustHaveDefaultFeature); + } + + [Fact] + public void WhenEnsureInvariantsAndRegisteredPersonStillInvited_ThenReturnsError() + { + var emailAddress = EmailAddress.Create("auser@company.com").Value; + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + emailAddress); +#if TESTINGONLY + _user.TestingOnly_InviteGuest(emailAddress); +#endif - [Fact] - public void WhenAddMembershipAndHasMembership_ThenChangesNextToDefaultMembership() - { - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, - EmailAddress.Create("auser@company.com").Value); - var roles = Roles.Create(TenantRoles.Member).Value; - var features = Features.Create(TenantFeatures.Basic).Value; - _user.AddMembership("anorganizationid1".ToId(), roles, features); - - var result = _user.AddMembership("anorganizationid2".ToId(), roles, features); - - result.Should().BeSuccess(); - _user.Memberships.Should().Contain(ms => - ms.OrganizationId.Value == "anorganizationid1" - && !ms.IsDefault - && ms.Roles == roles - && ms.Features == features); - _user.Memberships.Should().Contain(ms => - ms.OrganizationId.Value == "anorganizationid2" - && ms.IsDefault - && ms.Roles == roles - && ms.Features == features); - _user.Events.Last().Should().BeOfType(); - } + var result = _user.EnsureInvariants(); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_GuestAlreadyRegistered); + } + + [Fact] + public void WhenAddMembershipByNonOwner_ThenReturnsError() + { + var inviter = CreateOrgMember(_recorder, "anorganizationid"); + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + + var result = _user.AddMembership(inviter, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Empty, + Features.Empty); + + result.Should().BeError(ErrorCode.RoleViolation, Resources.EndUserRoot_NotOrganizationOwner); + } + + [Fact] + public void WhenAddMembershipAndAlreadyMember_ThenReturns() + { + var inviter = CreateOrgOwner(_recorder, "anorganizationid"); + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + _user.AddMembership(inviter, OrganizationOwnership.Shared, "anorganizationid".ToId(), Roles.Empty, + Features.Empty); + + var result = _user.AddMembership(inviter, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Empty, + Features.Empty); + + result.Should().BeSuccess(); + } + + [Fact] + public void WhenAddMembershipToPersonsSharedOrganization_ThenAddsMembership() + { + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + var inviter = CreateOrgOwner(_recorder, "anorganizationid"); + + var roles = Roles.Create(TenantRoles.Member).Value; + var features = Features.Create(TenantFeatures.Basic).Value; + var result = _user.AddMembership(inviter, OrganizationOwnership.Shared, "anorganizationid".ToId(), + roles, features); + + result.Should().BeSuccess(); + _user.Memberships.Should().Contain(ms => + ms.OrganizationId.Value == "anorganizationid" + && ms.IsDefault + && ms.Roles == roles + && ms.Features == features); + _user.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenAddMembershipToPersonsPersonalOrganization_ThenReturnsError() + { + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + var inviter = CreateOrgOwner(_recorder, "anorganizationid"); + + var result = _user.AddMembership(inviter, OrganizationOwnership.Personal, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Member).Value, Features.Create(TenantFeatures.Basic).Value); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.EndUserRoot_Addmembership_SharedOwnershipRequired); + } + + [Fact] + public void WhenAddMembershipToMachinesPersonalOrganization_ThenReturnsError() + { + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + var inviter = CreateOrgOwner(_recorder, "anorganizationid", UserClassification.Machine); + var roles = Roles.Create(TenantRoles.Member).Value; + var features = Features.Create(TenantFeatures.Basic).Value; + + var result = _user.AddMembership(inviter, OrganizationOwnership.Personal, "anorganizationid".ToId(), + roles, features); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.EndUserRoot_Addmembership_SharedOwnershipRequired); + } + + [Fact] + public void WhenAddMembershipToMachinesSharedOrganization_ThenAddsMembership() + { + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + var inviter = CreateOrgOwner(_recorder, "anorganizationid", UserClassification.Machine); + var roles = Roles.Create(TenantRoles.Member).Value; + var features = Features.Create(TenantFeatures.Basic).Value; + + var result = _user.AddMembership(inviter, OrganizationOwnership.Shared, "anorganizationid".ToId(), + roles, features); + + result.Should().BeSuccess(); + _user.Memberships.Should().Contain(ms => + ms.OrganizationId.Value == "anorganizationid" + && ms.IsDefault + && ms.Roles == roles + && ms.Features == features); + _user.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenAddMembershipToSelfPersonalOrganization_ThenAddsMembership() + { + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + var roles = Roles.Create(TenantRoles.Member).Value; + var features = Features.Create(TenantFeatures.Basic).Value; + + var result = _user.AddMembership(_user, OrganizationOwnership.Personal, "anorganizationid".ToId(), + roles, features); + + result.Should().BeSuccess(); + _user.Memberships.Should().Contain(ms => + ms.OrganizationId.Value == "anorganizationid" + && ms.IsDefault + && ms.Roles == roles + && ms.Features == features); + _user.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenAddMembershipToSelfSharedOrganization_ThenAddsMembership() + { + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + var roles = Roles.Create(TenantRoles.Member).Value; + var features = Features.Create(TenantFeatures.Basic).Value; + + var result = _user.AddMembership(_user, OrganizationOwnership.Shared, "anorganizationid".ToId(), + roles, features); + + result.Should().BeSuccess(); + _user.Memberships.Should().Contain(ms => + ms.OrganizationId.Value == "anorganizationid" + && ms.IsDefault + && ms.Roles == roles + && ms.Features == features); + _user.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenAddMembership_ThenAddsMembershipAsDefaultWithRolesAndFeatures() + { + var inviter = CreateOrgOwner(_recorder, "anorganizationid"); + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + var roles = Roles.Create(TenantRoles.Member).Value; + var features = Features.Create(TenantFeatures.Basic).Value; + + var result = _user.AddMembership(inviter, OrganizationOwnership.Shared, "anorganizationid".ToId(), roles, + features); + + result.Should().BeSuccess(); + _user.Memberships.Should().ContainSingle(ms => + ms.OrganizationId.Value == "anorganizationid" + && ms.IsDefault + && ms.Roles == roles + && ms.Features == features); + _user.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenAddMembershipAndAlreadyHasMembership_ThenChangesToDefaultMembership() + { + var inviter = CreateOrgOwner(_recorder, "anorganizationid2"); + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + var roles = Roles.Create(TenantRoles.Member).Value; + var features = Features.Create(TenantFeatures.Basic).Value; + _user.AddMembership(_user, OrganizationOwnership.Shared, "anorganizationid1".ToId(), roles, features); + + var result = _user.AddMembership(inviter, OrganizationOwnership.Shared, "anorganizationid2".ToId(), roles, + features); + + result.Should().BeSuccess(); + _user.Memberships.Should().Contain(ms => + ms.OrganizationId.Value == "anorganizationid1" + && !ms.IsDefault + && ms.Roles == roles + && ms.Features == features); + _user.Memberships.Should().Contain(ms => + ms.OrganizationId.Value == "anorganizationid2" + && ms.IsDefault + && ms.Roles == roles + && ms.Features == features); + _user.Events.Last().Should().BeOfType(); + } #if TESTINGONLY - [Fact] - public void WhenAssignMembershipFeaturesAndAssignerNotOwner_ThenReturnsError() - { - var assigner = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person).Value; + [Fact] + public void WhenAssignMembershipFeaturesAndAssignerNotOwner_ThenReturnsError() + { + var assigner = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person) + .Value; - var result = _user.AssignMembershipFeatures(assigner, "anorganizationid".ToId(), - Features.Create(TenantFeatures.TestingOnly).Value); + var result = _user.AssignMembershipFeatures(assigner, "anorganizationid".ToId(), + Features.Create(TenantFeatures.TestingOnly).Value); - result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_NotOrganizationOwner); - } + result.Should().BeError(ErrorCode.RoleViolation, Resources.EndUserRoot_NotOrganizationOwner); + } #endif #if TESTINGONLY - [Fact] - public void WhenAssignMembershipFeaturesAndNoMembership_ThenReturnsError() - { - var assigner = CreateOrgOwner("anorganizationid"); + [Fact] + public void WhenAssignMembershipFeaturesAndNoMembership_ThenReturnsError() + { + var assigner = CreateOrgOwner(_recorder, "anorganizationid"); - var result = _user.AssignMembershipFeatures(assigner, "anorganizationid".ToId(), - Features.Create(TenantFeatures.TestingOnly).Value); + var result = _user.AssignMembershipFeatures(assigner, "anorganizationid".ToId(), + Features.Create(TenantFeatures.TestingOnly).Value); - result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_NoMembership.Format("anorganizationid")); - } + result.Should().BeError(ErrorCode.RuleViolation, + Resources.EndUserRoot_NoMembership.Format("anorganizationid")); + } #endif - [Fact] - public void WhenAssignMembershipFeaturesAndFeatureNotAssignable_ThenReturnsError() - { - var assigner = CreateOrgOwner("anorganizationid"); - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, - EmailAddress.Create("auser@company.com").Value); - _user.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Member).Value, - Features.Create(TenantFeatures.Basic).Value); + [Fact] + public void WhenAssignMembershipFeaturesAndFeatureNotAssignable_ThenReturnsError() + { + var assigner = CreateOrgOwner(_recorder, "anorganizationid"); + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + _user.AddMembership(assigner, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Member).Value, + Features.Create(TenantFeatures.Basic).Value); - var result = _user.AssignMembershipFeatures(assigner, "anorganizationid".ToId(), - Features.Create("anunknownfeature").Value); + var result = _user.AssignMembershipFeatures(assigner, "anorganizationid".ToId(), + Features.Create("anunknownfeature").Value); - result.Should().BeError(ErrorCode.RuleViolation, - Resources.EndUserRoot_UnassignableTenantFeature.Format("anunknownfeature")); - } + result.Should().BeError(ErrorCode.RuleViolation, + Resources.EndUserRoot_UnassignableTenantFeature.Format("anunknownfeature")); + } #if TESTINGONLY - [Fact] - public void WhenAssignMembershipFeatures_ThenAssigns() - { - var assigner = CreateOrgOwner("anorganizationid"); - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, - EmailAddress.Create("auser@company.com").Value); - _user.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Member).Value, - Features.Create(TenantFeatures.Basic).Value); - - var result = _user.AssignMembershipFeatures(assigner, "anorganizationid".ToId(), - Features.Create(TenantFeatures.TestingOnly).Value); - - result.Should().BeSuccess(); - _user.Memberships[0].Roles.Should().Be(Roles.Create(TenantRoles.Member.Name).Value); - _user.Memberships[0].Features.Should() - .Be(Features.Create(TenantFeatures.Basic.Name, TenantFeatures.TestingOnly.Name).Value); - _user.Events.Last().Should().BeOfType(); - } + [Fact] + public void WhenAssignMembershipFeatures_ThenAssigns() + { + var assigner = CreateOrgOwner(_recorder, "anorganizationid"); + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + _user.AddMembership(assigner, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Member).Value, + Features.Create(TenantFeatures.Basic).Value); + + var result = _user.AssignMembershipFeatures(assigner, "anorganizationid".ToId(), + Features.Create(TenantFeatures.TestingOnly).Value); + + result.Should().BeSuccess(); + _user.Memberships[0].Roles.Should().Be(Roles.Create(TenantRoles.Member.Name).Value); + _user.Memberships[0].Features.Should() + .Be(Features.Create(TenantFeatures.Basic.Name, TenantFeatures.TestingOnly.Name).Value); + _user.Events.Last().Should().BeOfType(); + } #endif #if TESTINGONLY - [Fact] - public void WhenAssignMembershipRolesAndAssignerNotOwner_ThenReturnsError() - { - var assigner = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person).Value; + [Fact] + public void WhenAssignMembershipRolesAndAssignerNotOwner_ThenReturnsError() + { + var assigner = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person) + .Value; - var result = _user.AssignMembershipRoles(assigner, "anorganizationid".ToId(), - Roles.Create(TenantRoles.TestingOnly).Value); + var result = _user.AssignMembershipRoles(assigner, "anorganizationid".ToId(), + Roles.Create(TenantRoles.TestingOnly).Value); - result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_NotOrganizationOwner); - } + result.Should().BeError(ErrorCode.RoleViolation, Resources.EndUserRoot_NotOrganizationOwner); + } #endif - [Fact] - public void WhenAssignMembershipRolesAndRoleNotAssignable_ThenReturnsError() - { - var assigner = CreateOrgOwner("anorganizationid"); - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, - EmailAddress.Create("auser@company.com").Value); - _user.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Member).Value, - Features.Create(TenantFeatures.Basic).Value); + [Fact] + public void WhenAssignMembershipRolesAndRoleNotAssignable_ThenReturnsError() + { + var assigner = CreateOrgOwner(_recorder, "anorganizationid"); + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + _user.AddMembership(assigner, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Member).Value, + Features.Create(TenantFeatures.Basic).Value); - var result = _user.AssignMembershipRoles(assigner, "anorganizationid".ToId(), - Roles.Create("anunknownrole").Value); + var result = _user.AssignMembershipRoles(assigner, "anorganizationid".ToId(), + Roles.Create("anunknownrole").Value); - result.Should().BeError(ErrorCode.RuleViolation, - Resources.EndUserRoot_UnassignableTenantRole.Format("anunknownrole")); - } + result.Should().BeError(ErrorCode.RuleViolation, + Resources.EndUserRoot_UnassignableTenantRole.Format("anunknownrole")); + } #if TESTINGONLY - [Fact] - public void WhenAssignMembershipRoles_ThenAssigns() - { - var assigner = CreateOrgOwner("anorganizationid"); - _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, - EmailAddress.Create("auser@company.com").Value); - _user.AddMembership("anorganizationid".ToId(), Roles.Create(TenantRoles.Member).Value, - Features.Create(TenantFeatures.Basic).Value); - - var result = _user.AssignMembershipRoles(assigner, "anorganizationid".ToId(), - Roles.Create(TenantRoles.TestingOnly).Value); - - result.Should().BeSuccess(); - _user.Memberships[0].Roles.Should() - .Be(Roles.Create(TenantRoles.Member.Name, TenantRoles.TestingOnly.Name).Value); - _user.Memberships[0].Features.Should() - .Be(Features.Create(TenantFeatures.Basic.Name).Value); - _user.Events.Last().Should().BeOfType(); - } + [Fact] + public void WhenAssignMembershipRoles_ThenAssigns() + { + var assigner = CreateOrgOwner(_recorder, "anorganizationid"); + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + _user.AddMembership(assigner, OrganizationOwnership.Shared, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Member).Value, + Features.Create(TenantFeatures.Basic).Value); + + var result = _user.AssignMembershipRoles(assigner, "anorganizationid".ToId(), + Roles.Create(TenantRoles.TestingOnly).Value); + + result.Should().BeSuccess(); + _user.Memberships[0].Roles.Should() + .Be(Roles.Create(TenantRoles.Member.Name, TenantRoles.TestingOnly.Name).Value); + _user.Memberships[0].Features.Should() + .Be(Features.Create(TenantFeatures.Basic.Name).Value); + _user.Events.Last().Should().BeOfType(); + } #endif #if TESTINGONLY - [Fact] - public void WhenAssignPlatformFeaturesAndAssignerNotOperator_ThenReturnsError() - { - var assigner = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person).Value; + [Fact] + public void WhenAssignPlatformFeaturesAndAssignerNotOperator_ThenReturnsError() + { + var assigner = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person) + .Value; - var result = _user.AssignPlatformFeatures(assigner, Features.Create(PlatformFeatures.TestingOnly).Value); + var result = _user.AssignPlatformFeatures(assigner, Features.Create(PlatformFeatures.TestingOnly).Value); - result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_NotOperator); - } + result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_NotOperator); + } #endif - [Fact] - public void WhenAssignPlatformFeaturesAndFeatureNotAssignable_ThenReturnsError() - { - var assigner = CreateOperator(); + [Fact] + public void WhenAssignPlatformFeaturesAndFeatureNotAssignable_ThenReturnsError() + { + var assigner = CreateOperator(_recorder, _identifierFactory); - var result = _user.AssignPlatformFeatures(assigner, Features.Create("anunknownfeature").Value); + var result = _user.AssignPlatformFeatures(assigner, Features.Create("anunknownfeature").Value); - result.Should().BeError(ErrorCode.RuleViolation, - Resources.EndUserRoot_UnassignablePlatformFeature.Format("anunknownfeature")); - } + result.Should().BeError(ErrorCode.RuleViolation, + Resources.EndUserRoot_UnassignablePlatformFeature.Format("anunknownfeature")); + } #if TESTINGONLY - [Fact] - public void WhenAssignPlatformFeatures_ThenAssigns() - { - var assigner = CreateOperator(); - - var result = _user.AssignPlatformFeatures(assigner, Features.Create(PlatformFeatures.TestingOnly).Value); - - result.Should().BeSuccess(); - _user.Roles.HasNone().Should().BeTrue(); - _user.Features.Should().Be(Features.Create(PlatformFeatures.TestingOnly.Name).Value); - _user.Events.Last().Should().BeOfType(); - } + [Fact] + public void WhenAssignPlatformFeatures_ThenAssigns() + { + var assigner = CreateOperator(_recorder, _identifierFactory); + + var result = _user.AssignPlatformFeatures(assigner, Features.Create(PlatformFeatures.TestingOnly).Value); + + result.Should().BeSuccess(); + _user.Roles.HasNone().Should().BeTrue(); + _user.Features.Should().Be(Features.Create(PlatformFeatures.TestingOnly.Name).Value); + _user.Events.Last().Should().BeOfType(); + } #endif #if TESTINGONLY - [Fact] - public void WhenAssignPlatformRolesAndAssignerNotOperator_ThenReturnsError() - { - var assigner = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person).Value; + [Fact] + public void WhenAssignPlatformRolesAndAssignerNotOperator_ThenReturnsError() + { + var assigner = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person) + .Value; - var result = _user.AssignPlatformRoles(assigner, Roles.Create(PlatformRoles.TestingOnly).Value); + var result = _user.AssignPlatformRoles(assigner, Roles.Create(PlatformRoles.TestingOnly).Value); - result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_NotOperator); - } + result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_NotOperator); + } #endif - [Fact] - public void WhenAssignPlatformRolesAndRoleNotAssignable_ThenReturnsError() - { - var assigner = CreateOperator(); + [Fact] + public void WhenAssignPlatformRolesAndRoleNotAssignable_ThenReturnsError() + { + var assigner = CreateOperator(_recorder, _identifierFactory); - var result = _user.AssignPlatformRoles(assigner, Roles.Create("anunknownrole").Value); + var result = _user.AssignPlatformRoles(assigner, Roles.Create("anunknownrole").Value); - result.Should().BeError(ErrorCode.RuleViolation, - Resources.EndUserRoot_UnassignablePlatformRole.Format("anunknownrole")); - } + result.Should().BeError(ErrorCode.RuleViolation, + Resources.EndUserRoot_UnassignablePlatformRole.Format("anunknownrole")); + } #if TESTINGONLY - [Fact] - public void WhenAssignPlatformRoles_ThenAssigns() - { - var assigner = CreateOperator(); - - var result = _user.AssignPlatformRoles(assigner, Roles.Create(PlatformRoles.TestingOnly).Value); - - result.Should().BeSuccess(); - _user.Roles.Should().Be(Roles.Create(PlatformRoles.TestingOnly.Name).Value); - _user.Features.HasNone().Should().BeTrue(); - _user.Events.Last().Should().BeOfType(); - } + [Fact] + public void WhenAssignPlatformRoles_ThenAssigns() + { + var assigner = CreateOperator(_recorder, _identifierFactory); + + var result = _user.AssignPlatformRoles(assigner, Roles.Create(PlatformRoles.TestingOnly).Value); + + result.Should().BeSuccess(); + _user.Roles.Should().Be(Roles.Create(PlatformRoles.TestingOnly.Name).Value); + _user.Features.HasNone().Should().BeTrue(); + _user.Events.Last().Should().BeOfType(); + } #endif #if TESTINGONLY - [Fact] - public void WhenUnassignPlatformRolesAndAssignerNotOperator_ThenReturnsError() - { - var assigner = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person).Value; + [Fact] + public void WhenUnassignPlatformRolesAndAssignerNotOperator_ThenReturnsError() + { + var assigner = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person) + .Value; - var result = _user.UnassignPlatformRoles(assigner, Roles.Create(PlatformRoles.TestingOnly).Value); + var result = _user.UnassignPlatformRoles(assigner, Roles.Create(PlatformRoles.TestingOnly).Value); - result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_NotOperator); - } + result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_NotOperator); + } #endif - [Fact] - public void WhenUnassignPlatformRolesAndRoleNotAssignable_ThenReturnsError() - { - var assigner = CreateOperator(); + [Fact] + public void WhenUnassignPlatformRolesAndRoleNotAssignable_ThenReturnsError() + { + var assigner = CreateOperator(_recorder, _identifierFactory); - var result = _user.UnassignPlatformRoles(assigner, Roles.Create("anunknownrole").Value); + var result = _user.UnassignPlatformRoles(assigner, Roles.Create("anunknownrole").Value); - result.Should().BeError(ErrorCode.RuleViolation, - Resources.EndUserRoot_UnassignablePlatformRole.Format("anunknownrole")); - } + result.Should().BeError(ErrorCode.RuleViolation, + Resources.EndUserRoot_UnassignablePlatformRole.Format("anunknownrole")); + } #if TESTINGONLY - [Fact] - public void WhenUnassignPlatformRolesAndUserNotAssignedRole_ThenReturnsError() - { - var assigner = CreateOperator(); + [Fact] + public void WhenUnassignPlatformRolesAndUserNotAssignedRole_ThenReturnsError() + { + var assigner = CreateOperator(_recorder, _identifierFactory); - var result = _user.UnassignPlatformRoles(assigner, Roles.Create(PlatformRoles.TestingOnly).Value); + var result = _user.UnassignPlatformRoles(assigner, Roles.Create(PlatformRoles.TestingOnly).Value); - result.Should().BeError(ErrorCode.RuleViolation, - Resources.EndUserRoot_CannotUnassignUnassignedRole.Format(PlatformRoles.TestingOnly.Name)); - } + result.Should().BeError(ErrorCode.RuleViolation, + Resources.EndUserRoot_CannotUnassignUnassignedRole.Format(PlatformRoles.TestingOnly.Name)); + } #endif #if TESTINGONLY - [Fact] - public void WhenUnassignPlatformRolesAndStandardRole_ThenReturnsError() - { - var assigner = CreateOperator(); + [Fact] + public void WhenUnassignPlatformRolesAndStandardRole_ThenReturnsError() + { + var assigner = CreateOperator(_recorder, _identifierFactory); - var result = _user.UnassignPlatformRoles(assigner, Roles.Create(PlatformRoles.Standard).Value); + var result = _user.UnassignPlatformRoles(assigner, Roles.Create(PlatformRoles.Standard).Value); - result.Should().BeError(ErrorCode.RuleViolation, - Resources.EndUserRoot_CannotUnassignBaselinePlatformRole.Format(PlatformRoles.Standard.Name)); - } + result.Should().BeError(ErrorCode.RuleViolation, + Resources.EndUserRoot_CannotUnassignBaselinePlatformRole.Format(PlatformRoles.Standard.Name)); + } #endif #if TESTINGONLY - [Fact] - public void WhenUnassignPlatformRoles_ThenUnassigns() - { - var assigner = CreateOperator(); - _user.AssignPlatformRoles(assigner, Roles.Create(PlatformRoles.TestingOnly).Value); - - var result = _user.UnassignPlatformRoles(assigner, Roles.Create(PlatformRoles.TestingOnly).Value); - - result.Should().BeSuccess(); - _user.Roles.HasNone().Should().BeTrue(); - _user.Features.HasNone().Should().BeTrue(); - _user.Events.Last().Should().BeOfType(); - } + [Fact] + public void WhenUnassignPlatformRoles_ThenUnassigns() + { + var assigner = CreateOperator(_recorder, _identifierFactory); + _user.AssignPlatformRoles(assigner, Roles.Create(PlatformRoles.TestingOnly).Value); + + var result = _user.UnassignPlatformRoles(assigner, Roles.Create(PlatformRoles.TestingOnly).Value); + + result.Should().BeSuccess(); + _user.Roles.HasNone().Should().BeTrue(); + _user.Features.HasNone().Should().BeTrue(); + _user.Events.Last().Should().BeOfType(); + } #endif - [Fact] - public async Task WhenInviteAsGuestAndRegistered_ThenDoesNothing() - { - var emailAddress = EmailAddress.Create("invitee@company.com").Value; - _user.Register(Roles.Empty, Features.Empty, EndUserProfile.Create("afirstname").Value, emailAddress); - var wasCallbackCalled = false; - - await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, - (_, _) => - { - wasCallbackCalled = true; - return Task.FromResult(Result.Ok); - }); - - wasCallbackCalled.Should().BeFalse(); - _user.Events.Last().Should().BeOfType(); - _user.GuestInvitation.IsInvited.Should().BeFalse(); - _user.GuestInvitation.IsAccepted.Should().BeFalse(); - } - - [Fact] - public async Task WhenInviteAsGuestAndAlreadyInvited_ThenInvitedAgain() - { - var emailAddress = EmailAddress.Create("invitee@company.com").Value; - await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, - (_, _) => Task.FromResult(Result.Ok)); - var wasCallbackCalled = false; - - await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, - (_, _) => - { - wasCallbackCalled = true; - return Task.FromResult(Result.Ok); - }); - - wasCallbackCalled.Should().BeTrue(); - _user.Events.Last().Should().BeOfType(); - _user.GuestInvitation.IsInvited.Should().BeTrue(); - _user.GuestInvitation.IsAccepted.Should().BeFalse(); - } - - [Fact] - public async Task WhenInviteAsGuestAndUnknown_ThenInvited() - { - var emailAddress = EmailAddress.Create("invitee@company.com").Value; - var wasCallbackCalled = false; - - await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, - (_, _) => - { - wasCallbackCalled = true; - return Task.FromResult(Result.Ok); - }); - - wasCallbackCalled.Should().BeTrue(); - _user.Events.Last().Should().BeOfType(); - _user.GuestInvitation.IsInvited.Should().BeTrue(); - _user.GuestInvitation.IsAccepted.Should().BeFalse(); - } - - [Fact] - public async Task WhenReInviteGuestAsyncAndNotInvited_ThenReturnsError() - { - var wasCallbackCalled = false; - - var result = await _user.ReInviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), - (_, _) => - { - wasCallbackCalled = true; - return Task.FromResult(Result.Ok); - }); - - wasCallbackCalled.Should().BeFalse(); - result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_GuestInvitationNeverSent); - } + [Fact] + public async Task WhenInviteAsGuestAndRegistered_ThenDoesNothing() + { + var emailAddress = EmailAddress.Create("invitee@company.com").Value; + _user.Register(Roles.Empty, Features.Empty, EndUserProfile.Create("afirstname").Value, emailAddress); + var wasCallbackCalled = false; - [Fact] - public async Task WhenReInviteGuestAsyncAndInvitationExpired_ThenReturnsError() - { - var emailAddress = EmailAddress.Create("invitee@company.com").Value; - await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, - (_, _) => Task.FromResult(Result.Ok)); + await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, + (_, _) => + { + wasCallbackCalled = true; + return Task.FromResult(Result.Ok); + }); + + wasCallbackCalled.Should().BeFalse(); + _user.Events.Last().Should().BeOfType(); + _user.GuestInvitation.IsInvited.Should().BeFalse(); + _user.GuestInvitation.IsAccepted.Should().BeFalse(); + } + + [Fact] + public async Task WhenInviteAsGuestAndAlreadyInvited_ThenInvitedAgain() + { + var emailAddress = EmailAddress.Create("invitee@company.com").Value; + await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, + (_, _) => Task.FromResult(Result.Ok)); + var wasCallbackCalled = false; + + await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, + (_, _) => + { + wasCallbackCalled = true; + return Task.FromResult(Result.Ok); + }); + + wasCallbackCalled.Should().BeTrue(); + _user.Events.Last().Should().BeOfType(); + _user.GuestInvitation.IsInvited.Should().BeTrue(); + _user.GuestInvitation.IsAccepted.Should().BeFalse(); + } + + [Fact] + public async Task WhenInviteAsGuestAndUnknown_ThenInvited() + { + var emailAddress = EmailAddress.Create("invitee@company.com").Value; + var wasCallbackCalled = false; + + await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, + (_, _) => + { + wasCallbackCalled = true; + return Task.FromResult(Result.Ok); + }); + + wasCallbackCalled.Should().BeTrue(); + _user.Events.Last().Should().BeOfType(); + _user.GuestInvitation.IsInvited.Should().BeTrue(); + _user.GuestInvitation.IsAccepted.Should().BeFalse(); + } + + [Fact] + public async Task WhenReInviteGuestAsyncAndNotInvited_ThenReturnsError() + { + var wasCallbackCalled = false; + + var result = await _user.ReInviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), + (_, _) => + { + wasCallbackCalled = true; + return Task.FromResult(Result.Ok); + }); + + wasCallbackCalled.Should().BeFalse(); + result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_GuestInvitationNeverSent); + } + + [Fact] + public async Task WhenReInviteGuestAsyncAndInvitationExpired_ThenReturnsError() + { + var emailAddress = EmailAddress.Create("invitee@company.com").Value; + await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, + (_, _) => Task.FromResult(Result.Ok)); #if TESTINGONLY - _user.TestingOnly_ExpireGuestInvitation(); + _user.TestingOnly_ExpireGuestInvitation(); #endif - var wasCallbackCalled = false; - - var result = await _user.ReInviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), - (_, _) => - { - wasCallbackCalled = true; - return Task.FromResult(Result.Ok); - }); - - wasCallbackCalled.Should().BeFalse(); - result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_GuestInvitationHasExpired); - } - - [Fact] - public async Task WhenReInviteGuestAsyncAndInvited_ThenReInvites() - { - var emailAddress = EmailAddress.Create("invitee@company.com").Value; - await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, - (_, _) => Task.FromResult(Result.Ok)); - var wasCallbackCalled = false; - - await _user.ReInviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), - (_, _) => - { - wasCallbackCalled = true; - return Task.FromResult(Result.Ok); - }); - - wasCallbackCalled.Should().BeTrue(); - _user.Events.Last().Should().BeOfType(); - _user.GuestInvitation.IsInvited.Should().BeTrue(); - _user.GuestInvitation.IsAccepted.Should().BeFalse(); - } - - [Fact] - public void WhenVerifyGuestInvitationAndAlreadyRegistered_ThenReturnsError() - { - var emailAddress = EmailAddress.Create("invitee@company.com").Value; - _user.Register(Roles.Empty, Features.Empty, EndUserProfile.Create("afirstname").Value, emailAddress); - - var result = _user.VerifyGuestInvitation(); - - result.Should().BeError(ErrorCode.EntityExists, Resources.EndUserRoot_GuestAlreadyRegistered); - } - - [Fact] - public void WhenVerifyGuestInvitationAndNotInvited_ThenReturnsError() - { - var result = _user.VerifyGuestInvitation(); - - result.Should().BeError(ErrorCode.PreconditionViolation, Resources.EndUserRoot_GuestInvitationNeverSent); - } + var wasCallbackCalled = false; - [Fact] - public async Task WhenVerifyGuestInvitationAndInvitationExpired_ThenReturnsError() - { - var emailAddress = EmailAddress.Create("invitee@company.com").Value; - await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, - (_, _) => Task.FromResult(Result.Ok)); + var result = await _user.ReInviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), + (_, _) => + { + wasCallbackCalled = true; + return Task.FromResult(Result.Ok); + }); + + wasCallbackCalled.Should().BeFalse(); + result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_GuestInvitationHasExpired); + } + + [Fact] + public async Task WhenReInviteGuestAsyncAndInvited_ThenReInvites() + { + var emailAddress = EmailAddress.Create("invitee@company.com").Value; + await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, + (_, _) => Task.FromResult(Result.Ok)); + var wasCallbackCalled = false; + + await _user.ReInviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), + (_, _) => + { + wasCallbackCalled = true; + return Task.FromResult(Result.Ok); + }); + + wasCallbackCalled.Should().BeTrue(); + _user.Events.Last().Should().BeOfType(); + _user.GuestInvitation.IsInvited.Should().BeTrue(); + _user.GuestInvitation.IsAccepted.Should().BeFalse(); + } + + [Fact] + public void WhenVerifyGuestInvitationAndAlreadyRegistered_ThenReturnsError() + { + var emailAddress = EmailAddress.Create("invitee@company.com").Value; + _user.Register(Roles.Empty, Features.Empty, EndUserProfile.Create("afirstname").Value, emailAddress); + + var result = _user.VerifyGuestInvitation(); + + result.Should().BeError(ErrorCode.EntityExists, Resources.EndUserRoot_GuestAlreadyRegistered); + } + + [Fact] + public void WhenVerifyGuestInvitationAndNotInvited_ThenReturnsError() + { + var result = _user.VerifyGuestInvitation(); + + result.Should().BeError(ErrorCode.PreconditionViolation, Resources.EndUserRoot_GuestInvitationNeverSent); + } + + [Fact] + public async Task WhenVerifyGuestInvitationAndInvitationExpired_ThenReturnsError() + { + var emailAddress = EmailAddress.Create("invitee@company.com").Value; + await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, + (_, _) => Task.FromResult(Result.Ok)); #if TESTINGONLY - _user.TestingOnly_ExpireGuestInvitation(); + _user.TestingOnly_ExpireGuestInvitation(); #endif - var result = _user.VerifyGuestInvitation(); + var result = _user.VerifyGuestInvitation(); - result.Should().BeError(ErrorCode.PreconditionViolation, Resources.EndUserRoot_GuestInvitationHasExpired); - } + result.Should().BeError(ErrorCode.PreconditionViolation, Resources.EndUserRoot_GuestInvitationHasExpired); + } - [Fact] - public async Task WhenVerifyGuestInvitationAndStillValid_ThenVerifies() - { - var emailAddress = EmailAddress.Create("invitee@company.com").Value; - await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, - (_, _) => Task.FromResult(Result.Ok)); + [Fact] + public async Task WhenVerifyGuestInvitationAndStillValid_ThenVerifies() + { + var emailAddress = EmailAddress.Create("invitee@company.com").Value; + await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, + (_, _) => Task.FromResult(Result.Ok)); - var result = _user.VerifyGuestInvitation(); + var result = _user.VerifyGuestInvitation(); - result.Should().BeSuccess(); - } + result.Should().BeSuccess(); + } - [Fact] - public void WhenAcceptGuestInvitationAndAuthenticatedUser_ThenReturnsError() - { - var emailAddress = EmailAddress.Create("auser@company.com").Value; + [Fact] + public void WhenAcceptGuestInvitationAndAuthenticatedUser_ThenReturnsError() + { + var emailAddress = EmailAddress.Create("auser@company.com").Value; - var result = _user.AcceptGuestInvitation("auserid".ToId(), emailAddress); + var result = _user.AcceptGuestInvitation("auserid".ToId(), emailAddress); - result.Should().BeError(ErrorCode.ForbiddenAccess, - Resources.EndUserRoot_GuestInvitationAcceptedByNonAnonymousUser); - } + result.Should().BeError(ErrorCode.ForbiddenAccess, + Resources.EndUserRoot_GuestInvitationAcceptedByNonAnonymousUser); + } - [Fact] - public void WhenAcceptGuestInvitationAndRegistered_ThenReturnsError() - { - var emailAddress = EmailAddress.Create("auser@company.com").Value; - _user.Register(Roles.Empty, Features.Empty, EndUserProfile.Create("afirstname").Value, emailAddress); + [Fact] + public void WhenAcceptGuestInvitationAndRegistered_ThenReturnsError() + { + var emailAddress = EmailAddress.Create("auser@company.com").Value; + _user.Register(Roles.Empty, Features.Empty, EndUserProfile.Create("afirstname").Value, emailAddress); - var result = _user.AcceptGuestInvitation(CallerConstants.AnonymousUserId.ToId(), emailAddress); + var result = _user.AcceptGuestInvitation(CallerConstants.AnonymousUserId.ToId(), emailAddress); - result.Should().BeError(ErrorCode.EntityExists, Resources.EndUserRoot_GuestAlreadyRegistered); - } + result.Should().BeError(ErrorCode.EntityExists, Resources.EndUserRoot_GuestAlreadyRegistered); + } - [Fact] - public void WhenAcceptGuestInvitationAndNotInvited_ThenReturnsError() - { - var emailAddress = EmailAddress.Create("auser@company.com").Value; + [Fact] + public void WhenAcceptGuestInvitationAndNotInvited_ThenReturnsError() + { + var emailAddress = EmailAddress.Create("auser@company.com").Value; - var result = _user.AcceptGuestInvitation(CallerConstants.AnonymousUserId.ToId(), emailAddress); + var result = _user.AcceptGuestInvitation(CallerConstants.AnonymousUserId.ToId(), emailAddress); - result.Should().BeError(ErrorCode.PreconditionViolation, Resources.EndUserRoot_GuestInvitationNeverSent); - } + result.Should().BeError(ErrorCode.PreconditionViolation, Resources.EndUserRoot_GuestInvitationNeverSent); + } - [Fact] - public async Task WhenAcceptGuestInvitationAndInviteExpired_ThenReturnsError() - { - var emailAddress = EmailAddress.Create("auser@company.com").Value; - await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, - (_, _) => Task.FromResult(Result.Ok)); + [Fact] + public async Task WhenAcceptGuestInvitationAndInviteExpired_ThenReturnsError() + { + var emailAddress = EmailAddress.Create("auser@company.com").Value; + await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, + (_, _) => Task.FromResult(Result.Ok)); #if TESTINGONLY - _user.TestingOnly_ExpireGuestInvitation(); + _user.TestingOnly_ExpireGuestInvitation(); #endif - var result = _user.AcceptGuestInvitation(CallerConstants.AnonymousUserId.ToId(), emailAddress); - - result.Should().BeError(ErrorCode.PreconditionViolation, Resources.EndUserRoot_GuestInvitationHasExpired); - } - - [Fact] - public async Task WhenAcceptGuestInvitation_ThenAccepts() - { - var emailAddress = EmailAddress.Create("auser@company.com").Value; - await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, - (_, _) => Task.FromResult(Result.Ok)); + var result = _user.AcceptGuestInvitation(CallerConstants.AnonymousUserId.ToId(), emailAddress); - var result = _user.AcceptGuestInvitation(CallerConstants.AnonymousUserId.ToId(), emailAddress); + result.Should().BeError(ErrorCode.PreconditionViolation, Resources.EndUserRoot_GuestInvitationHasExpired); + } - result.Should().BeSuccess(); - _user.Events.Last().Should().BeOfType(); - _user.GuestInvitation.IsAccepted.Should().BeTrue(); - _user.GuestInvitation.AcceptedEmailAddress.Should().Be(emailAddress); - } + [Fact] + public async Task WhenAcceptGuestInvitation_ThenAccepts() + { + var emailAddress = EmailAddress.Create("auser@company.com").Value; + await _user.InviteGuestAsync(_tokensService.Object, "aninviterid".ToId(), emailAddress, + (_, _) => Task.FromResult(Result.Ok)); - private EndUserRoot CreateOrgOwner(string organizationId) - { - var owner = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person).Value; - owner.Register(Roles.Create(PlatformRoles.Standard.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, - EmailAddress.Create("orgowner@company.com").Value); - owner.AddMembership(organizationId.ToId(), Roles.Create(TenantRoles.Owner).Value, Features.Empty); + var result = _user.AcceptGuestInvitation(CallerConstants.AnonymousUserId.ToId(), emailAddress); - return owner; + result.Should().BeSuccess(); + _user.Events.Last().Should().BeOfType(); + _user.GuestInvitation.IsAccepted.Should().BeTrue(); + _user.GuestInvitation.AcceptedEmailAddress.Should().Be(emailAddress); + } } - private EndUserRoot CreateOperator() + [Trait("Category", "Unit")] + public class GivenAMachine { - var @operator = EndUserRoot.Create(_recorder.Object, _identifierFactory.Object, UserClassification.Person) - .Value; - @operator.Register(Roles.Create(PlatformRoles.Standard.Name, PlatformRoles.Operations.Name).Value, - Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, - EmailAddress.Create("operator@company.com").Value); - - return @operator; + private readonly Mock _recorder; + private readonly EndUserRoot _user; + + public GivenAMachine() + { + _recorder = new Mock(); + var counter = 0; + var identifierFactory = new Mock(); + identifierFactory.Setup(idf => idf.Create(It.IsAny())) + .Returns((IIdentifiableEntity entity) => + { + if (entity is Membership) + { + return $"amembershipid{++counter}".ToId(); + } + + return "anid".ToId(); + }); + var tokensService = new Mock(); + tokensService.Setup(ts => ts.CreateGuestInvitationToken()) + .Returns("aninvitationtoken"); + + _user = EndUserRoot.Create(_recorder.Object, identifierFactory.Object, UserClassification.Machine).Value; + } + + [Fact] + public void WhenConstructed_ThenAssigned() + { + _user.Access.Should().Be(UserAccess.Enabled); + _user.Status.Should().Be(UserStatus.Unregistered); + _user.Classification.Should().Be(UserClassification.Machine); + _user.Roles.HasNone().Should().BeTrue(); + _user.Features.HasNone().Should().BeTrue(); + _user.GuestInvitation.IsInvited.Should().BeFalse(); + } + + [Fact] + public void WhenEnsureInvariantsAndMachineIsNotRegistered_ThenReturnsError() + { + var result = _user.EnsureInvariants(); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.EndUserRoot_MachineNotRegistered); + } + + [Fact] + public void WhenAddMembershipToPersonsSharedOrganization_ThenAddsMembership() + { + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + var inviter = CreateOrgOwner(_recorder, "anorganizationid"); + + var roles = Roles.Create(TenantRoles.Member).Value; + var features = Features.Create(TenantFeatures.Basic).Value; + var result = _user.AddMembership(inviter, OrganizationOwnership.Shared, "anorganizationid".ToId(), + roles, features); + + result.Should().BeSuccess(); + _user.Memberships.Should().Contain(ms => + ms.OrganizationId.Value == "anorganizationid" + && ms.IsDefault + && ms.Roles == roles + && ms.Features == features); + _user.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenAddMembershipToPersonsPersonalOrganization_ThenReturnsError() + { + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + var inviter = CreateOrgOwner(_recorder, "anorganizationid"); + + var result = _user.AddMembership(inviter, OrganizationOwnership.Personal, "anorganizationid".ToId(), + Roles.Create(TenantRoles.Member).Value, Features.Create(TenantFeatures.Basic).Value); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.EndUserRoot_Addmembership_SharedOwnershipRequired); + } + + [Fact] + public void WhenAddMembershipToMachinesPersonalOrganization_ThenReturnsError() + { + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + var inviter = CreateOrgOwner(_recorder, "anorganizationid", UserClassification.Machine); + var roles = Roles.Create(TenantRoles.Member).Value; + var features = Features.Create(TenantFeatures.Basic).Value; + + var result = _user.AddMembership(inviter, OrganizationOwnership.Personal, "anorganizationid".ToId(), + roles, features); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.EndUserRoot_Addmembership_SharedOwnershipRequired); + } + + [Fact] + public void WhenAddMembershipToMachinesSharedOrganization_ThenAddsMembership() + { + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + var inviter = CreateOrgOwner(_recorder, "anorganizationid", UserClassification.Machine); + var roles = Roles.Create(TenantRoles.Member).Value; + var features = Features.Create(TenantFeatures.Basic).Value; + + var result = _user.AddMembership(inviter, OrganizationOwnership.Shared, "anorganizationid".ToId(), + roles, features); + + result.Should().BeSuccess(); + _user.Memberships.Should().Contain(ms => + ms.OrganizationId.Value == "anorganizationid" + && ms.IsDefault + && ms.Roles == roles + && ms.Features == features); + _user.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenAddMembershipToSelfPersonalOrganization_ThenAddsMembership() + { + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + var roles = Roles.Create(TenantRoles.Member).Value; + var features = Features.Create(TenantFeatures.Basic).Value; + + var result = _user.AddMembership(_user, OrganizationOwnership.Personal, "anorganizationid".ToId(), + roles, features); + + result.Should().BeSuccess(); + _user.Memberships.Should().Contain(ms => + ms.OrganizationId.Value == "anorganizationid" + && ms.IsDefault + && ms.Roles == roles + && ms.Features == features); + _user.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenAddMembershipToSelfSharedOrganization_ThenAddsMembership() + { + _user.Register(Roles.Create(PlatformRoles.Standard.Name).Value, + Features.Create(PlatformFeatures.Basic.Name).Value, EndUserProfile.Create("afirstname").Value, + EmailAddress.Create("auser@company.com").Value); + var roles = Roles.Create(TenantRoles.Member).Value; + var features = Features.Create(TenantFeatures.Basic).Value; + + var result = _user.AddMembership(_user, OrganizationOwnership.Shared, "anorganizationid".ToId(), + roles, features); + + result.Should().BeSuccess(); + _user.Memberships.Should().Contain(ms => + ms.OrganizationId.Value == "anorganizationid" + && ms.IsDefault + && ms.Roles == roles + && ms.Features == features); + _user.Events.Last().Should().BeOfType(); + } } } \ No newline at end of file diff --git a/src/EndUsersDomain.UnitTests/MembershipSpec.cs b/src/EndUsersDomain.UnitTests/MembershipSpec.cs index 59d77c09..a4a5a843 100644 --- a/src/EndUsersDomain.UnitTests/MembershipSpec.cs +++ b/src/EndUsersDomain.UnitTests/MembershipSpec.cs @@ -5,6 +5,7 @@ using Domain.Interfaces.Authorization; using Domain.Interfaces.Entities; using Domain.Shared; +using Domain.Shared.Organizations; using FluentAssertions; using Moq; using UnitTesting.Common; @@ -44,8 +45,8 @@ public void WhenMembershipAddedEventRaised_ThenAssigned() var features = Features.Create(); _membership.As() - .RaiseEvent(Events.MembershipAdded("arootid".ToId(), - "anorganizationid".ToId(), true, roles, features), true); + .RaiseEvent(Events.MembershipAdded("arootid".ToId(), "anorganizationid".ToId(), + OrganizationOwnership.Shared, true, roles, features), true); _membership.IsDefault.Should().BeTrue(); _membership.RootId.Should().Be("arootid".ToId()); diff --git a/src/EndUsersDomain.UnitTests/MembershipsSpec.cs b/src/EndUsersDomain.UnitTests/MembershipsSpec.cs index 53858361..623a83c3 100644 --- a/src/EndUsersDomain.UnitTests/MembershipsSpec.cs +++ b/src/EndUsersDomain.UnitTests/MembershipsSpec.cs @@ -3,6 +3,7 @@ using Domain.Common.ValueObjects; using Domain.Interfaces.Entities; using Domain.Shared; +using Domain.Shared.Organizations; using FluentAssertions; using Moq; using UnitTesting.Common; @@ -168,7 +169,7 @@ private Membership CreateMembership(string organizationId = "anorganizationid", var membership = Membership.Create(_recorder.Object, _idFactory.Object, _ => Result.Ok).Value; membership.As() .RaiseEvent(Events.MembershipAdded("arootid".ToId(), - organizationId.ToId(), isDefault, roles, features), true); + organizationId.ToId(), OrganizationOwnership.Shared, isDefault, roles, features), true); return membership; } } \ No newline at end of file diff --git a/src/EndUsersDomain/EndUserRoot.cs b/src/EndUsersDomain/EndUserRoot.cs index 40f6ff23..ec070712 100644 --- a/src/EndUsersDomain/EndUserRoot.cs +++ b/src/EndUsersDomain/EndUserRoot.cs @@ -11,6 +11,7 @@ using Domain.Services.Shared.DomainServices; using Domain.Shared; using Domain.Shared.EndUsers; +using Domain.Shared.Organizations; namespace EndUsersDomain; @@ -159,16 +160,19 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco case MembershipDefaultChanged changed: { - var fromMembership = Memberships.FindByMembershipId(changed.FromMembershipId.ToId()); - if (!fromMembership.HasValue) + if (changed.FromMembershipId.Exists()) { - return Error.RuleViolation(Resources.EndUserRoot_MissingMembership); - } - - var from = RaiseEventToChildEntity(changed, fromMembership.Value); - if (!from.IsSuccessful) - { - return from.Error; + var fromMembership = Memberships.FindByMembershipId(changed.FromMembershipId.ToId()); + if (!fromMembership.HasValue) + { + return Error.RuleViolation(Resources.EndUserRoot_MissingMembership); + } + + var from = RaiseEventToChildEntity(changed, fromMembership.Value); + if (!from.IsSuccessful) + { + return from.Error; + } } var toMembership = Memberships.FindByMembershipId(changed.ToMembershipId.ToId()); @@ -185,7 +189,7 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco Recorder.TraceDebug(null, "EndUser {Id} changed default membership from {FromMembership} to {ToMembership}", Id, - changed.FromMembershipId, changed.ToMembershipId); + changed.FromMembershipId ?? "none", changed.ToMembershipId); return Result.Ok; } @@ -325,9 +329,22 @@ public Result AcceptGuestInvitation(Identifier acceptedById, EmailAddress return RaiseChangeEvent(EndUsersDomain.Events.GuestInvitationAccepted(Id, emailAddress)); } - public Result AddMembership(Identifier organizationId, Roles tenantRoles, Features tenantFeatures) + public Result AddMembership(EndUserRoot adder, OrganizationOwnership ownership, Identifier organizationId, + Roles tenantRoles, Features tenantFeatures) { - //TODO: check that the adder is a member of this organization, and an owner of it + var skipOwnershipCheck = adder.Id == Id; + if (!skipOwnershipCheck) + { + if (!IsOrganizationOwner(adder, organizationId)) + { + return Error.RoleViolation(Resources.EndUserRoot_NotOrganizationOwner); + } + + if (ownership == OrganizationOwnership.Personal) + { + return Error.RuleViolation(Resources.EndUserRoot_Addmembership_SharedOwnershipRequired); + } + } var existing = Memberships.FindByOrganizationId(organizationId); if (existing.HasValue) @@ -335,24 +352,21 @@ public Result AddMembership(Identifier organizationId, Roles tenantRoles, return Result.Ok; } - var isDefault = Memberships.HasNone(); + var isFirst = Memberships.HasNone(); var added = RaiseChangeEvent( - EndUsersDomain.Events.MembershipAdded(Id, organizationId, isDefault, tenantRoles, tenantFeatures)); + EndUsersDomain.Events.MembershipAdded(Id, organizationId, ownership, isFirst, tenantRoles, tenantFeatures)); if (!added.IsSuccessful) { return added.Error; } - if (!isDefault) - { - var defaultMembership = Memberships.DefaultMembership; - var addedMembership = Memberships.FindByOrganizationId(organizationId); - return RaiseChangeEvent( - EndUsersDomain.Events.MembershipDefaultChanged(Id, defaultMembership.Id, - addedMembership.Value.Id)); - } + var defaultMembershipId = isFirst + ? Optional.None + : Memberships.DefaultMembership.Id.ToOptional(); + var addedMembership = Memberships.FindByOrganizationId(organizationId); - return Result.Ok; + return RaiseChangeEvent(EndUsersDomain.Events.MembershipDefaultChanged(Id, defaultMembershipId, + addedMembership.Value.Id, addedMembership.Value.OrganizationId, tenantRoles, tenantFeatures)); } public Result AssignMembershipFeatures(EndUserRoot assigner, Identifier organizationId, @@ -360,7 +374,7 @@ public Result AssignMembershipFeatures(EndUserRoot assigner, Identifier o { if (!IsOrganizationOwner(assigner, organizationId)) { - return Error.RuleViolation(Resources.EndUserRoot_NotOrganizationOwner); + return Error.RoleViolation(Resources.EndUserRoot_NotOrganizationOwner); } var membership = Memberships.FindByOrganizationId(organizationId); @@ -398,7 +412,7 @@ public Result AssignMembershipRoles(EndUserRoot assigner, Ide { if (!IsOrganizationOwner(assigner, organizationId)) { - return Error.RuleViolation(Resources.EndUserRoot_NotOrganizationOwner); + return Error.RoleViolation(Resources.EndUserRoot_NotOrganizationOwner); } var membership = Memberships.FindByOrganizationId(organizationId); diff --git a/src/EndUsersDomain/Events.cs b/src/EndUsersDomain/Events.cs index 738f9f18..89c70f0c 100644 --- a/src/EndUsersDomain/Events.cs +++ b/src/EndUsersDomain/Events.cs @@ -3,6 +3,8 @@ using Domain.Events.Shared.EndUsers; using Domain.Shared; using Domain.Shared.EndUsers; +using Domain.Shared.Organizations; +using Created = Domain.Events.Shared.EndUsers.Created; namespace EndUsersDomain; @@ -38,7 +40,8 @@ public static GuestInvitationCreated GuestInvitationCreated(Identifier id, strin }; } - public static MembershipAdded MembershipAdded(Identifier id, Identifier organizationId, bool isDefault, Roles roles, + public static MembershipAdded MembershipAdded(Identifier id, Identifier organizationId, + OrganizationOwnership ownership, bool isDefault, Roles roles, Features features) { return new MembershipAdded(id) @@ -47,17 +50,22 @@ public static MembershipAdded MembershipAdded(Identifier id, Identifier organiza IsDefault = isDefault, OrganizationId = organizationId, Roles = roles.ToList(), - Features = features.ToList() + Features = features.ToList(), + Ownership = ownership }; } - public static MembershipDefaultChanged MembershipDefaultChanged(Identifier id, Identifier fromMembershipId, - Identifier toMembershipId) + public static MembershipDefaultChanged MembershipDefaultChanged(Identifier id, + Optional fromMembershipId, + Identifier toMembershipId, Identifier toOrganizationId, Roles roles, Features features) { return new MembershipDefaultChanged(id) { - FromMembershipId = fromMembershipId, - ToMembershipId = toMembershipId + FromMembershipId = fromMembershipId.ValueOrDefault!, + ToMembershipId = toMembershipId, + ToOrganizationId = toOrganizationId, + Roles = roles.ToList(), + Features = features.ToList() }; } diff --git a/src/EndUsersDomain/GuestInvitation.cs b/src/EndUsersDomain/GuestInvitation.cs index 7a776cc9..edf8ddc7 100644 --- a/src/EndUsersDomain/GuestInvitation.cs +++ b/src/EndUsersDomain/GuestInvitation.cs @@ -8,7 +8,7 @@ namespace EndUsersDomain; public sealed class GuestInvitation : ValueObjectBase { - public static readonly TimeSpan DefaultTokenExpiry = TimeSpan.FromDays(7); + public static readonly TimeSpan DefaultTokenExpiry = TimeSpan.FromDays(14); public static readonly GuestInvitation Empty = new(); public static Result Create() diff --git a/src/EndUsersDomain/Membership.cs b/src/EndUsersDomain/Membership.cs index e1acc682..9f7136cd 100644 --- a/src/EndUsersDomain/Membership.cs +++ b/src/EndUsersDomain/Membership.cs @@ -7,6 +7,7 @@ using Domain.Interfaces.Authorization; using Domain.Interfaces.Entities; using Domain.Shared; +using Domain.Shared.Organizations; namespace EndUsersDomain; @@ -36,6 +37,10 @@ private Membership(IRecorder recorder, IIdentifierFactory idFactory, public Optional RootId { get; private set; } = Optional.None; + public Optional Ownership { get; private set; } + + public bool IsShared => Ownership is { HasValue: true, Value: OrganizationOwnership.Shared }; + protected override Result OnStateChanged(IDomainEvent @event) { switch (@event) @@ -45,6 +50,7 @@ protected override Result OnStateChanged(IDomainEvent @event) RootId = added.RootId.ToId(); OrganizationId = added.OrganizationId.ToId(); IsDefault = added.IsDefault; + Ownership = added.Ownership; var roles = Roles.Create(added.Roles.ToArray()); if (!roles.IsSuccessful) { diff --git a/src/EndUsersDomain/Resources.Designer.cs b/src/EndUsersDomain/Resources.Designer.cs index a0e363b2..dbf907c0 100644 --- a/src/EndUsersDomain/Resources.Designer.cs +++ b/src/EndUsersDomain/Resources.Designer.cs @@ -59,6 +59,24 @@ internal Resources() { } } + /// + /// Looks up a localized string similar to Cannot add a membership to machines organization. + /// + internal static string EndUserRoot_AddMembership_MachineCannotHaveMemberships { + get { + return ResourceManager.GetString("EndUserRoot_AddMembership_MachineCannotHaveMemberships", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The adder cannot add a membership to a personal organization. + /// + internal static string EndUserRoot_Addmembership_SharedOwnershipRequired { + get { + return ResourceManager.GetString("EndUserRoot_Addmembership_SharedOwnershipRequired", resourceCulture); + } + } + /// /// Looks up a localized string similar to All person users must have at least one feature. /// diff --git a/src/EndUsersDomain/Resources.resx b/src/EndUsersDomain/Resources.resx index aed397d8..a772a3ac 100644 --- a/src/EndUsersDomain/Resources.resx +++ b/src/EndUsersDomain/Resources.resx @@ -75,6 +75,9 @@ The assigner is not an owner of the organization + + The adder cannot add a membership to a personal organization + A membership must always have at least the role '{0}' @@ -105,4 +108,7 @@ This guest invitation cannot be accepted by an authenticated user + + Cannot add a membership to machines organization + \ No newline at end of file diff --git a/src/EndUsersInfrastructure/Api/EndUsers/EndUsersApi.cs b/src/EndUsersInfrastructure/Api/EndUsers/EndUsersApi.cs index 753f4f94..e85a0e42 100644 --- a/src/EndUsersInfrastructure/Api/EndUsers/EndUsersApi.cs +++ b/src/EndUsersInfrastructure/Api/EndUsers/EndUsersApi.cs @@ -25,7 +25,7 @@ public async Task> AssignPla await _endUsersApplication.AssignPlatformRolesAsync(_contextFactory.Create(), request.Id, request.Roles ?? new List(), cancellationToken); - return () => user.HandleApplicationResult(usr => + return () => user.HandleApplicationResult(usr => new PostResult(new AssignPlatformRolesResponse { User = usr })); } @@ -37,7 +37,7 @@ await _endUsersApplication.UnassignPlatformRolesAsync(_contextFactory.Create(), request.Roles ?? new List(), cancellationToken); return () => - user.HandleApplicationResult(usr => new AssignPlatformRolesResponse + user.HandleApplicationResult(usr => new AssignPlatformRolesResponse { User = usr }); } } \ No newline at end of file diff --git a/src/EndUsersInfrastructure/Api/Invitations/InvitationsApi.cs b/src/EndUsersInfrastructure/Api/Invitations/InvitationsApi.cs index 5b8fb4c0..da04bcb1 100644 --- a/src/EndUsersInfrastructure/Api/Invitations/InvitationsApi.cs +++ b/src/EndUsersInfrastructure/Api/Invitations/InvitationsApi.cs @@ -25,7 +25,7 @@ public async Task> AcceptGu await _invitationsApplication.VerifyGuestInvitationAsync(_contextFactory.Create(), request.Token, cancellationToken); - return () => invitation.HandleApplicationResult(invite => + return () => invitation.HandleApplicationResult(invite => new VerifyGuestInvitationResponse { Invitation = invite }); } @@ -36,7 +36,7 @@ public async Task> InviteGuest( await _invitationsApplication.InviteGuestAsync(_contextFactory.Create(), request.Email, cancellationToken); - return () => invitation.HandleApplicationResult(invite => + return () => invitation.HandleApplicationResult(invite => new PostResult(new InviteGuestResponse { Invitation = invite })); } diff --git a/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs b/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs index 6e3ca26a..3e279d52 100644 --- a/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs +++ b/src/EndUsersInfrastructure/ApplicationServices/EndUsersInProcessServiceClient.cs @@ -32,6 +32,12 @@ public async Task> GetMembershipsPrivateAs return await _endUsersApplication.GetMembershipsAsync(caller, id, cancellationToken); } + public async Task> GetUserPrivateAsync(ICallerContext caller, string id, + CancellationToken cancellationToken) + { + return await _endUsersApplication.GetUserAsync(caller, id, cancellationToken); + } + public async Task, Error>> ListMembershipsForOrganizationAsync( ICallerContext caller, string organizationId, SearchOptions searchOptions, GetOptions getOptions, CancellationToken cancellationToken) @@ -59,6 +65,6 @@ public async Task> RegisterMachinePrivateAsync( public async Task> GetPersonAsync(ICallerContext caller, string id, CancellationToken cancellationToken) { - return await _endUsersApplication.GetPersonAsync(caller, id, cancellationToken); + return await _endUsersApplication.GetUserAsync(caller, id, cancellationToken); } } \ No newline at end of file diff --git a/src/EndUsersInfrastructure/Persistence/ReadModels/EndUserProjection.cs b/src/EndUsersInfrastructure/Persistence/ReadModels/EndUserProjection.cs index 244981fd..b0fa240a 100644 --- a/src/EndUsersInfrastructure/Persistence/ReadModels/EndUserProjection.cs +++ b/src/EndUsersInfrastructure/Persistence/ReadModels/EndUserProjection.cs @@ -1,6 +1,7 @@ using Application.Persistence.Common.Extensions; using Application.Persistence.Interfaces; using Common; +using Common.Extensions; using Domain.Common.ValueObjects; using Domain.Events.Shared.EndUsers; using Domain.Interfaces; @@ -72,11 +73,14 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven case MembershipDefaultChanged e: { - var from = await _memberships.HandleUpdateAsync(e.FromMembershipId.ToId(), - dto => { dto.IsDefault = false; }, cancellationToken); - if (!from.IsSuccessful) + if (e.FromMembershipId.Exists()) { - return from.Error; + var from = await _memberships.HandleUpdateAsync(e.FromMembershipId.ToId(), + dto => { dto.IsDefault = false; }, cancellationToken); + if (!from.IsSuccessful) + { + return from.Error; + } } var to = await _memberships.HandleUpdateAsync(e.ToMembershipId.ToId(), diff --git a/src/IdentityInfrastructure/Api/APIKeys/APIKeysApi.cs b/src/IdentityInfrastructure/Api/APIKeys/APIKeysApi.cs index 769cb0fd..870a061e 100644 --- a/src/IdentityInfrastructure/Api/APIKeys/APIKeysApi.cs +++ b/src/IdentityInfrastructure/Api/APIKeys/APIKeysApi.cs @@ -24,7 +24,7 @@ public async Task> Create( { var apiKey = await _apiKeysApplication.CreateAPIKeyAsync(_contextFactory.Create(), cancellationToken); - return () => apiKey.HandleApplicationResult(x => + return () => apiKey.HandleApplicationResult(x => new PostResult(new CreateAPIKeyResponse { ApiKey = x.Key })); } #endif diff --git a/src/IdentityInfrastructure/Api/AuthTokens/AuthTokensApi.cs b/src/IdentityInfrastructure/Api/AuthTokens/AuthTokensApi.cs index fe9c5d8d..63b9f3d5 100644 --- a/src/IdentityInfrastructure/Api/AuthTokens/AuthTokensApi.cs +++ b/src/IdentityInfrastructure/Api/AuthTokens/AuthTokensApi.cs @@ -26,7 +26,7 @@ public async Task> Refre await _authTokensApplication.RefreshTokenAsync(_contextFactory.Create(), request.RefreshToken, cancellationToken); - return () => tokens.HandleApplicationResult(tok => + return () => tokens.HandleApplicationResult(tok => new PostResult(new RefreshTokenResponse { Tokens = tok diff --git a/src/IdentityInfrastructure/Api/MachineCredentials/MachineCredentialsApi.cs b/src/IdentityInfrastructure/Api/MachineCredentials/MachineCredentialsApi.cs index 2f40647c..cb01ad6c 100644 --- a/src/IdentityInfrastructure/Api/MachineCredentials/MachineCredentialsApi.cs +++ b/src/IdentityInfrastructure/Api/MachineCredentials/MachineCredentialsApi.cs @@ -20,13 +20,12 @@ public MachineCredentialsApi(ICallerContextFactory contextFactory, } public async Task> RegisterMachine( - RegisterMachineRequest request, - CancellationToken cancellationToken) + RegisterMachineRequest request, CancellationToken cancellationToken) { var machine = await _machineCredentialsApplication.RegisterMachineAsync(_contextFactory.Create(), request.Name, request.Timezone, request.CountryCode, request.ApiKeyExpiresOnUtc, cancellationToken); - return () => machine.HandleApplicationResult(x => + return () => machine.HandleApplicationResult(x => new PostResult(new RegisterMachineResponse { Machine = x })); } } \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs b/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs index 91c8a633..aa1b5102 100644 --- a/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs +++ b/src/IdentityInfrastructure/Api/PasswordCredentials/PasswordCredentialsApi.cs @@ -29,7 +29,7 @@ await _passwordCredentialsApplication.AuthenticateAsync(_contextFactory.Create() request.Password, cancellationToken); - return () => authenticated.HandleApplicationResult(tok => + return () => authenticated.HandleApplicationResult(tok => new PostResult(new AuthenticateResponse { Tokens = tok @@ -58,7 +58,7 @@ public async Task - token.HandleApplicationResult( + token.HandleApplicationResult( con => new GetRegistrationPersonConfirmationResponse { Token = con.Token }); } @@ -72,7 +72,7 @@ public async Task credential.HandleApplicationResult(creds => + return () => credential.HandleApplicationResult(creds => new PostResult(new RegisterPersonPasswordResponse { Credential = creds })); } } \ No newline at end of file diff --git a/src/IdentityInfrastructure/Api/SSO/SingleSignOnApi.cs b/src/IdentityInfrastructure/Api/SSO/SingleSignOnApi.cs index 354276ad..c4283873 100644 --- a/src/IdentityInfrastructure/Api/SSO/SingleSignOnApi.cs +++ b/src/IdentityInfrastructure/Api/SSO/SingleSignOnApi.cs @@ -27,7 +27,7 @@ await _singleSignOnApplication.AuthenticateAsync(_contextFactory.Create(), reque request.Provider, request.AuthCode, request.Username, cancellationToken); - return () => authenticated.HandleApplicationResult(tok => + return () => authenticated.HandleApplicationResult(tok => new PostResult(new AuthenticateResponse { Tokens = tok diff --git a/src/ImagesInfrastructure/Api/Images/ImagesApi.cs b/src/ImagesInfrastructure/Api/Images/ImagesApi.cs index 55d7adf1..0f72c2bf 100644 --- a/src/ImagesInfrastructure/Api/Images/ImagesApi.cs +++ b/src/ImagesInfrastructure/Api/Images/ImagesApi.cs @@ -38,7 +38,7 @@ public async Task> GetImage(GetImageReques { var image = await _application.GetImageAsync(_contextFactory.Create(), request.Id, cancellationToken); - return () => image.HandleApplicationResult(x => new GetImageResponse { Image = x }); + return () => image.HandleApplicationResult(x => new GetImageResponse { Image = x }); } public async Task ImageDelete(DeleteImageRequest request, CancellationToken cancellationToken) @@ -55,7 +55,7 @@ public async Task> UpdateImage(Upd cancellationToken); return () => - image.HandleApplicationResult(x => new UpdateImageResponse { Image = x }); + image.HandleApplicationResult(x => new UpdateImageResponse { Image = x }); } public async Task> UploadImage(UploadImageRequest request, @@ -73,7 +73,7 @@ public async Task> UploadImage(UploadI cancellationToken); return () => - image.HandleApplicationResult(x => + image.HandleApplicationResult(x => new PostResult(new UploadImageResponse { Image = x })); } } \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Common/Extensions/HandlerExtensions.cs b/src/Infrastructure.Web.Api.Common/Extensions/HandlerExtensions.cs index 5e536a54..acd003c4 100644 --- a/src/Infrastructure.Web.Api.Common/Extensions/HandlerExtensions.cs +++ b/src/Infrastructure.Web.Api.Common/Extensions/HandlerExtensions.cs @@ -88,8 +88,7 @@ public static IResult HandleApiResult(this ApiSearchResult /// /// Converts the into an appropriate depending on error returned /// - public static IResult HandleApiResult(this ApiDeleteResult result, - OperationMethod method) + public static IResult HandleApiResult(this ApiDeleteResult result, OperationMethod method) { return result() .Match(response => ((PostResult)response.Value).ToResult(method), @@ -110,7 +109,7 @@ public static Result HandleApplicationResult(th /// /// using the callback /// - public static Result HandleApplicationResult( + public static Result HandleApplicationResult( this Result result, Func onSuccess) where TResponse : IWebResponse { @@ -123,7 +122,7 @@ public static Result HandleApplicationResult /// using the callback /// - public static Result, Error> HandleApplicationResult( + public static Result, Error> HandleApplicationResult( this Result result, Func> onSuccess) where TResponse : IWebResponse { diff --git a/src/Infrastructure.Web.Api.Operations.Shared/UserProfiles/GetCurrentProfileRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/UserProfiles/GetCurrentProfileRequest.cs new file mode 100644 index 00000000..28aec38d --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/UserProfiles/GetCurrentProfileRequest.cs @@ -0,0 +1,8 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.UserProfiles; + +[Route("/profiles/me", OperationMethod.Get)] +public class GetCurrentProfileRequest : UnTenantedRequest +{ +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/UserProfiles/GetCurrentProfileResponse.cs b/src/Infrastructure.Web.Api.Operations.Shared/UserProfiles/GetCurrentProfileResponse.cs new file mode 100644 index 00000000..a461ebcf --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/UserProfiles/GetCurrentProfileResponse.cs @@ -0,0 +1,9 @@ +using Application.Resources.Shared; +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.UserProfiles; + +public class GetCurrentProfileResponse : IWebResponse +{ + public UserProfileForCurrent? Profile { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Hosting.Common/AspNetCallerContext.cs b/src/Infrastructure.Web.Hosting.Common/AspNetCallerContext.cs index b1648d97..f7406827 100644 --- a/src/Infrastructure.Web.Hosting.Common/AspNetCallerContext.cs +++ b/src/Infrastructure.Web.Hosting.Common/AspNetCallerContext.cs @@ -72,17 +72,20 @@ private static ICallerContext.CallerAuthorization GetAuthorization(HttpContext c } var scheme = authenticationFeature.AuthenticateResult?.Ticket?.AuthenticationScheme; - if (scheme.NotExists()) + var schemes = scheme.HasValue() + ? scheme.Split(',', ';') + : Array.Empty(); + if (schemes.HasNone()) { return Optional.None; } - return GetCallerAuthorization(context, scheme); + return GetCallerAuthorization(context, schemes.ToList()); } - private static ICallerContext.CallerAuthorization GetCallerAuthorization(HttpContext context, string scheme) + private static ICallerContext.CallerAuthorization GetCallerAuthorization(HttpContext context, List schemes) { - if (scheme == JwtBearerDefaults.AuthenticationScheme) + if (schemes.ContainsIgnoreCase(JwtBearerDefaults.AuthenticationScheme)) { var token = context.Request.GetTokenAuth(); if (!token.HasValue) @@ -93,7 +96,7 @@ private static ICallerContext.CallerAuthorization GetCallerAuthorization(HttpCon return new ICallerContext.CallerAuthorization(ICallerContext.AuthorizationMethod.Token, token); } - if (scheme == APIKeyAuthenticationHandler.AuthenticationScheme) + if (schemes.ContainsIgnoreCase(APIKeyAuthenticationHandler.AuthenticationScheme)) { var apikey = context.Request.GetAPIKeyAuth(); if (!apikey.HasValue) @@ -104,7 +107,7 @@ private static ICallerContext.CallerAuthorization GetCallerAuthorization(HttpCon return new ICallerContext.CallerAuthorization(ICallerContext.AuthorizationMethod.APIKey, apikey); } - if (scheme == HMACAuthenticationHandler.AuthenticationScheme) + if (schemes.ContainsIgnoreCase(HMACAuthenticationHandler.AuthenticationScheme)) { return Optional.None; } diff --git a/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs b/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs index f38da30e..89a4839f 100644 --- a/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs +++ b/src/OrganizationsApplication.UnitTests/OrganizationsApplicationSpec.cs @@ -9,6 +9,7 @@ using Domain.Interfaces.Entities; using Domain.Interfaces.Services; using Domain.Shared; +using Domain.Shared.EndUsers; using FluentAssertions; using Moq; using OrganizationsApplication.Persistence; @@ -65,10 +66,17 @@ public OrganizationsApplicationSpec() } [Fact] - public async Task WhenCreateSharedOrganizationAsync_ThenReturnsSharedOrganization() + public async Task WhenCreateSharedOrganizationAsyncForPerson_ThenReturnsSharedOrganization() { _caller.Setup(c => c.CallerId) .Returns("acallerid"); + _endUsersService.Setup(eus => eus.GetUserPrivateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new EndUser + { + Id = "acallerid", + Classification = EndUserClassification.Person + }); var result = await _application.CreateSharedOrganizationAsync(_caller.Object, "aname", @@ -106,7 +114,8 @@ public async Task WhenGetAsyncAndNotExists_ThenReturnsError() public async Task WhenGetAsync_ThenReturnsOrganization() { var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, - OrganizationOwnership.Personal, "auserid".ToId(), DisplayName.Create("aname").Value).Value; + OrganizationOwnership.Personal, "auserid".ToId(), UserClassification.Person, + DisplayName.Create("aname").Value).Value; org.CreateSettings(Settings.Create(new Dictionary { { "aname", Setting.Create("avalue", true).Value } @@ -138,7 +147,8 @@ public async Task WhenGetSettingsAsyncAndNotExists_ThenReturnsError() public async Task WhenGetSettingsasync_ThenReturnsSettings() { var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, - OrganizationOwnership.Personal, "auserid".ToId(), DisplayName.Create("aname").Value).Value; + OrganizationOwnership.Personal, "auserid".ToId(), UserClassification.Person, + DisplayName.Create("aname").Value).Value; org.CreateSettings(Settings.Create(new Dictionary { { "aname", Setting.Create("avalue", true).Value } @@ -170,7 +180,8 @@ public async Task WhenChangeSettingsAsyncAndNotExists_ThenReturnsError() public async Task WhenChangeSettingsAsync_ThenReturnsSettings() { var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, - OrganizationOwnership.Shared, "auserid".ToId(), DisplayName.Create("aname").Value).Value; + OrganizationOwnership.Shared, "auserid".ToId(), UserClassification.Person, + DisplayName.Create("aname").Value).Value; org.CreateSettings(Settings.Create(new Dictionary { { "aname1", Setting.Create("anoldvalue", true).Value }, @@ -220,9 +231,10 @@ public async Task WhenInviteMemberToOrganizationAsyncAndNotExist_ThenReturnsErro public async Task WhenInviteMemberToOrganizationAsyncAndNoUserIdNorEmail_ThenReturnsError() { _caller.Setup(cc => cc.Roles) - .Returns(new ICallerContext.CallerRoles(Array.Empty(), new[] { TenantRoles.Owner })); + .Returns(new ICallerContext.CallerRoles([], new[] { TenantRoles.Owner })); var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, - OrganizationOwnership.Shared, "auserid".ToId(), DisplayName.Create("aname").Value).Value; + OrganizationOwnership.Shared, "auserid".ToId(), UserClassification.Person, + DisplayName.Create("aname").Value).Value; _repository.Setup(s => s.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(org); @@ -237,9 +249,10 @@ public async Task WhenInviteMemberToOrganizationAsyncAndNoUserIdNorEmail_ThenRet public async Task WhenInviteMemberToOrganizationAsync_ThenInvites() { _caller.Setup(cc => cc.Roles) - .Returns(new ICallerContext.CallerRoles(Array.Empty(), new[] { TenantRoles.Owner })); + .Returns(new ICallerContext.CallerRoles([], new[] { TenantRoles.Owner })); var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, - OrganizationOwnership.Shared, "auserid".ToId(), DisplayName.Create("aname").Value).Value; + OrganizationOwnership.Shared, "auserid".ToId(), UserClassification.Person, + DisplayName.Create("aname").Value).Value; _repository.Setup(s => s.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(org); @@ -273,7 +286,8 @@ public async Task WhenListMembersForOrganizationAsyncAndNotExist_ThenReturnsErro public async Task WhenListMembersForOrganizationAsyncWithUnregisteredUser_ThenReturnsMemberships() { var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, - OrganizationOwnership.Shared, "auserid".ToId(), DisplayName.Create("aname").Value).Value; + OrganizationOwnership.Shared, "auserid".ToId(), UserClassification.Person, + DisplayName.Create("aname").Value).Value; _repository.Setup(s => s.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(org); @@ -328,7 +342,8 @@ public async Task WhenListMembersForOrganizationAsyncWithUnregisteredUser_ThenRe public async Task WhenListMembersForOrganizationAsyncWithRegisteredUsers_ThenReturnsMemberships() { var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, - OrganizationOwnership.Shared, "auserid".ToId(), DisplayName.Create("aname").Value).Value; + OrganizationOwnership.Shared, "auserid".ToId(), UserClassification.Person, + DisplayName.Create("aname").Value).Value; _repository.Setup(s => s.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(org); @@ -402,9 +417,10 @@ public async Task WhenChangeAvatarAsyncAndNotExists_ThenReturnsError() public async Task WhenChangeAvatarAsyncAndNoExistingAvatar_ThenReturnsOrganization() { _caller.Setup(cc => cc.Roles) - .Returns(new ICallerContext.CallerRoles(Array.Empty(), new[] { TenantRoles.Owner })); + .Returns(new ICallerContext.CallerRoles([], new[] { TenantRoles.Owner })); var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, - OrganizationOwnership.Personal, "auserid".ToId(), DisplayName.Create("aname").Value).Value; + OrganizationOwnership.Personal, "auserid".ToId(), UserClassification.Person, + DisplayName.Create("aname").Value).Value; _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(org); var upload = new FileUpload @@ -444,9 +460,10 @@ public async Task WhenChangeAvatarAsyncAndNoExistingAvatar_ThenReturnsOrganizati public async Task WhenChangeAvatarAsyncAndExistingAvatar_ThenReturnsOrganization() { _caller.Setup(cc => cc.Roles) - .Returns(new ICallerContext.CallerRoles(Array.Empty(), new[] { TenantRoles.Owner })); + .Returns(new ICallerContext.CallerRoles([], new[] { TenantRoles.Owner })); var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, - OrganizationOwnership.Personal, "auserid".ToId(), DisplayName.Create("aname").Value).Value; + OrganizationOwnership.Personal, "auserid".ToId(), UserClassification.Person, + DisplayName.Create("aname").Value).Value; await org.ChangeAvatarAsync("auserid".ToId(), Roles.Create(TenantRoles.Owner).Value, _ => Task.FromResult>(Avatar.Create("anoldimageid".ToId(), "aurl").Value), _ => Task.FromResult(Result.Ok)); @@ -507,7 +524,8 @@ public async Task WhenDeleteAvatarAsyncAndNotOwner_ThenReturnsError() _caller.Setup(cc => cc.Roles) .Returns(new ICallerContext.CallerRoles()); var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, - OrganizationOwnership.Personal, "auserid".ToId(), DisplayName.Create("aname").Value).Value; + OrganizationOwnership.Personal, "auserid".ToId(), UserClassification.Person, + DisplayName.Create("aname").Value).Value; _repository.Setup(rep => rep.LoadAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(org); @@ -521,9 +539,10 @@ public async Task WhenDeleteAvatarAsyncAndNotOwner_ThenReturnsError() public async Task WhenDeleteAvatarAsync_ThenReturnsOrganization() { _caller.Setup(cc => cc.Roles) - .Returns(new ICallerContext.CallerRoles(Array.Empty(), new[] { TenantRoles.Owner })); + .Returns(new ICallerContext.CallerRoles([], new[] { TenantRoles.Owner })); var org = OrganizationRoot.Create(_recorder.Object, _idFactory.Object, _tenantSettingService.Object, - OrganizationOwnership.Personal, "auserid".ToId(), DisplayName.Create("aname").Value).Value; + OrganizationOwnership.Personal, "auserid".ToId(), UserClassification.Person, + DisplayName.Create("aname").Value).Value; await org.ChangeAvatarAsync("auserid".ToId(), Roles.Create(TenantRoles.Owner).Value, _ => Task.FromResult>(Avatar.Create("anoldimageid".ToId(), "aurl").Value), _ => Task.FromResult(Result.Ok)); diff --git a/src/OrganizationsApplication/OrganizationsApplication.DomainEventHandlers.cs b/src/OrganizationsApplication/OrganizationsApplication.DomainEventHandlers.cs index 4b04d4d4..bdac6995 100644 --- a/src/OrganizationsApplication/OrganizationsApplication.DomainEventHandlers.cs +++ b/src/OrganizationsApplication/OrganizationsApplication.DomainEventHandlers.cs @@ -1,9 +1,9 @@ using Application.Interfaces; -using Application.Resources.Shared; using Common; using Common.Extensions; using Domain.Common.ValueObjects; using Domain.Events.Shared.EndUsers; +using Domain.Shared.Organizations; namespace OrganizationsApplication; @@ -14,7 +14,8 @@ public async Task> HandleEndUserRegisteredAsync(ICallerContext cal { var name = $"{domainEvent.UserProfile.FirstName}{(domainEvent.UserProfile.LastName.HasValue() ? " " + domainEvent.UserProfile.LastName : string.Empty)}"; - var organization = await CreateOrganizationAsync(caller, domainEvent.RootId.ToId(), name, + var organization = await CreateOrganizationInternalAsync(caller, domainEvent.RootId.ToId(), + domainEvent.Classification, name, OrganizationOwnership.Personal, cancellationToken); if (!organization.IsSuccessful) { diff --git a/src/OrganizationsApplication/OrganizationsApplication.cs b/src/OrganizationsApplication/OrganizationsApplication.cs index a445ec6e..b1b0ace0 100644 --- a/src/OrganizationsApplication/OrganizationsApplication.cs +++ b/src/OrganizationsApplication/OrganizationsApplication.cs @@ -10,6 +10,7 @@ using Domain.Interfaces.Authorization; using Domain.Interfaces.Services; using Domain.Shared; +using Domain.Shared.EndUsers; using OrganizationsApplication.Persistence; using OrganizationsDomain; using OrganizationOwnership = Domain.Shared.Organizations.OrganizationOwnership; @@ -76,9 +77,17 @@ public async Task> ChangeSettingsAsync(ICallerContext caller, stri public async Task> CreateSharedOrganizationAsync(ICallerContext caller, string name, CancellationToken cancellationToken) { - var creatorId = caller.CallerId; - var created = await CreateOrganizationAsync(caller, creatorId, name, - Application.Resources.Shared.OrganizationOwnership.Shared, cancellationToken); + var userId = caller.ToCallerId(); + var retrieved = await _endUsersService.GetUserPrivateAsync(caller, userId, cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + var user = retrieved.Value; + var created = await CreateOrganizationInternalAsync(caller, user.Id, + user.Classification.ToEnumOrDefault(UserClassification.Person), name, + OrganizationOwnership.Shared, cancellationToken); if (!created.IsSuccessful) { return created.Error; @@ -304,8 +313,9 @@ public async Task> DeleteAvatarAsync(ICallerContext return org.ToOrganization(); } - private async Task> CreateOrganizationAsync(ICallerContext caller, string creatorId, - string name, Application.Resources.Shared.OrganizationOwnership ownership, CancellationToken cancellationToken) + private async Task> CreateOrganizationInternalAsync(ICallerContext caller, + string creatorId, UserClassification classification, string name, OrganizationOwnership ownership, + CancellationToken cancellationToken) { var displayName = DisplayName.Create(name); if (!displayName.IsSuccessful) @@ -314,7 +324,7 @@ private async Task> CreateOrganizationAsync(ICallerC } var created = OrganizationRoot.Create(_recorder, _identifierFactory, _tenantSettingService, - ownership.ToEnumOrDefault(OrganizationOwnership.Shared), creatorId.ToId(), displayName.Value); + ownership, creatorId.ToId(), classification, displayName.Value); if (!created.IsSuccessful) { return created.Error; diff --git a/src/OrganizationsDomain.UnitTests/OrganizationRootSpec.cs b/src/OrganizationsDomain.UnitTests/OrganizationRootSpec.cs index 569c6e18..9bb1ad9c 100644 --- a/src/OrganizationsDomain.UnitTests/OrganizationRootSpec.cs +++ b/src/OrganizationsDomain.UnitTests/OrganizationRootSpec.cs @@ -7,6 +7,7 @@ using Domain.Interfaces.Entities; using Domain.Interfaces.Services; using Domain.Shared; +using Domain.Shared.EndUsers; using Domain.Shared.Organizations; using FluentAssertions; using Moq; @@ -33,15 +34,25 @@ public OrganizationRootSpec() .Returns((string value) => value); _org = OrganizationRoot.Create(recorder.Object, identifierFactory.Object, tenantSettingService.Object, - OrganizationOwnership.Personal, "acreatorid".ToId(), DisplayName.Create("aname").Value).Value; + OrganizationOwnership.Shared, "acreatorid".ToId(), UserClassification.Person, + DisplayName.Create("aname").Value).Value; } + [Fact] + public void WhenCreateWithMachineUser_ThenReturnsError() + { + var result = OrganizationRoot.Create(new Mock().Object, new Mock().Object, + new Mock().Object, OrganizationOwnership.Shared, "acreatorid".ToId(), + UserClassification.Machine, DisplayName.Create("aname").Value); + + result.Should().BeError(ErrorCode.RuleViolation, Resources.OrganizationRoot_Create_SharedRequiresPerson); + } [Fact] public void WhenCreate_ThenAssigns() { _org.Name.Name.Should().Be("aname"); _org.CreatedById.Should().Be("acreatorid".ToId()); - _org.Ownership.Should().Be(OrganizationOwnership.Personal); + _org.Ownership.Should().Be(OrganizationOwnership.Shared); _org.Settings.Should().Be(Settings.Empty); } @@ -101,6 +112,19 @@ public void WhenAddMembershipAndNoUser_ThenReturnsError() Resources.OrganizationRoot_AddMembership_UserIdAndEmailMissing); } + [Fact] + public void WhenAddMembershipAndNotShared_ThenReturnsError() + { +#if TESTINGONLY + _org.TestingOnly_ChangeOwnership(OrganizationOwnership.Personal); +#endif + var result = _org.AddMembership("aninviterid".ToId(), Roles.Create(TenantRoles.Owner).Value, + Optional.None, Optional.None); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.OrganizationRoot_AddMembership_PersonalOrgMembershipNotAllowed); + } + [Fact] public void WhenAddMembershipWithUserId_ThenAddsMembership() { diff --git a/src/OrganizationsDomain/OrganizationRoot.cs b/src/OrganizationsDomain/OrganizationRoot.cs index a0b7eb6e..d3f0a8aa 100644 --- a/src/OrganizationsDomain/OrganizationRoot.cs +++ b/src/OrganizationsDomain/OrganizationRoot.cs @@ -10,6 +10,7 @@ using Domain.Interfaces.Services; using Domain.Interfaces.ValueObjects; using Domain.Shared; +using Domain.Shared.EndUsers; using Domain.Shared.Organizations; namespace OrganizationsDomain; @@ -20,8 +21,14 @@ public sealed class OrganizationRoot : AggregateRootBase public static Result Create(IRecorder recorder, IIdentifierFactory idFactory, ITenantSettingService tenantSettingService, OrganizationOwnership ownership, Identifier createdBy, - DisplayName name) + UserClassification classification, DisplayName name) { + if (ownership == OrganizationOwnership.Shared + && classification != UserClassification.Person) + { + return Error.RuleViolation(Resources.OrganizationRoot_Create_SharedRequiresPerson); + } + var root = new OrganizationRoot(recorder, idFactory, tenantSettingService); root.RaiseCreateEvent(OrganizationsDomain.Events.Created(root.Id, ownership, createdBy, name)); return root; @@ -169,6 +176,11 @@ public Result AddMembership(Identifier inviterId, Roles inviterRoles, Opt return Error.RoleViolation(Resources.OrganizationRoot_NotOrgOwner); } + if (Ownership == OrganizationOwnership.Personal) + { + return Error.RuleViolation(Resources.OrganizationRoot_AddMembership_PersonalOrgMembershipNotAllowed); + } + if (!userId.HasValue && !emailAddress.HasValue) { @@ -208,6 +220,20 @@ public async Task> ChangeAvatarAsync(Identifier modifierId, Roles return RaiseChangeEvent(OrganizationsDomain.Events.AvatarAdded(Id, created.Value)); } + public Result CreateSettings(Settings settings) + { + foreach (var (key, value) in settings.Properties) + { + var valueValue = value.IsEncrypted + ? _tenantSettingService.Encrypt(value.Value.ToString() ?? string.Empty) + : value.Value.ToString() ?? string.Empty; + RaiseChangeEvent(OrganizationsDomain.Events.SettingCreated(Id, key, valueValue, value.ValueType, + value.IsEncrypted)); + } + + return Result.Ok; + } + public async Task> DeleteAvatarAsync(Identifier deleterId, Roles deleterRoles, RemoveAvatarAction onRemoveOld) { @@ -231,19 +257,12 @@ public async Task> DeleteAvatarAsync(Identifier deleterId, Roles d return RaiseChangeEvent(OrganizationsDomain.Events.AvatarRemoved(Id, avatarId)); } - public Result CreateSettings(Settings settings) +#if TESTINGONLY + public void TestingOnly_ChangeOwnership(OrganizationOwnership ownership) { - foreach (var (key, value) in settings.Properties) - { - var valueValue = value.IsEncrypted - ? _tenantSettingService.Encrypt(value.Value.ToString() ?? string.Empty) - : value.Value.ToString() ?? string.Empty; - RaiseChangeEvent(OrganizationsDomain.Events.SettingCreated(Id, key, valueValue, value.ValueType, - value.IsEncrypted)); - } - - return Result.Ok; + Ownership = ownership; } +#endif public Result UpdateSettings(Settings settings) { diff --git a/src/OrganizationsDomain/Resources.Designer.cs b/src/OrganizationsDomain/Resources.Designer.cs index 68e831bb..4d4e9859 100644 --- a/src/OrganizationsDomain/Resources.Designer.cs +++ b/src/OrganizationsDomain/Resources.Designer.cs @@ -68,6 +68,15 @@ internal static string OrganizationDisplayName_InvalidName { } } + /// + /// Looks up a localized string similar to Cannot add another user to a Personal organization. + /// + internal static string OrganizationRoot_AddMembership_PersonalOrgMembershipNotAllowed { + get { + return ResourceManager.GetString("OrganizationRoot_AddMembership_PersonalOrgMembershipNotAllowed", resourceCulture); + } + } + /// /// Looks up a localized string similar to Both the ID of the user and an email for the user is missing. /// @@ -77,6 +86,15 @@ internal static string OrganizationRoot_AddMembership_UserIdAndEmailMissing { } } + /// + /// Looks up a localized string similar to Must be a person to create a 'Shared' organization. + /// + internal static string OrganizationRoot_Create_SharedRequiresPerson { + get { + return ResourceManager.GetString("OrganizationRoot_Create_SharedRequiresPerson", resourceCulture); + } + } + /// /// Looks up a localized string similar to This organization has no avatar to delete. /// @@ -87,7 +105,7 @@ internal static string OrganizationRoot_NoAvatar { } /// - /// Looks up a localized string similar to You must be an organization owner to perform this action. + /// Looks up a localized string similar to Must be an organization owner to perform this action. /// internal static string OrganizationRoot_NotOrgOwner { get { diff --git a/src/OrganizationsDomain/Resources.resx b/src/OrganizationsDomain/Resources.resx index 404a7731..708d277a 100644 --- a/src/OrganizationsDomain/Resources.resx +++ b/src/OrganizationsDomain/Resources.resx @@ -33,8 +33,11 @@ The value type of the value: '{0}' is unsupported + + Must be a person to create a 'Shared' organization + - You must be an organization owner to perform this action + Must be an organization owner to perform this action Both the ID of the user and an email for the user is missing @@ -42,4 +45,7 @@ This organization has no avatar to delete + + Cannot add another user to a Personal organization + \ No newline at end of file diff --git a/src/OrganizationsInfrastructure/Api/Organizations/OrganizationsApi.cs b/src/OrganizationsInfrastructure/Api/Organizations/OrganizationsApi.cs index 76692718..73433477 100644 --- a/src/OrganizationsInfrastructure/Api/Organizations/OrganizationsApi.cs +++ b/src/OrganizationsInfrastructure/Api/Organizations/OrganizationsApi.cs @@ -41,7 +41,7 @@ await _organizationsApplication.ChangeAvatarAsync(_contextFactory.Create(), requ uploaded.Value, cancellationToken); return () => - org.HandleApplicationResult(o => + org.HandleApplicationResult(o => new GetOrganizationResponse { Organization = o }); } @@ -52,7 +52,7 @@ public async Task> Create(C await _organizationsApplication.CreateSharedOrganizationAsync(_contextFactory.Create(), request.Name, cancellationToken); - return () => organization.HandleApplicationResult(org => + return () => organization.HandleApplicationResult(org => new PostResult(new GetOrganizationResponse { Organization = org })); } @@ -63,7 +63,7 @@ public async Task> DeleteAvatar cancellationToken); return () => - org.HandleApplicationResult(o => + org.HandleApplicationResult(o => new GetOrganizationResponse { Organization = o }); } @@ -75,7 +75,7 @@ await _organizationsApplication.GetOrganizationAsync(_contextFactory.Create(), r cancellationToken); return () => - organization.HandleApplicationResult(org => + organization.HandleApplicationResult(org => new GetOrganizationResponse { Organization = org }); } @@ -88,7 +88,7 @@ await _organizationsApplication.GetOrganizationSettingsAsync(_contextFactory.Cre cancellationToken); return () => - organization.HandleApplicationResult(org => + organization.HandleApplicationResult(org => new GetOrganizationSettingsResponse { Organization = org, @@ -106,7 +106,7 @@ await _organizationsApplication.InviteMemberToOrganizationAsync(_contextFactory. request.UserId, request.Email, cancellationToken); - return () => organization.HandleApplicationResult(org => + return () => organization.HandleApplicationResult(org => new PostResult(new InviteMemberToOrganizationResponse { Organization = org })); } diff --git a/src/SaaStack.sln.DotSettings b/src/SaaStack.sln.DotSettings index fa56eec9..3c506a2d 100644 --- a/src/SaaStack.sln.DotSettings +++ b/src/SaaStack.sln.DotSettings @@ -760,7 +760,7 @@ public class $Action$$Resource$RequestValidator : AbstractValidator<$Action$$ { var $resource$ = await _application.$Action$$Resource$Async(_contextFactory.Create(), request.Id, cancellationToken); - return () => $resource$.HandleApplicationResult<$Action$$Resource$Response, $Resource$>(x => new PostResult<$Action$$Resource$Response>(new $Action$$Resource$Response { $Resource$ = x })); + return () => $resource$.HandleApplicationResult<$Resource$, $Action$$Resource$Response>(x => new PostResult<$Action$$Resource$Response>(new $Action$$Resource$Response { $Resource$ = x })); } True True @@ -1215,7 +1215,7 @@ public class $Action$$Resource$Response : IWebResponse { var $resource$ = await _application.$Action$$Resource$Async(_contextFactory.Create(), request.Id, cancellationToken); - return () => $resource$.HandleApplicationResult<$Action$$Resource$Response, $Resource$>(x => new $Action$$Resource$Response { $Resource$ = x }); + return () => $resource$.HandleApplicationResult<$Resource$, $Action$$Resource$Response>(x => new $Action$$Resource$Response { $Resource$ = x }); } True True @@ -1390,7 +1390,7 @@ public sealed class $name$Root : AggregateRootBase { var $resource$ = await _application.$Action$$Resource$Async(_contextFactory.Create(), request.Id, cancellationToken); - return () => $resource$.HandleApplicationResult<$Action$$Resource$Response, $Resource$>(x => new $Action$$Resource$Response { $Resource$ = x }); + return () => $resource$.HandleApplicationResult<$Resource$, $Action$$Resource$Response>(x => new $Action$$Resource$Response { $Resource$ = x }); } True True diff --git a/src/UnitTesting.Common/ResultAssertions.cs b/src/UnitTesting.Common/ResultAssertions.cs index 4e499ef0..17df87ca 100644 --- a/src/UnitTesting.Common/ResultAssertions.cs +++ b/src/UnitTesting.Common/ResultAssertions.cs @@ -50,9 +50,11 @@ public AndConstraint BeSuccess(string because = "", params obj .Given(() => Subject) .ForCondition(result => result.IsSuccessful) .FailWith( - "Expected {context:result} to return a Successful value {reason}, but it returned an Error with code {0}.", + "Expected {context:result} to return a Successful value {reason}, but it returned an Error {0}.", result => !result.IsSuccessful - ? result.Error.Code + ? result.Error.Message.HasValue() + ? $"{result.Error.Code}: {result.Error.Message}" + : result.Error.Code : ErrorCode.NoError); return new AndConstraint(this); diff --git a/src/UserProfilesApplication.UnitTests/UserProfileApplication.DomainEventHandlersSpec.cs b/src/UserProfilesApplication.UnitTests/UserProfileApplication.DomainEventHandlersSpec.cs index 89d7711c..d4ad48d5 100644 --- a/src/UserProfilesApplication.UnitTests/UserProfileApplication.DomainEventHandlersSpec.cs +++ b/src/UserProfilesApplication.UnitTests/UserProfileApplication.DomainEventHandlersSpec.cs @@ -101,7 +101,7 @@ public async Task WhenHandleEndUserRegisteredAsyncForAnyAndExistsForEmailAddress } [Fact] - public async Task WhenCreateProfileAsyncForMachine_ThenCreatesProfile() + public async Task WhenHandleEndUserRegisteredAsyncForMachine_ThenCreatesProfile() { var domainEvent = Events.Registered("amachineid".ToId(), EndUserProfile.Create("afirstname").Value, EmailAddress.Create("amachine@company.com").Value, UserClassification.Machine, UserAccess.Enabled, @@ -203,4 +203,25 @@ public async Task WhenHandleEndUserRegisteredAsyncForPersonAndHasDefaultAvatar_T && up.Avatar.Value.Url == "aurl" ), It.IsAny())); } + + [Fact] + public async Task WhenHandleEndUserDefaultOrganizationChangedAsync_ThenSetsDefaultOrganization() + { + var domainEvent = Events.MembershipDefaultChanged("auserid".ToId(), "amembershipid".ToId(), + "amembershipid".ToId(), "anorganizationid".ToId(), Roles.Empty, Features.Empty); + var user = UserProfileRoot.Create(_recorder.Object, _idFactory.Object, ProfileType.Person, "auserid".ToId(), + PersonName.Create("afirstname", "alastname").Value).Value; + _repository.Setup(rep => rep.FindByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(user.ToOptional()); + + var result = await _application.HandleEndUserDefaultOrganizationChangedAsync(_caller.Object, + domainEvent, CancellationToken.None); + + result.Should().BeSuccess(); + _repository.Verify(rep => rep.SaveAsync(It.Is(up => + up.UserId == "auserid".ToId() + && up.DefaultOrganizationId == "anorganizationid".ToId() + ), It.IsAny())); + _repository.Verify(rep => rep.FindByUserIdAsync("auserid".ToId(), It.IsAny())); + } } \ No newline at end of file diff --git a/src/UserProfilesApplication.UnitTests/UserProfileApplicationSpec.cs b/src/UserProfilesApplication.UnitTests/UserProfileApplicationSpec.cs index 5c59189c..bcffb459 100644 --- a/src/UserProfilesApplication.UnitTests/UserProfileApplicationSpec.cs +++ b/src/UserProfilesApplication.UnitTests/UserProfileApplicationSpec.cs @@ -4,6 +4,8 @@ using Common; using Domain.Common.Identity; using Domain.Common.ValueObjects; +using Domain.Interfaces; +using Domain.Interfaces.Authorization; using Domain.Interfaces.Entities; using Domain.Shared; using FluentAssertions; @@ -470,4 +472,50 @@ await user.ChangeAvatarAsync("auserid".ToId(), _imagesService.Verify( isv => isv.DeleteImageAsync(_caller.Object, "anoldimageid", It.IsAny())); } + + [Fact] + public async Task WhenGetCurrentUserProfileAsyncAndNotAuthenticated_ThenReturnsAnonymousProfile() + { + _caller.Setup(cc => cc.IsAuthenticated) + .Returns(false); + + var result = await _application.GetCurrentUserProfileAsync(_caller.Object, CancellationToken.None); + + result.Value.IsAuthenticated.Should().BeFalse(); + result.Value.Id.Should().Be(CallerConstants.AnonymousUserId); + result.Value.UserId.Should().Be(CallerConstants.AnonymousUserId); + result.Value.Address.CountryCode.Should().Be(CountryCodes.Default.ToString()); + result.Value.Roles.Should().BeEmpty(); + result.Value.Features.Should().BeEmpty(); + } + + [Fact] + public async Task WhenGetCurrentUserProfileAsyncAndAuthenticated_ThenReturnsProfile() + { + _caller.Setup(cc => cc.IsAuthenticated) + .Returns(true); + var roles = new ICallerContext.CallerRoles(new[] { PlatformRoles.Standard }, Array.Empty()); + var features = new ICallerContext.CallerFeatures(new[] { PlatformFeatures.Basic }, Array.Empty()); + _caller.Setup(cc => cc.Roles) + .Returns(roles); + _caller.Setup(cc => cc.Features) + .Returns(features); + var profile = UserProfileRoot.Create(_recorder.Object, _idFactory.Object, ProfileType.Person, "auserid".ToId(), + PersonName.Create("afirstname", "alastname").Value).Value; + _repository.Setup(rep => rep.FindByUserIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(profile.ToOptional()); + + var result = await _application.GetCurrentUserProfileAsync(_caller.Object, CancellationToken.None); + + result.Value.IsAuthenticated.Should().BeTrue(); + result.Value.Id.Should().Be("anid"); + result.Value.UserId.Should().Be("auserid"); + result.Value.Name.FirstName.Should().Be("afirstname"); + result.Value.Name.LastName.Should().Be("alastname"); + result.Value.DisplayName.Should().Be("afirstname"); + result.Value.Timezone.Should().Be(Timezones.Default.ToString()); + result.Value.Address.CountryCode.Should().Be(CountryCodes.Default.ToString()); + result.Value.Roles.Should().ContainInOrder(PlatformRoles.Standard.Name); + result.Value.Features.Should().ContainInOrder(PlatformFeatures.Basic.Name); + } } \ No newline at end of file diff --git a/src/UserProfilesApplication/IUserProfilesApplication.DomainEventHandlers.cs b/src/UserProfilesApplication/IUserProfilesApplication.DomainEventHandlers.cs index 35303fbe..6b0fb0b9 100644 --- a/src/UserProfilesApplication/IUserProfilesApplication.DomainEventHandlers.cs +++ b/src/UserProfilesApplication/IUserProfilesApplication.DomainEventHandlers.cs @@ -8,4 +8,8 @@ partial interface IUserProfilesApplication { Task> HandleEndUserRegisteredAsync(ICallerContext caller, Registered domainEvent, CancellationToken cancellationToken); + + Task> HandleEndUserDefaultOrganizationChangedAsync(ICallerContext caller, + MembershipDefaultChanged domainEvent, + CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/UserProfilesApplication/IUserProfilesApplication.cs b/src/UserProfilesApplication/IUserProfilesApplication.cs index c4fbb3bb..03e76c11 100644 --- a/src/UserProfilesApplication/IUserProfilesApplication.cs +++ b/src/UserProfilesApplication/IUserProfilesApplication.cs @@ -27,6 +27,9 @@ Task, Error>> FindPersonByEmailAddressAsync(ICaller Task, Error>> GetAllProfilesAsync(ICallerContext caller, List ids, GetOptions options, CancellationToken cancellationToken); + Task> GetCurrentUserProfileAsync(ICallerContext caller, + CancellationToken cancellationToken); + Task> GetProfileAsync(ICallerContext caller, string userId, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/UserProfilesApplication/Persistence/ReadModels/UserProfile.cs b/src/UserProfilesApplication/Persistence/ReadModels/UserProfile.cs index b971f9d3..b4048702 100644 --- a/src/UserProfilesApplication/Persistence/ReadModels/UserProfile.cs +++ b/src/UserProfilesApplication/Persistence/ReadModels/UserProfile.cs @@ -28,4 +28,6 @@ public class UserProfile : ReadModelEntity public Optional Type { get; set; } public Optional UserId { get; set; } + + public Optional DefaultOrganizationId { get; set; } } \ No newline at end of file diff --git a/src/UserProfilesApplication/UserProfilesApplication.DomainEventHandlers.cs b/src/UserProfilesApplication/UserProfilesApplication.DomainEventHandlers.cs index 6aa210e4..6519c5c7 100644 --- a/src/UserProfilesApplication/UserProfilesApplication.DomainEventHandlers.cs +++ b/src/UserProfilesApplication/UserProfilesApplication.DomainEventHandlers.cs @@ -1,8 +1,13 @@ +using Application.Common.Extensions; using Application.Interfaces; using Application.Resources.Shared; using Common; using Common.Extensions; +using Domain.Common.ValueObjects; using Domain.Events.Shared.EndUsers; +using Domain.Shared; +using UserProfilesDomain; +using PersonName = Domain.Shared.PersonName; namespace UserProfilesApplication; @@ -23,4 +28,179 @@ await CreateProfileAsync(caller, classification, domainEvent.RootId, domainEvent return Result.Ok; } + + public async Task> HandleEndUserDefaultOrganizationChangedAsync(ICallerContext caller, + MembershipDefaultChanged domainEvent, + CancellationToken cancellationToken) + { + var profile = await UpdateDefaultOrganizationAsync(caller, domainEvent.RootId, domainEvent.ToOrganizationId, + cancellationToken); + if (!profile.IsSuccessful) + { + return profile.Error; + } + + return Result.Ok; + } + + private async Task> CreateProfileAsync(ICallerContext caller, + UserProfileClassification classification, string userId, string? emailAddress, string firstName, + string? lastName, string? timezone, string? countryCode, + CancellationToken cancellationToken) + { + if (classification == UserProfileClassification.Person && emailAddress.HasNoValue()) + { + return Error.RuleViolation(Resources.UserProfilesApplication_PersonMustHaveEmailAddress); + } + + var retrievedById = await _repository.FindByUserIdAsync(userId.ToId(), cancellationToken); + if (!retrievedById.IsSuccessful) + { + return retrievedById.Error; + } + + if (retrievedById.Value.HasValue) + { + return Error.EntityExists(Resources.UserProfilesApplication_ProfileExistsForUser); + } + + if (classification == UserProfileClassification.Person && emailAddress.HasValue()) + { + var email = EmailAddress.Create(emailAddress); + if (!email.IsSuccessful) + { + return email.Error; + } + + var retrievedByEmail = await _repository.FindByEmailAddressAsync(email.Value, cancellationToken); + if (!retrievedByEmail.IsSuccessful) + { + return retrievedByEmail.Error; + } + + if (retrievedByEmail.Value.HasValue) + { + return Error.EntityExists(Resources.UserProfilesApplication_ProfileExistsForEmailAddress); + } + } + + var name = PersonName.Create(firstName, classification == UserProfileClassification.Person + ? lastName + : Optional.None); + if (!name.IsSuccessful) + { + return name.Error; + } + + var created = UserProfileRoot.Create(_recorder, _identifierFactory, + classification.ToEnumOrDefault(ProfileType.Person), + userId.ToId(), name.Value); + if (!created.IsSuccessful) + { + return created.Error; + } + + var profile = created.Value; + if (classification == UserProfileClassification.Person) + { + var personEmail = EmailAddress.Create(emailAddress!); + if (!personEmail.IsSuccessful) + { + return personEmail.Error; + } + + var emailed = profile.SetEmailAddress(userId.ToId(), personEmail.Value); + if (!emailed.IsSuccessful) + { + return emailed.Error; + } + } + + var address = Address.Create(CountryCodes.FindOrDefault(countryCode)); + if (!address.IsSuccessful) + { + return address.Error; + } + + var contacted = profile.SetContactAddress(userId.ToId(), address.Value); + if (!contacted.IsSuccessful) + { + return contacted.Error; + } + + var tz = Timezone.Create(Timezones.FindOrDefault(timezone)); + if (!tz.IsSuccessful) + { + return tz.Error; + } + + var timezoned = profile.SetTimezone(userId.ToId(), tz.Value); + if (!timezoned.IsSuccessful) + { + return timezoned.Error; + } + + if (classification == UserProfileClassification.Person) + { + //Attempt to download the default avatar for the user. If this fails, we just move on + var defaultAvatared = await _avatarService.FindAvatarAsync(caller, emailAddress!, cancellationToken); + if (defaultAvatared is { IsSuccessful: true, Value.HasValue: true }) + { + var upload = defaultAvatared.Value.Value; + var avatared = + await ChangeAvatarInternalAsync(caller, userId.ToId(), profile, upload, cancellationToken); + if (!avatared.IsSuccessful) + { + return avatared.Error; + } + } + } + + var saved = await _repository.SaveAsync(profile, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + profile = saved.Value; + _recorder.TraceInformation(caller.ToCall(), "Profile {Id} was created for user {UserId}", profile.Id, + profile.UserId); + + return profile.ToProfile(); + } + + private async Task> UpdateDefaultOrganizationAsync(ICallerContext caller, string userId, + string defaultOrganizationId, CancellationToken cancellationToken) + { + var retrieved = await _repository.FindByUserIdAsync(userId.ToId(), cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + if (!retrieved.Value.HasValue) + { + return Error.EntityNotFound(); + } + + var profile = retrieved.Value.Value; + var defaulted = profile.ChangeDefaultOrganization(caller.ToCallerId(), defaultOrganizationId.ToId()); + if (!defaulted.IsSuccessful) + { + return defaulted.Error; + } + + var saved = await _repository.SaveAsync(profile, cancellationToken); + if (!saved.IsSuccessful) + { + return saved.Error; + } + + profile = saved.Value; + _recorder.TraceInformation(caller.ToCall(), "Profile {Id} updated its default organization for user {UserId}", + profile.Id, + profile.UserId); + + return profile.ToProfile(); + } } \ No newline at end of file diff --git a/src/UserProfilesApplication/UserProfilesApplication.cs b/src/UserProfilesApplication/UserProfilesApplication.cs index 85881abf..853766ed 100644 --- a/src/UserProfilesApplication/UserProfilesApplication.cs +++ b/src/UserProfilesApplication/UserProfilesApplication.cs @@ -6,6 +6,7 @@ using Common.Extensions; using Domain.Common.Identity; using Domain.Common.ValueObjects; +using Domain.Interfaces; using Domain.Shared; using UserProfilesApplication.Persistence; using UserProfilesDomain; @@ -129,6 +130,54 @@ public async Task, Error>> FindPersonByEmailAddress return Optional.None; } + public async Task> GetCurrentUserProfileAsync(ICallerContext caller, + CancellationToken cancellationToken) + { + if (!caller.IsAuthenticated) + { + return new UserProfileForCurrent + { + Address = new ProfileAddress + { + CountryCode = CountryCodes.Default.ToString() + }, + AvatarUrl = null, + DisplayName = CallerConstants.AnonymousUserId, + EmailAddress = null, + Name = new Application.Resources.Shared.PersonName + { + FirstName = CallerConstants.AnonymousUserId + }, + PhoneNumber = null, + Timezone = null, + Classification = UserProfileClassification.Person, + UserId = CallerConstants.AnonymousUserId, + Id = CallerConstants.AnonymousUserId, + DefaultOrganizationId = null, + IsAuthenticated = false, + Features = [], + Roles = [] + }; + } + + var retrieved = await _repository.FindByUserIdAsync(caller.ToCallerId(), cancellationToken); + if (!retrieved.IsSuccessful) + { + return retrieved.Error; + } + + if (!retrieved.Value.HasValue) + { + return Error.EntityNotFound(); + } + + var profile = retrieved.Value.Value; + _recorder.TraceInformation(caller.ToCall(), "Profile {Id} was retrieved for current user {UserId}", profile.Id, + profile.UserId); + + return profile.ToCurrentProfile(caller); + } + public async Task> GetProfileAsync(ICallerContext caller, string userId, CancellationToken cancellationToken) { @@ -150,7 +199,8 @@ public async Task> GetProfileAsync(ICallerContext cal } var profile = retrieved.Value.Value; - _recorder.TraceInformation(caller.ToCall(), "Profile {Id} was retrieved for user {UserId}", profile.Id, userId); + _recorder.TraceInformation(caller.ToCall(), "Profile {Id} was retrieved for user {UserId}", profile.Id, + profile.UserId); return profile.ToProfile(); } @@ -240,7 +290,8 @@ public async Task> ChangeProfileAsync(ICallerContext } profile = saved.Value; - _recorder.TraceInformation(caller.ToCall(), "Profile {Id} was updated for user {UserId}", profile.Id, userId); + _recorder.TraceInformation(caller.ToCall(), "Profile {Id} was updated for user {UserId}", profile.Id, + profile.UserId); return profile.ToProfile(); } @@ -330,130 +381,6 @@ public async Task, Error>> GetAllProfilesAsync(ICallerC .ToList(); } - private async Task> CreateProfileAsync(ICallerContext caller, - UserProfileClassification classification, string userId, string? emailAddress, string firstName, - string? lastName, string? timezone, string? countryCode, - CancellationToken cancellationToken) - { - if (classification == UserProfileClassification.Person && emailAddress.HasNoValue()) - { - return Error.RuleViolation(Resources.UserProfilesApplication_PersonMustHaveEmailAddress); - } - - var retrievedById = await _repository.FindByUserIdAsync(userId.ToId(), cancellationToken); - if (!retrievedById.IsSuccessful) - { - return retrievedById.Error; - } - - if (retrievedById.Value.HasValue) - { - return Error.EntityExists(Resources.UserProfilesApplication_ProfileExistsForUser); - } - - if (classification == UserProfileClassification.Person && emailAddress.HasValue()) - { - var email = EmailAddress.Create(emailAddress); - if (!email.IsSuccessful) - { - return email.Error; - } - - var retrievedByEmail = await _repository.FindByEmailAddressAsync(email.Value, cancellationToken); - if (!retrievedByEmail.IsSuccessful) - { - return retrievedByEmail.Error; - } - - if (retrievedByEmail.Value.HasValue) - { - return Error.EntityExists(Resources.UserProfilesApplication_ProfileExistsForEmailAddress); - } - } - - var name = PersonName.Create(firstName, classification == UserProfileClassification.Person - ? lastName - : Optional.None); - if (!name.IsSuccessful) - { - return name.Error; - } - - var created = UserProfileRoot.Create(_recorder, _identifierFactory, - classification.ToEnumOrDefault(ProfileType.Person), - userId.ToId(), name.Value); - if (!created.IsSuccessful) - { - return created.Error; - } - - var profile = created.Value; - if (classification == UserProfileClassification.Person) - { - var personEmail = EmailAddress.Create(emailAddress!); - if (!personEmail.IsSuccessful) - { - return personEmail.Error; - } - - var emailed = profile.SetEmailAddress(userId.ToId(), personEmail.Value); - if (!emailed.IsSuccessful) - { - return emailed.Error; - } - } - - var address = Address.Create(CountryCodes.FindOrDefault(countryCode)); - if (!address.IsSuccessful) - { - return address.Error; - } - - var contacted = profile.SetContactAddress(userId.ToId(), address.Value); - if (!contacted.IsSuccessful) - { - return contacted.Error; - } - - var tz = Timezone.Create(Timezones.FindOrDefault(timezone)); - if (!tz.IsSuccessful) - { - return tz.Error; - } - - var timezoned = profile.SetTimezone(userId.ToId(), tz.Value); - if (!timezoned.IsSuccessful) - { - return timezoned.Error; - } - - if (classification == UserProfileClassification.Person) - { - //Attempt to download the default avatar for the user. If this fails, we just move on - var defaultAvatared = await _avatarService.FindAvatarAsync(caller, emailAddress!, cancellationToken); - if (defaultAvatared is { IsSuccessful: true, Value.HasValue: true }) - { - var upload = defaultAvatared.Value.Value; - var avatared = - await ChangeAvatarInternalAsync(caller, userId.ToId(), profile, upload, cancellationToken); - if (!avatared.IsSuccessful) - { - return avatared.Error; - } - } - } - - var saved = await _repository.SaveAsync(profile, cancellationToken); - if (!saved.IsSuccessful) - { - return saved.Error; - } - - profile = saved.Value; - _recorder.TraceInformation(caller.ToCall(), "Profile {Id} was created for user {UserId}", profile.Id, userId); - - return profile.ToProfile(); - } private async Task> ChangeAvatarInternalAsync(ICallerContext caller, Identifier modifierId, UserProfileRoot profile, FileUpload upload, CancellationToken cancellationToken) @@ -479,6 +406,17 @@ private async Task> ChangeAvatarInternalAsync(ICallerContext calle internal static class UserProfileConversionExtensions { + public static UserProfileForCurrent ToCurrentProfile(this UserProfileRoot profile, ICallerContext caller) + { + var dto = profile.ToProfile().Convert(); + dto.IsAuthenticated = caller.IsAuthenticated; + dto.Roles = caller.Roles.Platform.Select(rol => rol.Name).ToList(); + dto.Features = caller.Features.Platform.Select(feat => feat.Name).ToList(); + dto.DefaultOrganizationId = profile.DefaultOrganizationId.ValueOrDefault!; + + return dto; + } + public static UserProfile ToProfile(this UserProfileRoot profile) { return new UserProfile diff --git a/src/UserProfilesDomain/Events.cs b/src/UserProfilesDomain/Events.cs index 4b97c3e0..ef6c4f3d 100644 --- a/src/UserProfilesDomain/Events.cs +++ b/src/UserProfilesDomain/Events.cs @@ -1,3 +1,4 @@ +using Common; using Domain.Common.ValueObjects; using Domain.Events.Shared.UserProfiles; using Domain.Shared; @@ -52,6 +53,16 @@ public static Created Created(Identifier id, ProfileType type, Identifier userId }; } + public static DefaultOrganizationChanged DefaultOrganizationChanged(Identifier id, Identifier userId, + Optional fromOrganizationId, Identifier toOrganizationId) + { + return new DefaultOrganizationChanged(id) + { + FromOrganizationId = fromOrganizationId.ValueOrDefault!, + ToOrganizationId = toOrganizationId + }; + } + public static DisplayNameChanged DisplayNameChanged(Identifier id, Identifier userId, PersonDisplayName name) { return new DisplayNameChanged(id) diff --git a/src/UserProfilesDomain/UserProfileRoot.cs b/src/UserProfilesDomain/UserProfileRoot.cs index 152e976f..dd7a3a2a 100644 --- a/src/UserProfilesDomain/UserProfileRoot.cs +++ b/src/UserProfilesDomain/UserProfileRoot.cs @@ -35,6 +35,8 @@ private UserProfileRoot(IRecorder recorder, IIdentifierFactory idFactory, public Optional Avatar { get; private set; } + public Optional DefaultOrganizationId { get; private set; } + public Optional DisplayName { get; private set; } public Optional EmailAddress { get; private set; } @@ -212,6 +214,14 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco return Result.Ok; } + case DefaultOrganizationChanged changed: + { + DefaultOrganizationId = changed.ToOrganizationId.ToId(); + Recorder.TraceDebug(null, "Profile {Id} changed its default organization to {OrganizationId}", Id, + changed.ToOrganizationId); + return Result.Ok; + } + default: return HandleUnKnownStateChangedEvent(@event); } @@ -251,6 +261,17 @@ public async Task> ChangeAvatarAsync(Identifier modifierId, Create return RaiseChangeEvent(UserProfilesDomain.Events.AvatarAdded(Id, UserId, created.Value)); } + public Result ChangeDefaultOrganization(Identifier modifierId, Identifier organizationId) + { + if (DefaultOrganizationId.HasValue && DefaultOrganizationId.Value == organizationId) + { + return Result.Ok; + } + + return RaiseChangeEvent( + UserProfilesDomain.Events.DefaultOrganizationChanged(Id, UserId, DefaultOrganizationId, organizationId)); + } + public Result ChangeDisplayName(Identifier modifierId, PersonDisplayName displayName) { if (IsNotOwner(modifierId)) diff --git a/src/UserProfilesInfrastructure.IntegrationTests/UserProfileApiSpec.cs b/src/UserProfilesInfrastructure.IntegrationTests/UserProfileApiSpec.cs index 5109b181..12b08e46 100644 --- a/src/UserProfilesInfrastructure.IntegrationTests/UserProfileApiSpec.cs +++ b/src/UserProfilesInfrastructure.IntegrationTests/UserProfileApiSpec.cs @@ -1,6 +1,7 @@ using System.Net; using ApiHost1; using Common; +using Domain.Interfaces; using FluentAssertions; using Infrastructure.Web.Api.Common; using Infrastructure.Web.Api.Common.Extensions; @@ -155,6 +156,36 @@ await Api.PutAsync(new ChangeProfileAvatarRequest result.Content.Value.Profile!.AvatarUrl.Should().BeNull(); } + [Fact] + public async Task WhenGetCurrentUserForAnonymous_ThenNotAuthenticated() + { + var result = await Api.GetAsync(new GetCurrentProfileRequest()); + + result.Content.Value.Profile!.IsAuthenticated.Should().BeFalse(); + result.Content.Value.Profile.Id.Should().Be(CallerConstants.AnonymousUserId); + result.Content.Value.Profile.UserId.Should().Be(CallerConstants.AnonymousUserId); + result.Content.Value.Profile.DefaultOrganizationId.Should().BeNull(); + } + + [Fact] + public async Task WhenGetCurrentUserForAuthenticated_ThenAuthenticated() + { + var login = await LoginUserAsync(); + + var result = await Api.GetAsync(new GetCurrentProfileRequest(), + req => req.SetJWTBearerToken(login.AccessToken)); + + result.Content.Value.Profile!.IsAuthenticated.Should().BeTrue(); + result.Content.Value.Profile.Id.Should().NotBeNullOrEmpty(); + result.Content.Value.Profile.UserId.Should().Be(login.User.Id); + result.Content.Value.Profile.DefaultOrganizationId.Should().NotBeNullOrEmpty(); + result.Content.Value.Profile.Name.FirstName.Should().Be("persona"); + result.Content.Value.Profile.Name.LastName.Should().Be("alastname"); + result.Content.Value.Profile.DisplayName.Should().Be("persona"); + result.Content.Value.Profile.Timezone.Should().Be(Timezones.Default.ToString()); + result.Content.Value.Profile.AvatarUrl.Should().BeNull(); + } + private static void OverrideDependencies(IServiceCollection services) { // do nothing diff --git a/src/UserProfilesInfrastructure/Api/Profiles/UserProfilesApi.cs b/src/UserProfilesInfrastructure/Api/Profiles/UserProfilesApi.cs index 2fc761c7..a7905a95 100644 --- a/src/UserProfilesInfrastructure/Api/Profiles/UserProfilesApi.cs +++ b/src/UserProfilesInfrastructure/Api/Profiles/UserProfilesApi.cs @@ -35,7 +35,7 @@ await _userProfilesApplication.ChangeContactAddressAsync(_contextFactory.Create( cancellationToken); return () => - profile.HandleApplicationResult(pro => new GetProfileResponse + profile.HandleApplicationResult(pro => new GetProfileResponse { Profile = pro }); } @@ -48,7 +48,7 @@ await _userProfilesApplication.ChangeProfileAsync(_contextFactory.Create(), requ cancellationToken); return () => - profile.HandleApplicationResult(pro => new GetProfileResponse + profile.HandleApplicationResult(pro => new GetProfileResponse { Profile = pro }); } @@ -68,7 +68,7 @@ await _userProfilesApplication.ChangeProfileAvatarAsync(_contextFactory.Create() uploaded.Value, cancellationToken); return () => - profile.HandleApplicationResult(pro => + profile.HandleApplicationResult(pro => new ChangeProfileAvatarResponse { Profile = pro }); } @@ -80,7 +80,18 @@ await _userProfilesApplication.DeleteProfileAvatarAsync(_contextFactory.Create() cancellationToken); return () => - profile.HandleApplicationResult(pro => + profile.HandleApplicationResult(pro => new DeleteProfileAvatarResponse { Profile = pro }); } + + public async Task> GetCurrentProfile( + GetCurrentProfileRequest request, CancellationToken cancellationToken) + { + var profile = + await _userProfilesApplication.GetCurrentUserProfileAsync(_contextFactory.Create(), cancellationToken); + + return () => + profile.HandleApplicationResult(pro => + new GetCurrentProfileResponse { Profile = pro }); + } } \ No newline at end of file diff --git a/src/UserProfilesInfrastructure/Notifications/UserProfileNotificationConsumer.cs b/src/UserProfilesInfrastructure/Notifications/UserProfileNotificationConsumer.cs index 767a9875..231aad91 100644 --- a/src/UserProfilesInfrastructure/Notifications/UserProfileNotificationConsumer.cs +++ b/src/UserProfilesInfrastructure/Notifications/UserProfileNotificationConsumer.cs @@ -27,6 +27,11 @@ public async Task> NotifyAsync(IDomainEvent domainEvent, Cancellat return await _userProfilesApplication.HandleEndUserRegisteredAsync(_callerContextFactory.Create(), registered, cancellationToken); + case MembershipDefaultChanged changed: + return await _userProfilesApplication.HandleEndUserDefaultOrganizationChangedAsync( + _callerContextFactory.Create(), + changed, cancellationToken); + default: return Result.Ok; } diff --git a/src/UserProfilesInfrastructure/Persistence/ReadModels/UserProfileProjection.cs b/src/UserProfilesInfrastructure/Persistence/ReadModels/UserProfileProjection.cs index 47b4eacd..614e3d93 100644 --- a/src/UserProfilesInfrastructure/Persistence/ReadModels/UserProfileProjection.cs +++ b/src/UserProfilesInfrastructure/Persistence/ReadModels/UserProfileProjection.cs @@ -81,6 +81,11 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven }, cancellationToken); + case DefaultOrganizationChanged e: + return await _users.HandleUpdateAsync(e.RootId.ToId(), + dto => { dto.DefaultOrganizationId = e.ToOrganizationId; }, + cancellationToken); + default: return false; } diff --git a/src/WebsiteHost/Api/AuthN/AuthenticationApi.cs b/src/WebsiteHost/Api/AuthN/AuthenticationApi.cs index 5e28f6f0..74ae6f0f 100644 --- a/src/WebsiteHost/Api/AuthN/AuthenticationApi.cs +++ b/src/WebsiteHost/Api/AuthN/AuthenticationApi.cs @@ -33,7 +33,7 @@ public async Task> Authe PopulateCookies(response, tokens.Value); } - return () => tokens.HandleApplicationResult(tok => + return () => tokens.HandleApplicationResult(tok => new PostResult(new AuthenticateResponse { UserId = tok.UserId })); }