Skip to content

Commit 5ae8570

Browse files
authored
[PM-34774] Add GET endpoint for organization invite links (#7534)
* Add Get method to OrganizationInviteLinksController for retrieving invite links by organization ID - Implemented a new GET endpoint to fetch an invite link based on the organization ID. - Integrated IOrganizationInviteLinkRepository to handle data retrieval. - Updated tests to validate the new functionality, ensuring correct responses for existing and non-existing links. - Refactored service registration for invite link commands to improve clarity. * Add GetOrganizationInviteLinkQuery and IGetOrganizationInviteLinkQuery interface - Implemented GetOrganizationInviteLinkQuery to retrieve invite links for organizations. - Added IGetOrganizationInviteLinkQuery interface defining the contract for fetching invite links. - Included error handling for cases where invite links are not available or do not exist. * Add unit tests for GetOrganizationInviteLinkQuery - Created GetOrganizationInviteLinkQueryTests to validate the functionality of retrieving organization invite links. - Implemented tests for successful retrieval, handling cases where no link exists, and scenarios with insufficient permissions or null abilities. - Ensured proper error handling and assertions for various outcomes in the query execution. * Add InviteLinkNotFound error type for handling missing invite links - Introduced InviteLinkNotFound record to represent a not found error for invite links. - Enhanced error handling in the InviteLinks feature to provide clearer feedback when an invite link is not found. * Add IGetOrganizationInviteLinkQuery to service collection - Registered IGetOrganizationInviteLinkQuery with the service collection to enable dependency injection for retrieving organization invite links. - This addition supports the functionality introduced in the GetOrganizationInviteLinkQuery implementation. * Refactor OrganizationInviteLinksController to use IGetOrganizationInviteLinkQuery - Updated OrganizationInviteLinksController to replace IOrganizationInviteLinkRepository with IGetOrganizationInviteLinkQuery for retrieving invite links. - Enhanced the Get method to handle results more effectively, returning appropriate responses based on the query outcome. - Modified unit tests to align with the new query implementation, ensuring proper handling of both found and not found scenarios. * Set AllowedDomains for invite link in OrganizationInviteLinksControllerTests
1 parent 16e4d4e commit 5ae8570

8 files changed

Lines changed: 239 additions & 18 deletions

File tree

src/Api/AdminConsole/Controllers/OrganizationInviteLinksController.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,20 @@ namespace Bit.Api.AdminConsole.Controllers;
1414
[Authorize("Application")]
1515
[RequireFeature(FeatureFlagKeys.GenerateInviteLink)]
1616
public class OrganizationInviteLinksController(
17-
ICreateOrganizationInviteLinkCommand createOrganizationInviteLinkCommand)
17+
ICreateOrganizationInviteLinkCommand createOrganizationInviteLinkCommand,
18+
IGetOrganizationInviteLinkQuery getOrganizationInviteLinkQuery)
1819
: BaseAdminConsoleController
1920
{
21+
[HttpGet("")]
22+
[Authorize<ManageUsersRequirement>]
23+
public async Task<IResult> Get(Guid orgId)
24+
{
25+
var result = await getOrganizationInviteLinkQuery.GetAsync(orgId);
26+
27+
return Handle(result, link =>
28+
TypedResults.Ok(new OrganizationInviteLinkResponseModel(link)));
29+
}
30+
2031
[HttpPost("")]
2132
[Authorize<ManageUsersRequirement>]
2233
public async Task<IResult> Create(Guid orgId, [FromBody] CreateOrganizationInviteLinkRequestModel model)

src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Errors.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,6 @@ public record InviteLinkDomainsRequired()
1010

1111
public record InviteLinkNotAvailable()
1212
: BadRequestError("Your organization's plan does not support invite links.");
13+
14+
public record InviteLinkNotFound()
15+
: NotFoundError("Invite link not found.");
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using Bit.Core.AdminConsole.Entities;
2+
using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces;
3+
using Bit.Core.AdminConsole.Repositories;
4+
using Bit.Core.AdminConsole.Utilities.v2.Results;
5+
using Bit.Core.Services;
6+
7+
namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks;
8+
9+
public class GetOrganizationInviteLinkQuery(
10+
IOrganizationInviteLinkRepository organizationInviteLinkRepository,
11+
IApplicationCacheService applicationCacheService)
12+
: IGetOrganizationInviteLinkQuery
13+
{
14+
public async Task<CommandResult<OrganizationInviteLink>> GetAsync(Guid organizationId)
15+
{
16+
if (!await OrganizationHasInviteLinksAbilityAsync(organizationId))
17+
{
18+
return new InviteLinkNotAvailable();
19+
}
20+
21+
var link = await organizationInviteLinkRepository.GetByOrganizationIdAsync(organizationId);
22+
if (link is null)
23+
{
24+
return new InviteLinkNotFound();
25+
}
26+
27+
return link;
28+
}
29+
30+
private async Task<bool> OrganizationHasInviteLinksAbilityAsync(Guid organizationId)
31+
{
32+
var ability = await applicationCacheService.GetOrganizationAbilityAsync(organizationId);
33+
return ability is not null && ability.UseInviteLinks;
34+
}
35+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using Bit.Core.AdminConsole.Entities;
2+
using Bit.Core.AdminConsole.Utilities.v2.Results;
3+
4+
namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces;
5+
6+
public interface IGetOrganizationInviteLinkQuery
7+
{
8+
/// <summary>
9+
/// Gets the invite link for the specified organization.
10+
/// </summary>
11+
/// <param name="organizationId">The organization to get the invite link for.</param>
12+
/// <returns>
13+
/// The <see cref="OrganizationInviteLink"/> if found and available, or an error if the invite link
14+
/// feature is not available or no invite link exists.
15+
/// </returns>
16+
Task<CommandResult<OrganizationInviteLink>> GetAsync(Guid organizationId);
17+
}

src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ public static void AddOrganizationServices(this IServiceCollection services, IGl
6767
services.AddOrganizationApiKeyCommandsQueries();
6868
services.AddOrganizationCollectionCommands();
6969
services.AddOrganizationGroupCommands();
70-
services.AddOrganizationInviteLinkCommands();
70+
services.AddOrganizationInviteLinkCommandsQueries();
7171
services.AddOrganizationDomainCommandsQueries();
7272
services.AddOrganizationSignUpCommands();
7373
services.AddOrganizationDeleteCommands();
@@ -191,9 +191,10 @@ private static void AddOrganizationGroupCommands(this IServiceCollection service
191191
services.AddScoped<IUpdateGroupCommand, UpdateGroupCommand>();
192192
}
193193

194-
private static void AddOrganizationInviteLinkCommands(this IServiceCollection services)
194+
private static void AddOrganizationInviteLinkCommandsQueries(this IServiceCollection services)
195195
{
196196
services.TryAddScoped<ICreateOrganizationInviteLinkCommand, CreateOrganizationInviteLinkCommand>();
197+
services.TryAddScoped<IGetOrganizationInviteLinkQuery, GetOrganizationInviteLinkQuery>();
197198
}
198199

199200
private static void AddOrganizationDomainCommandsQueries(this IServiceCollection services)

test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
using Bit.Api.IntegrationTest.Helpers;
66
using Bit.Core;
77
using Bit.Core.AdminConsole.Entities;
8-
using Bit.Core.AdminConsole.Repositories;
98
using Bit.Core.Billing.Enums;
109
using Bit.Core.Enums;
1110
using Bit.Core.Models.Data.Organizations;
@@ -68,30 +67,37 @@ public Task DisposeAsync()
6867
}
6968

7069
[Fact]
71-
public async Task Create_AsOwner_ReturnsCreated()
70+
public async Task CreateThenGet_AsOwner_ReturnsCreatedAndOk()
7271
{
7372
var request = new CreateOrganizationInviteLinkRequestModel
7473
{
7574
AllowedDomains = ["acme.com", "example.com"],
7675
EncryptedInviteKey = _validEncryptedKey,
7776
};
7877

79-
var response = await _client.PostAsJsonAsync(
78+
static void AssertInviteLink(OrganizationInviteLinkResponseModel? content, Organization organization)
79+
{
80+
Assert.NotNull(content);
81+
Assert.NotEqual(Guid.Empty, content.Id);
82+
Assert.NotEqual(Guid.Empty, content.Code);
83+
Assert.Equal(organization.Id, content.OrganizationId);
84+
Assert.Equal(["acme.com", "example.com"], content.AllowedDomains);
85+
Assert.Equal(_validEncryptedKey, content.EncryptedInviteKey);
86+
}
87+
88+
var createResponse = await _client.PostAsJsonAsync(
8089
$"/organizations/{_organization.Id}/invite-link", request);
8190

82-
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
91+
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
92+
93+
var created = await createResponse.Content.ReadFromJsonAsync<OrganizationInviteLinkResponseModel>();
94+
AssertInviteLink(created, _organization);
95+
96+
var getResponse = await _client.GetAsync($"/organizations/{_organization.Id}/invite-link");
8397

84-
var content = await response.Content.ReadFromJsonAsync<OrganizationInviteLinkResponseModel>();
85-
Assert.NotNull(content);
86-
Assert.NotEqual(Guid.Empty, content.Id);
87-
Assert.NotEqual(Guid.Empty, content.Code);
88-
Assert.Equal(_organization.Id, content.OrganizationId);
89-
Assert.Equal(["acme.com", "example.com"], content.AllowedDomains);
90-
Assert.Equal(_validEncryptedKey, content.EncryptedInviteKey);
98+
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
9199

92-
var repository = _factory.GetService<IOrganizationInviteLinkRepository>();
93-
var persisted = await repository.GetByOrganizationIdAsync(_organization.Id);
94-
Assert.NotNull(persisted);
95-
Assert.Equal(content.Id, persisted.Id);
100+
var content = await getResponse.Content.ReadFromJsonAsync<OrganizationInviteLinkResponseModel>();
101+
AssertInviteLink(content, _organization);
96102
}
97103
}

test/Api.Test/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,57 @@ public async Task Create_WithExistingLink_Returns409(
7474
Assert.Equal(StatusCodes.Status409Conflict, jsonResult.StatusCode);
7575
}
7676

77+
[Theory, BitAutoData]
78+
public async Task Get_WhenLinkExists_ReturnsOkWithModel(
79+
Guid orgId,
80+
OrganizationInviteLink inviteLink,
81+
SutProvider<OrganizationInviteLinksController> sutProvider)
82+
{
83+
inviteLink.OrganizationId = orgId;
84+
inviteLink.AllowedDomains = "[\"acme.com\"]";
85+
86+
sutProvider.GetDependency<IGetOrganizationInviteLinkQuery>()
87+
.GetAsync(orgId)
88+
.Returns(new CommandResult<OrganizationInviteLink>(inviteLink));
89+
90+
var result = await sutProvider.Sut.Get(orgId);
91+
92+
var okResult = Assert.IsType<Ok<OrganizationInviteLinkResponseModel>>(result);
93+
Assert.NotNull(okResult.Value);
94+
Assert.Equal(inviteLink.Id, okResult.Value.Id);
95+
Assert.Equal(orgId, okResult.Value.OrganizationId);
96+
}
97+
98+
[Theory, BitAutoData]
99+
public async Task Get_WhenNoLinkExists_ReturnsNotFound(
100+
Guid orgId,
101+
SutProvider<OrganizationInviteLinksController> sutProvider)
102+
{
103+
sutProvider.GetDependency<IGetOrganizationInviteLinkQuery>()
104+
.GetAsync(orgId)
105+
.Returns(new CommandResult<OrganizationInviteLink>(new InviteLinkNotFound()));
106+
107+
var result = await sutProvider.Sut.Get(orgId);
108+
109+
var notFoundResult = Assert.IsType<NotFound<Bit.Core.Models.Api.ErrorResponseModel>>(result);
110+
Assert.NotNull(notFoundResult.Value);
111+
}
112+
113+
[Theory, BitAutoData]
114+
public async Task Get_WhenInviteLinkNotAvailable_Returns400(
115+
Guid orgId,
116+
SutProvider<OrganizationInviteLinksController> sutProvider)
117+
{
118+
sutProvider.GetDependency<IGetOrganizationInviteLinkQuery>()
119+
.GetAsync(orgId)
120+
.Returns(new CommandResult<OrganizationInviteLink>(new InviteLinkNotAvailable()));
121+
122+
var result = await sutProvider.Sut.Get(orgId);
123+
124+
var badRequestResult = Assert.IsType<BadRequest<Bit.Core.Models.Api.ErrorResponseModel>>(result);
125+
Assert.NotNull(badRequestResult.Value);
126+
}
127+
77128
[Theory, BitAutoData]
78129
public async Task Create_WithValidationError_Returns400(
79130
Guid orgId,
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
using Bit.Core.AdminConsole.Entities;
2+
using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks;
3+
using Bit.Core.AdminConsole.Repositories;
4+
using Bit.Core.Models.Data.Organizations;
5+
using Bit.Core.Services;
6+
using Bit.Test.Common.AutoFixture;
7+
using Bit.Test.Common.AutoFixture.Attributes;
8+
using NSubstitute;
9+
using Xunit;
10+
11+
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.InviteLinks;
12+
13+
[SutProviderCustomize]
14+
public class GetOrganizationInviteLinkQueryTests
15+
{
16+
[Theory, BitAutoData]
17+
public async Task GetAsync_WithValidInput_Success(
18+
Guid organizationId,
19+
OrganizationInviteLink link,
20+
SutProvider<GetOrganizationInviteLinkQuery> sutProvider)
21+
{
22+
SetupAbility(sutProvider, organizationId);
23+
link.OrganizationId = organizationId;
24+
25+
sutProvider.GetDependency<IOrganizationInviteLinkRepository>()
26+
.GetByOrganizationIdAsync(organizationId)
27+
.Returns(link);
28+
29+
var result = await sutProvider.Sut.GetAsync(organizationId);
30+
31+
Assert.True(result.IsSuccess);
32+
Assert.Same(link, result.AsSuccess);
33+
}
34+
35+
[Theory, BitAutoData]
36+
public async Task GetAsync_WhenNoLinkExists_ReturnsNotFoundError(
37+
Guid organizationId,
38+
SutProvider<GetOrganizationInviteLinkQuery> sutProvider)
39+
{
40+
SetupAbility(sutProvider, organizationId);
41+
42+
sutProvider.GetDependency<IOrganizationInviteLinkRepository>()
43+
.GetByOrganizationIdAsync(organizationId)
44+
.Returns((OrganizationInviteLink?)null);
45+
46+
var result = await sutProvider.Sut.GetAsync(organizationId);
47+
48+
Assert.True(result.IsError);
49+
Assert.IsType<InviteLinkNotFound>(result.AsError);
50+
}
51+
52+
[Theory, BitAutoData]
53+
public async Task GetAsync_WithoutUseInviteLinksAbility_ReturnsBadRequestError(
54+
Guid organizationId,
55+
SutProvider<GetOrganizationInviteLinkQuery> sutProvider)
56+
{
57+
SetupAbility(sutProvider, organizationId, useInviteLinks: false);
58+
59+
var result = await sutProvider.Sut.GetAsync(organizationId);
60+
61+
Assert.True(result.IsError);
62+
Assert.IsType<InviteLinkNotAvailable>(result.AsError);
63+
64+
await sutProvider.GetDependency<IOrganizationInviteLinkRepository>()
65+
.DidNotReceiveWithAnyArgs()
66+
.GetByOrganizationIdAsync(default);
67+
}
68+
69+
[Theory, BitAutoData]
70+
public async Task GetAsync_WithNullAbility_ReturnsBadRequestError(
71+
Guid organizationId,
72+
SutProvider<GetOrganizationInviteLinkQuery> sutProvider)
73+
{
74+
sutProvider.GetDependency<IApplicationCacheService>()
75+
.GetOrganizationAbilityAsync(organizationId)
76+
.Returns((OrganizationAbility?)null);
77+
78+
var result = await sutProvider.Sut.GetAsync(organizationId);
79+
80+
Assert.True(result.IsError);
81+
Assert.IsType<InviteLinkNotAvailable>(result.AsError);
82+
83+
await sutProvider.GetDependency<IOrganizationInviteLinkRepository>()
84+
.DidNotReceiveWithAnyArgs()
85+
.GetByOrganizationIdAsync(default);
86+
}
87+
88+
private static void SetupAbility(
89+
SutProvider<GetOrganizationInviteLinkQuery> sutProvider,
90+
Guid organizationId,
91+
bool useInviteLinks = true)
92+
{
93+
sutProvider.GetDependency<IApplicationCacheService>()
94+
.GetOrganizationAbilityAsync(organizationId)
95+
.Returns(new OrganizationAbility { UseInviteLinks = useInviteLinks });
96+
}
97+
}

0 commit comments

Comments
 (0)