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
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
using System.Linq.Expressions;
using System.Security.Claims;
using System.Text.Encodings.Web;
using FluentAssertions;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
Expand All @@ -17,15 +17,26 @@ namespace Orbit.Infrastructure.Tests.Authentication;

public class ApiKeyAuthenticationHandlerTests
{
private static readonly Guid TestUserId = Guid.NewGuid();

private static async Task<AuthenticateResult> RunHandler(string? authorizationHeader)
private sealed record HandlerRun(
AuthenticateResult Result,
IGenericRepository<ApiKey> ApiKeyRepo,
IUnitOfWork UnitOfWork);

private static async Task<HandlerRun> RunHandler(
string? authorizationHeader,
string path = "/mcp",
IReadOnlyList<ApiKey>? candidates = null,
Result? payGateResult = null)
{
var apiKeyRepo = Substitute.For<IGenericRepository<ApiKey>>();
var payGate = Substitute.For<IPayGateService>();
var unitOfWork = Substitute.For<IUnitOfWork>();

payGate.CanReadApiKeys(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Result.Success()));
.Returns(Task.FromResult(payGateResult ?? Result.Success()));
apiKeyRepo.FindTrackedAsync(
Arg.Any<Expression<Func<ApiKey, bool>>>(), Arg.Any<CancellationToken>())
.Returns(candidates ?? []);

var services = new ServiceCollection();
services.AddSingleton(apiKeyRepo);
Expand All @@ -36,92 +47,136 @@ private static async Task<AuthenticateResult> RunHandler(string? authorizationHe
var optionsMonitor = Substitute.For<IOptionsMonitor<AuthenticationSchemeOptions>>();
optionsMonitor.Get(Arg.Any<string>()).Returns(new AuthenticationSchemeOptions());

var loggerFactory = new NullLoggerFactory();
var encoder = UrlEncoder.Default;

var handler = new ApiKeyAuthenticationHandler(optionsMonitor, loggerFactory, encoder, serviceProvider);
var handler = new ApiKeyAuthenticationHandler(
optionsMonitor, new NullLoggerFactory(), UrlEncoder.Default, serviceProvider);

var scheme = new AuthenticationScheme("ApiKey", "ApiKey", typeof(ApiKeyAuthenticationHandler));
var httpContext = new DefaultHttpContext();
httpContext.Request.Path = "/mcp";

httpContext.Request.Path = path;
if (authorizationHeader is not null)
httpContext.Request.Headers.Authorization = authorizationHeader;

await handler.InitializeAsync(scheme, httpContext);
return await handler.AuthenticateAsync();
var result = await handler.AuthenticateAsync();
return new HandlerRun(result, apiKeyRepo, unitOfWork);
}

private static Expression<Func<ApiKey, bool>> CapturedPredicate(IGenericRepository<ApiKey> repo) =>
(Expression<Func<ApiKey, bool>>)repo.ReceivedCalls()
.Single(call => call.GetMethodInfo().Name == "FindTrackedAsync")
.GetArguments()[0]!;

[Fact]
public async Task HandleAuthenticateAsync_MissingHeader_ReturnsFail()
{
var result = await RunHandler(null);
var run = await RunHandler(null);

result.Succeeded.Should().BeFalse();
result.Failure!.Message.Should().Contain("Not an API key");
run.Result.Succeeded.Should().BeFalse();
run.Result.Failure!.Message.Should().Contain("Not an API key");
}

[Fact]
public async Task HandleAuthenticateAsync_EmptyHeader_ReturnsFail()
{
var result = await RunHandler("");
var run = await RunHandler("");

result.Succeeded.Should().BeFalse();
run.Result.Succeeded.Should().BeFalse();
}

[Fact]
public async Task HandleAuthenticateAsync_NonApiKeyBearer_ReturnsFail()
{
var result = await RunHandler("Bearer eyJhbGciOiJIUzI1NiJ9.test");
var run = await RunHandler("Bearer eyJhbGciOiJIUzI1NiJ9.test");

result.Succeeded.Should().BeFalse();
result.Failure!.Message.Should().Contain("Not an API key");
run.Result.Succeeded.Should().BeFalse();
run.Result.Failure!.Message.Should().Contain("Not an API key");
}

[Fact]
public async Task HandleAuthenticateAsync_ApiKeyOnNonAgentPath_ReturnsFail()
{
var run = await RunHandler($"Bearer orb_{new string('a', 20)}", path: "/api/habits");

run.Result.Succeeded.Should().BeFalse();
run.Result.Failure!.Message.Should().Contain("agent endpoints");
}

[Fact]
public async Task HandleAuthenticateAsync_TooShortApiKey_ReturnsFail()
{
var result = await RunHandler("Bearer orb_short");
var run = await RunHandler("Bearer orb_short");

result.Succeeded.Should().BeFalse();
result.Failure!.Message.Should().Contain("Invalid API key format");
run.Result.Succeeded.Should().BeFalse();
run.Result.Failure!.Message.Should().Contain("Invalid API key format");
}

[Fact]
public async Task HandleAuthenticateAsync_ValidFormatButNoMatch_ReturnsFail()
{
var apiKeyRepo = Substitute.For<IGenericRepository<ApiKey>>();
var payGate = Substitute.For<IPayGateService>();
var unitOfWork = Substitute.For<IUnitOfWork>();
payGate.CanReadApiKeys(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Result.Success()));
var run = await RunHandler("Bearer orb_testkey12345678", candidates: []);

apiKeyRepo.FindTrackedAsync(
Arg.Any<System.Linq.Expressions.Expression<Func<ApiKey, bool>>>(),
Arg.Any<CancellationToken>())
.Returns(new List<ApiKey>());
run.Result.Succeeded.Should().BeFalse();
run.Result.Failure!.Message.Should().Contain("Invalid API key");
}

