Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,6 @@ public async Task<ActionResult<List<OrganizationIntegrationResponseModel>>> GetA
[HttpPost("")]
public async Task<ActionResult<OrganizationIntegrationResponseModel>> CreateAsync(Guid organizationId, [FromBody] OrganizationIntegrationRequestModel model)
{
if (!ModelState.IsValid)

@lastbestdev lastbestdev Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check is removed because it is redundant. .NET web API controller actions automatically run model validation logic when it is implemented (i.e. model implements IValidatableObject)

Therefore, the UpdateAsync method is actually running the validation logic as we expect, as well.

{
return BadRequest(ModelState);
}

if (!await HasPermission(organizationId))
{
return NotFound();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ public OrganizationIntegration ToOrganizationIntegration(OrganizationIntegration
return currentIntegration;
}

/// <summary>
/// Validates the request model based on the integration type. Runs automatically when the model is bound to a controller action parameter.
/// </summary>
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
switch (Type)
Expand All @@ -36,7 +39,7 @@ public IEnumerable<ValidationResult> Validate(ValidationContext validationContex
yield return new ValidationResult($"{nameof(Type)} integrations are not yet supported.", [nameof(Type)]);
break;
case IntegrationType.Slack or IntegrationType.Teams:
yield return new ValidationResult($"{nameof(Type)} integrations cannot be created directly.", [nameof(Type)]);
yield return new ValidationResult($"{nameof(Type)} integrations cannot be created or updated directly.", [nameof(Type)]);
break;
case IntegrationType.Webhook:
foreach (var r in ValidateConfiguration<WebhookIntegration>(allowNullOrEmpty: true))
Expand Down
2 changes: 1 addition & 1 deletion src/Core/Dirt/Services/Implementations/TeamsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ public async Task<IReadOnlyList<TeamInfo>> GetJoinedTeamsAsync(string accessToke
public async Task SendMessageToChannelAsync(Uri serviceUri, string channelId, string message)
{
var credentials = new MicrosoftAppCredentials(_clientId, _clientSecret);
using var connectorClient = new ConnectorClient(serviceUri, credentials);
using var connectorClient = new ConnectorClient(serviceUri, credentials, _httpClient, disposeHttpClient: false);

var activity = new Activity
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ public void Validate_Slack_ReturnsCannotBeCreatedDirectlyError()

Assert.Single(results);
Assert.Contains(nameof(model.Type), results[0].MemberNames);
Assert.Contains("cannot be created directly", results[0].ErrorMessage);
Assert.Contains("cannot be created or updated directly", results[0].ErrorMessage);
}

[Fact]
Expand All @@ -103,7 +103,7 @@ public void Validate_Teams_ReturnsCannotBeCreatedDirectlyError()

Assert.Single(results);
Assert.Contains(nameof(model.Type), results[0].MemberNames);
Assert.Contains("cannot be created directly", results[0].ErrorMessage);
Assert.Contains("cannot be created or updated directly", results[0].ErrorMessage);
}

[Fact]
Expand Down
55 changes: 55 additions & 0 deletions test/Core.Test/Dirt/Services/TeamsServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.MockedHttpClient;
using Microsoft.Bot.Schema;
using NSubstitute;
using Xunit;
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
Expand Down Expand Up @@ -179,6 +180,60 @@ public async Task GetJoinedTeamsAsync_ServerErrorCode_ReturnsEmptyList()
Assert.Empty(result);
}

[Fact]
public async Task SendMessageToChannelAsync_SendsViaInjectedHttpClient()
{
var sutProvider = GetSutProvider();
var serviceUri = new Uri("https://smba.example.com/amer/");
var channelId = "19:channel-id@thread.tacv2";

var matcher = _handler
.When(_ => true)
.RespondWith(HttpStatusCode.OK)
.WithContent("application/json", JsonSerializer.Serialize(new { id = "activity-id" }));

await sutProvider.Sut.SendMessageToChannelAsync(serviceUri, channelId, "test message");

// The ConnectorClient only reaches this handler if it was constructed with the injected
// (SSRF-protected) HttpClient rather than creating its own.
Assert.Single(_handler.CapturedRequests);
}

[Fact]
public async Task SendMessageToChannelAsync_CalledMultipleTimes_DoesNotDisposeInjectedHttpClient()
{
var sutProvider = GetSutProvider();
var serviceUri = new Uri("https://smba.example.com/amer/");

_handler
.When(_ => true)
.RespondWith(HttpStatusCode.OK)
.WithContent("application/json", JsonSerializer.Serialize(new { id = "activity-id" }));

await sutProvider.Sut.SendMessageToChannelAsync(serviceUri, "channel-id", "first message");
await sutProvider.Sut.SendMessageToChannelAsync(serviceUri, "channel-id", "second message");

// Disposing the ConnectorClient must not dispose the shared, factory-provided HttpClient.
Assert.Equal(2, _handler.CapturedRequests.Count);
}

[Fact]
public async Task SendMessageToChannelAsync_ServerErrorCode_Throws()
{
var sutProvider = GetSutProvider();
var serviceUri = new Uri("https://smba.example.com/amer/");

_handler
.When(_ => true)
.RespondWith(HttpStatusCode.Forbidden)
.WithContent("application/json", JsonSerializer.Serialize(new { error = new { code = "Forbidden" } }));

await Assert.ThrowsAsync<ErrorResponseException>(() =>
sutProvider.Sut.SendMessageToChannelAsync(serviceUri, "channel-id", "test message"));

Assert.Single(_handler.CapturedRequests);
}

[Theory, BitAutoData]
public async Task HandleIncomingAppInstall_Success_UpdatesTeamsIntegration(
OrganizationIntegration integration)
Expand Down
Loading