Skip to content
Merged
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
3 changes: 3 additions & 0 deletions src/Orbit.Api/Controllers/AccountabilityController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
[Route("api/accountability")]
public partial class AccountabilityController(IMediator mediator, ILogger<AccountabilityController> logger) : ControllerBase
{
public record InviteAccountabilityBuddyBody(Guid BuddyUserId, AccountabilityCadence Cadence, IReadOnlyList<Guid> HabitIds);

Check warning on line 17 in src/Orbit.Api/Controllers/AccountabilityController.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Value type property used as input in a controller action should be nullable, required or annotated with the JsonRequiredAttribute to avoid under-posting.

Check warning on line 17 in src/Orbit.Api/Controllers/AccountabilityController.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Value type property used as input in a controller action should be nullable, required or annotated with the JsonRequiredAttribute to avoid under-posting.
public record AcceptAccountabilityPairBody(IReadOnlyList<Guid> HabitIds);
public record SetAccountabilityHabitsBody(IReadOnlyList<Guid> HabitIds);
public record CheckInAccountabilityBody(string? Note);
Expand Down Expand Up @@ -50,6 +50,7 @@
}

[HttpPost("pairs/{pairId:guid}/accept")]
[DistributedRateLimit("accountability-mutations")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
Expand All @@ -70,6 +71,7 @@
}

[HttpDelete("pairs/{pairId:guid}")]
[DistributedRateLimit("accountability-mutations")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
Expand All @@ -85,6 +87,7 @@
}