var services = new ServiceCollection();
services.AddSingleton(apiKeyRepo);
services.AddSingleton(payGate);
services.AddSingleton(unitOfWork);
var serviceProvider = services.BuildServiceProvider();
[Fact]
public async Task HandleAuthenticateAsync_ValidKey_SucceedsWithIdentityClaims()
{
var userId = Guid.NewGuid();
var (apiKey, rawKey) = ApiKey.Create(
userId, "Agent Key", ["habits:read", "goals:read"], isReadOnly: true).Value;

var run = await RunHandler($"Bearer {rawKey}", candidates: [apiKey]);

run.Result.Succeeded.Should().BeTrue();
var principal = run.Result.Principal!;
principal.FindFirst(ClaimTypes.NameIdentifier)!.Value.Should().Be(userId.ToString());
principal.FindFirst("auth_method")!.Value.Should().Be("api_key");
principal.FindFirst("api_key_id")!.Value.Should().Be(apiKey.Id.ToString());
principal.FindFirst("api_key_read_only")!.Value.Should().Be("True");
principal.FindAll("scope").Select(claim => claim.Value)
.Should().BeEquivalentTo("habits:read", "goals:read");
apiKey.LastUsedAtUtc.Should().NotBeNull();
await run.UnitOfWork.Received(1).SaveChangesAsync();
}

var optionsMonitor = Substitute.For<IOptionsMonitor<AuthenticationSchemeOptions>>();
optionsMonitor.Get(Arg.Any<string>()).Returns(new AuthenticationSchemeOptions());
[Fact]
public async Task HandleAuthenticateAsync_ExpiredKey_ReturnsFail()
{
var (apiKey, rawKey) = ApiKey.Create(Guid.NewGuid(), "Agent Key").Value;
typeof(ApiKey).GetProperty(nameof(ApiKey.ExpiresAtUtc))!
.SetValue(apiKey, DateTime.UtcNow.AddDays(-1));

var handler = new ApiKeyAuthenticationHandler(
optionsMonitor, new NullLoggerFactory(), UrlEncoder.Default, serviceProvider);
var run = await RunHandler($"Bearer {rawKey}", candidates: [apiKey]);

var scheme = new AuthenticationScheme("ApiKey", "ApiKey", typeof(ApiKeyAuthenticationHandler));
var httpContext = new DefaultHttpContext();
httpContext.Request.Path = "/mcp";
httpContext.Request.Headers.Authorization = "Bearer orb_testkey12345678";
run.Result.Succeeded.Should().BeFalse();
run.Result.Failure!.Message.Should().Contain("expired");
await run.UnitOfWork.DidNotReceive().SaveChangesAsync();
}

await handler.InitializeAsync(scheme, httpContext);
var result = await handler.AuthenticateAsync();
[Fact]
public async Task HandleAuthenticateAsync_PayGateDenied_ReturnsFail()
{
var (apiKey, rawKey) = ApiKey.Create(Guid.NewGuid(), "Agent Key").Value;

var run = await RunHandler(
$"Bearer {rawKey}", candidates: [apiKey], payGateResult: Result.Failure("no plan"));

run.Result.Succeeded.Should().BeFalse();
run.Result.Failure!.Message.Should().Contain("not available for this plan");
}

[Fact]
public async Task HandleAuthenticateAsync_QueryPredicateExcludesRevokedKeys()
{
var (apiKey, rawKey) = ApiKey.Create(Guid.NewGuid(), "Agent Key").Value;

var run = await RunHandler($"Bearer {rawKey}", candidates: []);

var predicate = CapturedPredicate(run.ApiKeyRepo).Compile();
predicate(apiKey).Should().BeTrue();

result.Succeeded.Should().BeFalse();
result.Failure!.Message.Should().Contain("Invalid API key");
apiKey.Revoke();
predicate(apiKey).Should().BeFalse();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Orbit.Domain.Entities;
using Orbit.Infrastructure.Persistence;
using Orbit.Infrastructure.Services;

namespace Orbit.Infrastructure.Tests.Services;

/// <summary>
/// Verifies the retention boundaries of <see cref="PlayNotificationCleanupService"/>: Play RTDN
/// dedup records purge after 30 days and Stripe webhook dedup records after 90 days, while records
/// inside their provider redelivery window survive. Runs against in-memory SQLite so the service's
/// bulk <c>ExecuteDeleteAsync</c> executes as real SQL.
/// </summary>
public sealed class PlayNotificationCleanupServiceTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly OrbitDbContext _dbContext;
private readonly IServiceScopeFactory _scopeFactory;

public PlayNotificationCleanupServiceTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();

var options = new DbContextOptionsBuilder<OrbitDbContext>()
.UseSqlite(_connection)
.Options;

_dbContext = new SqliteCompatOrbitDbContext(options);
_dbContext.Database.EnsureCreated();

var services = new ServiceCollection();
services.AddSingleton(_ => _dbContext);
_scopeFactory = services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>();
}

public void Dispose()
{
_dbContext.Dispose();
_connection.Dispose();
GC.SuppressFinalize(this);
}

[Fact]
public async Task RunAsync_PurgesRecordsOlderThanRetentionAndKeepsRecentOnes()
{
AddPlayNotification("play-recent", DaysAgo(29));
AddPlayNotification("play-old", DaysAgo(31));
AddStripeEvent("stripe-recent", DaysAgo(89));
AddStripeEvent("stripe-old", DaysAgo(91));
await _dbContext.SaveChangesAsync();

var service = new PlayNotificationCleanupService(_scopeFactory, NullLogger<PlayNotificationCleanupService>.Instance);
await service.RunAsync(CancellationToken.None);

var remainingPlay = await _dbContext.ProcessedPlayNotifications.Select(n => n.MessageId).ToListAsync();
var remainingStripe = await _dbContext.ProcessedStripeEvents.Select(e => e.EventId).ToListAsync();

remainingPlay.Should().ContainSingle().Which.Should().Be("play-recent");
remainingStripe.Should().ContainSingle().Which.Should().Be("stripe-recent");
}

[Fact]
public async Task RunAsync_NothingExpired_DeletesNothing()
{
AddPlayNotification("play-fresh", DaysAgo(1));
AddStripeEvent("stripe-fresh", DaysAgo(1));
await _dbContext.SaveChangesAsync();

var service = new PlayNotificationCleanupService(_scopeFactory, NullLogger<PlayNotificationCleanupService>.Instance);
await service.RunAsync(CancellationToken.None);

(await _dbContext.ProcessedPlayNotifications.CountAsync()).Should().Be(1);
(await _dbContext.ProcessedStripeEvents.CountAsync()).Should().Be(1);
}

private static DateTime DaysAgo(int days) => DateTime.UtcNow.AddDays(-days);

private void AddPlayNotification(string messageId, DateTime processedAtUtc)
{
var notification = ProcessedPlayNotification.Create(messageId);
SetProcessedAt(notification, processedAtUtc);
_dbContext.ProcessedPlayNotifications.Add(notification);
}

private void AddStripeEvent(string eventId, DateTime processedAtUtc)
{
var stripeEvent = ProcessedStripeEvent.Create(eventId);
SetProcessedAt(stripeEvent, processedAtUtc);
_dbContext.ProcessedStripeEvents.Add(stripeEvent);
}

private static void SetProcessedAt(ProcessedExternalEvent target, DateTime processedAtUtc) =>
typeof(ProcessedExternalEvent)
.GetProperty(nameof(ProcessedExternalEvent.ProcessedAtUtc))!
.SetValue(target, processedAtUtc);

private sealed class SqliteCompatOrbitDbContext(DbContextOptions<OrbitDbContext> options)
: OrbitDbContext(options)
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);

foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
foreach (var property in entityType.GetProperties())
{
var defaultSql = property.GetDefaultValueSql();
if (defaultSql is not null && defaultSql.Contains("::", StringComparison.Ordinal))
property.SetDefaultValueSql(null);
}

foreach (var index in entityType.GetIndexes())
index.SetFilter(null);
}
}
}
}
Loading
Loading