[HttpPut("pairs/{pairId:guid}/habits")]
[DistributedRateLimit("accountability-mutations")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
Expand Down
2 changes: 2 additions & 0 deletions src/Orbit.Api/Controllers/AchievementsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Orbit.Api.Extensions;
using Orbit.Api.RateLimiting;
using Orbit.Application.Gamification.Commands;

namespace Orbit.Api.Controllers;
Expand All @@ -14,6 +15,7 @@ public class AchievementsController(IMediator mediator) : ControllerBase
public record ReportEventBody(string EventKey);

[HttpPost("report-event")]
[DistributedRateLimit("achievements")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
Expand Down
2 changes: 2 additions & 0 deletions src/Orbit.Api/Controllers/AiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ public record VerifyStepUpRequest([property: JsonRequired] Guid ChallengeId, str
public record ExecutePendingOperationRequest(string ConfirmationToken);

[HttpPost("pending-operations/{id:guid}/confirm")]
[DistributedRateLimit("ai-operations")]
public async Task<IActionResult> ConfirmPendingOperation(Guid id, CancellationToken cancellationToken)
{
if (HttpContext.User.GetAgentAuthMethod() == AgentAuthMethod.ApiKey)
Expand Down Expand Up @@ -204,6 +205,7 @@ await auditService.RecordAsync(new AgentAuditEntry(
}

[HttpPost("pending-operations/{id:guid}/execute")]
[DistributedRateLimit("ai-operations")]
public async Task<IActionResult> ExecutePendingOperation(
Guid id,
[FromBody] ExecutePendingOperationRequest request,
Expand Down
2 changes: 2 additions & 0 deletions src/Orbit.Api/Controllers/FriendsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ public async Task<IActionResult> SendRequest(
}

[HttpPost("requests/{friendshipId:guid}/accept")]
[DistributedRateLimit("friend-mutations")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
Expand All @@ -114,6 +115,7 @@ public async Task<IActionResult> AcceptRequest(Guid friendshipId, CancellationTo
}

[HttpDelete("{friendUserId:guid}")]
[DistributedRateLimit("friend-mutations")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> RemoveFriend(Guid friendUserId, CancellationToken cancellationToken)
Expand Down
5 changes: 5 additions & 0 deletions src/Orbit.Api/Controllers/TagsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public async Task<IActionResult> GetTags(CancellationToken cancellationToken)
}

[HttpPost]
[DistributedRateLimit("tags")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
Expand All @@ -51,6 +52,7 @@ public async Task<IActionResult> CreateTag(
}

[HttpPut("{id:guid}")]
[DistributedRateLimit("tags")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
Expand All @@ -71,6 +73,7 @@ public async Task<IActionResult> UpdateTag(
}

[HttpDelete("{id:guid}")]
[DistributedRateLimit("tags")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
Expand All @@ -88,6 +91,7 @@ public async Task<IActionResult> DeleteTag(Guid id, CancellationToken cancellati
}

[HttpPost("{id:guid}/restore")]
[DistributedRateLimit("tags")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
Expand All @@ -104,6 +108,7 @@ public async Task<IActionResult> RestoreTag(Guid id, CancellationToken cancellat
}

[HttpPut("{habitId:guid}/assign")]
[DistributedRateLimit("tags")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,20 @@ public class DistributedRateLimitService(OrbitDbContext dbContext, TimeProvider
["waitlist"] = new(TimeSpan.FromMinutes(10), PermitLimit: 5, SegmentCount: 1),
["chat"] = new(TimeSpan.FromMinutes(1), PermitLimit: 20, SegmentCount: 4),
["ai-resolve"] = new(TimeSpan.FromMinutes(1), PermitLimit: 30, SegmentCount: 4),
["ai-operations"] = new(TimeSpan.FromMinutes(1), PermitLimit: 15, SegmentCount: 4),
["habit-suggest"] = new(TimeSpan.FromMinutes(1), PermitLimit: 15, SegmentCount: 4),
["support"] = new(TimeSpan.FromHours(1), PermitLimit: 3, SegmentCount: 1),
["uploads"] = new(TimeSpan.FromMinutes(1), PermitLimit: 30, SegmentCount: 4),
["tag-suggest"] = new(TimeSpan.FromMinutes(1), PermitLimit: 15, SegmentCount: 4),
["tags"] = new(TimeSpan.FromMinutes(1), PermitLimit: 60, SegmentCount: 4),
["achievements"] = new(TimeSpan.FromMinutes(1), PermitLimit: 30, SegmentCount: 4),
["cheers"] = new(TimeSpan.FromHours(24), PermitLimit: 20, SegmentCount: 1),
["friend-requests"] = new(TimeSpan.FromHours(24), PermitLimit: 30, SegmentCount: 1),
["friend-mutations"] = new(TimeSpan.FromHours(24), PermitLimit: 50, SegmentCount: 1),
["invite-preview"] = new(TimeSpan.FromMinutes(1), PermitLimit: 15, SegmentCount: 4),
["accountability-invites"] = new(TimeSpan.FromHours(24), PermitLimit: 30, SegmentCount: 1),
["accountability-checkins"] = new(TimeSpan.FromHours(24), PermitLimit: 30, SegmentCount: 1),
["accountability-mutations"] = new(TimeSpan.FromHours(24), PermitLimit: 50, SegmentCount: 1),
["reports"] = new(TimeSpan.FromHours(24), PermitLimit: 20, SegmentCount: 1),
["set-handle"] = new(TimeSpan.FromHours(24), PermitLimit: 5, SegmentCount: 1),
["block"] = new(TimeSpan.FromHours(24), PermitLimit: 50, SegmentCount: 1),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Reflection;
using FluentAssertions;
using Orbit.Api.Controllers;
using Orbit.Api.RateLimiting;

namespace Orbit.Infrastructure.Tests.Controllers;

public class AccountabilityControllerRateLimitTests
{
[Theory]
[InlineData(nameof(AccountabilityController.Invite))]
[InlineData(nameof(AccountabilityController.Accept))]
[InlineData(nameof(AccountabilityController.End))]
[InlineData(nameof(AccountabilityController.SetHabits))]
[InlineData(nameof(AccountabilityController.CheckIn))]
public void MutatingActions_AreRateLimited(string actionName)
{
var rateLimitAttributes = GetAction(actionName)
.GetCustomAttributes<DistributedRateLimitAttribute>(inherit: false)
.ToList();

rateLimitAttributes.Should().HaveCount(1);
}

[Theory]
[InlineData(nameof(AccountabilityController.GetPairs))]
[InlineData(nameof(AccountabilityController.GetCheckIns))]
public void ReadActions_AreNotRateLimited(string actionName)
{
var rateLimitAttributes = GetAction(actionName)
.GetCustomAttributes<DistributedRateLimitAttribute>(inherit: false)
.ToList();

rateLimitAttributes.Should().BeEmpty();
}

private static MethodInfo GetAction(string actionName)
{
var method = typeof(AccountabilityController).GetMethod(actionName, BindingFlags.Public | BindingFlags.Instance);
method.Should().NotBeNull($"AccountabilityController should expose a public action named '{actionName}'");
return method!;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System.Reflection;
using FluentAssertions;
using Orbit.Api.Controllers;
using Orbit.Api.RateLimiting;

namespace Orbit.Infrastructure.Tests.Controllers;

public class AchievementsControllerRateLimitTests
{
[Fact]
public void ReportEvent_IsRateLimited()
{
var rateLimitAttributes = GetAction(nameof(AchievementsController.ReportEvent))
.GetCustomAttributes<DistributedRateLimitAttribute>(inherit: false)
.ToList();

rateLimitAttributes.Should().HaveCount(1);
}

private static MethodInfo GetAction(string actionName)
{
var method = typeof(AchievementsController).GetMethod(actionName, BindingFlags.Public | BindingFlags.Instance);
method.Should().NotBeNull($"AchievementsController should expose a public action named '{actionName}'");
return method!;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Reflection;
using FluentAssertions;
using Orbit.Api.Controllers;
using Orbit.Api.RateLimiting;

namespace Orbit.Infrastructure.Tests.Controllers;

public class AiControllerRateLimitTests
{
[Theory]
[InlineData(nameof(AiController.ConfirmPendingOperation))]
[InlineData(nameof(AiController.ExecutePendingOperation))]
public void PendingOperationActions_AreRateLimited(string actionName)
{
var rateLimitAttributes = GetAction(actionName)
.GetCustomAttributes<DistributedRateLimitAttribute>(inherit: false)
.ToList();

rateLimitAttributes.Should().HaveCount(1);
}

private static MethodInfo GetAction(string actionName)
{
var method = typeof(AiController).GetMethod(actionName, BindingFlags.Public | BindingFlags.Instance);
method.Should().NotBeNull($"AiController should expose a public action named '{actionName}'");
return method!;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ public class FriendsControllerRateLimitTests
[InlineData(nameof(FriendsController.Report))]
[InlineData(nameof(FriendsController.Block))]
[InlineData(nameof(FriendsController.Unblock))]
[InlineData(nameof(FriendsController.AcceptRequest))]
[InlineData(nameof(FriendsController.RemoveFriend))]
public void AbuseProneActions_AreRateLimited(string actionName)
{
var rateLimitAttributes = GetAction(actionName)
Expand All @@ -23,9 +25,8 @@ public void AbuseProneActions_AreRateLimited(string actionName)
}

[Theory]
[InlineData(nameof(FriendsController.AcceptRequest))]
[InlineData(nameof(FriendsController.RemoveFriend))]
[InlineData(nameof(FriendsController.GetFeed))]
[InlineData(nameof(FriendsController.GetFriends))]
public void NonMutatingOrLowRiskActions_AreNotRateLimited(string actionName)
{
var rateLimitAttributes = GetAction(actionName)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System.Reflection;
using FluentAssertions;
using Orbit.Api.Controllers;
using Orbit.Api.RateLimiting;

namespace Orbit.Infrastructure.Tests.Controllers;

public class TagsControllerRateLimitTests
{
[Theory]
[InlineData(nameof(TagsController.CreateTag))]
[InlineData(nameof(TagsController.UpdateTag))]
[InlineData(nameof(TagsController.DeleteTag))]
[InlineData(nameof(TagsController.RestoreTag))]
[InlineData(nameof(TagsController.AssignTags))]
[InlineData(nameof(TagsController.SuggestTags))]
public void MutatingActions_AreRateLimited(string actionName)
{
var rateLimitAttributes = GetAction(actionName)
.GetCustomAttributes<DistributedRateLimitAttribute>(inherit: false)
.ToList();

rateLimitAttributes.Should().HaveCount(1);
}

[Fact]
public void GetTags_IsNotRateLimited()
{
var rateLimitAttributes = GetAction(nameof(TagsController.GetTags))
.GetCustomAttributes<DistributedRateLimitAttribute>(inherit: false)
.ToList();

rateLimitAttributes.Should().BeEmpty();
}

private static MethodInfo GetAction(string actionName)
{
var method = typeof(TagsController).GetMethod(actionName, BindingFlags.Public | BindingFlags.Instance);
method.Should().NotBeNull($"TagsController should expose a public action named '{actionName}'");
return method!;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,38 @@ public async Task TryAcquireAsync_ModerationPolicy_BlocksAfterPermitLimit(string
finalDecision.CurrentCount.Should().Be(50);
}

[Theory]
[InlineData("tags", 60)]
[InlineData("achievements", 30)]
[InlineData("ai-operations", 15)]
[InlineData("friend-mutations", 50)]
[InlineData("accountability-mutations", 50)]
public async Task TryAcquireAsync_StateChangingSweepPolicy_BlocksAfterPermitLimit(string policyName, int permitLimit)
{
DistributedRateLimitDecision finalDecision = new(true, 0, 0, DateTime.UtcNow);

for (var attempt = 0; attempt <= permitLimit; attempt++)
finalDecision = await _service.TryAcquireAsync(policyName, "user:sweep");

finalDecision.Allowed.Should().BeFalse();
finalDecision.PermitLimit.Should().Be(permitLimit);
finalDecision.CurrentCount.Should().Be(permitLimit);
}

[Fact]
public async Task TryAcquireAsync_TagsPolicy_PartitionsPerUser()
{
for (var attempt = 0; attempt < 60; attempt++)
(await _service.TryAcquireAsync("tags", "user:one")).Allowed.Should().BeTrue();

var exhausted = await _service.TryAcquireAsync("tags", "user:one");
var otherUser = await _service.TryAcquireAsync("tags", "user:two");

exhausted.Allowed.Should().BeFalse();
otherUser.Allowed.Should().BeTrue();
otherUser.CurrentCount.Should().Be(1);
}

[Fact]
public async Task TryAcquireAsync_RelationalProvider_RetriesSerializationConflictThenSucceeds()
{
Expand Down
Loading