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
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,8 @@ Dev/dragonfly
Dev/postgres

# Claude Code
.claude/
.claude/

# Code coverage
TestResults/
*.cobertura.xml
2 changes: 2 additions & 0 deletions API.IntegrationTests/API.IntegrationTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
<!-- NuGet packages -->
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="Testcontainers.Redis" />
<PackageReference Include="TUnit" />
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" />
</ItemGroup>

<!-- Git stuff -->
Expand Down
19 changes: 19 additions & 0 deletions API.IntegrationTests/AssemblyAttributes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using TUnit.Core;
using TUnit.Core.Interfaces;

// Allow up to 3 minutes per test — integration tests can be slow in CI when Docker images
// are cold-pulled and EF migrations run for the first time. The execution timer in TUnit
// may include class-data-source initialization time for the first test that uses the factory.
[assembly: Timeout(3 * 60_000)]

// Limit parallel test execution to avoid thread pool starvation on CI runners.
// BCrypt password hashing in login/signup endpoints is synchronous and CPU-bound;
// too many concurrent tests exhaust the thread pool, causing request timeouts.
[assembly: ParallelLimiter<OpenShock.API.IntegrationTests.CiSafeParallelLimit>]

namespace OpenShock.API.IntegrationTests;

public record CiSafeParallelLimit : IParallelLimit
{
public int Limit => Math.Max(Environment.ProcessorCount * 2, 8);
}
36 changes: 36 additions & 0 deletions API.IntegrationTests/Docker/TestMailServer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using TUnit.Core.Interfaces;

namespace OpenShock.API.IntegrationTests.Docker;

public sealed class TestMailServer : IAsyncInitializer, IAsyncDisposable
{
[ClassDataSource<DockerNetwork>(Shared = SharedType.PerTestSession)]
public required DockerNetwork DockerNetwork { get; init; }

private IContainer? _container;
public IContainer Container
{
get
{
_container ??= new ContainerBuilder("axllent/mailpit:latest")
.WithNetwork(DockerNetwork.Instance)
.WithName($"tunit-mailpit-{Guid.CreateVersion7()}")
.WithPortBinding(1025, true)
.WithPortBinding(8025, true)
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilHttpRequestIsSucceeded(r => r.ForPort(8025).ForPath("/api/v1/info")))
.Build();

return _container;
}
}

public string SmtpHost => Container.Hostname;
public int SmtpPort => Container.GetMappedPublicPort(1025);
public string ApiBaseUrl => $"http://{Container.Hostname}:{Container.GetMappedPublicPort(8025)}";

public Task InitializeAsync() => Container.StartAsync();
public ValueTask DisposeAsync() => Container.DisposeAsync();
}
129 changes: 129 additions & 0 deletions API.IntegrationTests/Helpers/MailpitHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;

namespace OpenShock.API.IntegrationTests.Helpers;

/// <summary>
/// Helper for querying the Mailpit HTTP API in integration tests.
/// </summary>
public sealed class MailpitHelper : IDisposable
{
private readonly HttpClient _client;

public MailpitHelper(string apiBaseUrl)
{
_client = new HttpClient { BaseAddress = new Uri(apiBaseUrl) };
}

/// <summary>
/// Polls until at least one email arrives for the given recipient address, or the timeout elapses.
/// Returns null if no message arrived within the timeout.
/// </summary>
public async Task<MailpitMessage?> WaitForMessageAsync(
string toAddress,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(15));
while (DateTime.UtcNow < deadline && !cancellationToken.IsCancellationRequested)
{
var response = await _client.GetFromJsonAsync<MailpitSearchResponse>(
"/api/v1/messages?limit=50", cancellationToken);

var match = response?.Messages?.FirstOrDefault(m =>
m.To?.Any(c => c.Address.Equals(toAddress, StringComparison.OrdinalIgnoreCase)) == true);

if (match is not null)
return match;

await Task.Delay(300, cancellationToken);
}
return null;
}

/// <summary>
/// Returns all messages in Mailpit (no filtering).
/// </summary>
public async Task<List<MailpitMessage>> GetAllMessagesAsync(
int limit = 50,
CancellationToken cancellationToken = default)
{
var response = await _client.GetFromJsonAsync<MailpitSearchResponse>(
$"/api/v1/messages?limit={limit}", cancellationToken);
return response?.Messages ?? [];
}

/// <summary>
/// Fetches the full HTML body of a message by its ID.
/// </summary>
public async Task<MailpitFullMessage?> GetMessageAsync(string messageId, CancellationToken cancellationToken = default)
{
return await _client.GetFromJsonAsync<MailpitFullMessage>(
$"/api/v1/message/{messageId}",
cancellationToken);
}

/// <summary>
/// Deletes all messages from Mailpit (useful for test isolation between test classes).
/// </summary>
public Task DeleteAllMessagesAsync(CancellationToken cancellationToken = default)
=> _client.DeleteAsync("/api/v1/messages", cancellationToken);

public void Dispose() => _client.Dispose();

// --- DTOs ---

public sealed class MailpitSearchResponse
{
[JsonPropertyName("messages")]
public List<MailpitMessage> Messages { get; init; } = [];
}

public sealed class MailpitMessage
{
[JsonPropertyName("ID")]
public string Id { get; init; } = string.Empty;

[JsonPropertyName("Subject")]
public string Subject { get; init; } = string.Empty;

[JsonPropertyName("From")]
public MailpitContact? From { get; init; }

[JsonPropertyName("To")]
public List<MailpitContact>? To { get; init; }

[JsonPropertyName("Snippet")]
public string Snippet { get; init; } = string.Empty;
}

public sealed class MailpitFullMessage
{
[JsonPropertyName("ID")]
public string Id { get; init; } = string.Empty;

[JsonPropertyName("Subject")]
public string Subject { get; init; } = string.Empty;

[JsonPropertyName("From")]
public MailpitContact? From { get; init; }

[JsonPropertyName("To")]
public List<MailpitContact>? To { get; init; }

[JsonPropertyName("HTML")]
public string Html { get; init; } = string.Empty;

[JsonPropertyName("Text")]
public string Text { get; init; } = string.Empty;
}

public sealed class MailpitContact
{
[JsonPropertyName("Name")]
public string Name { get; init; } = string.Empty;

[JsonPropertyName("Address")]
public string Address { get; init; } = string.Empty;
}
}
175 changes: 175 additions & 0 deletions API.IntegrationTests/Helpers/TestHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
using System.Collections.Concurrent;
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using OpenShock.Common.Constants;
using OpenShock.Common.OpenShockDb;
using OpenShock.Common.Services.Session;
using OpenShock.Common.Utils;

namespace OpenShock.API.IntegrationTests.Helpers;

public static class TestHelper
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

/// <summary>
/// Cache BCrypt hashes to avoid repeated expensive hashing across tests.
/// BCrypt is synchronous and CPU-bound; hashing in every test causes thread pool
/// starvation on CI runners with fewer cores, leading to test server timeouts.
/// </summary>
private static readonly ConcurrentDictionary<string, string> PasswordHashCache = new();

/// <summary>
/// Creates a user directly in DB, creates a session via ISessionService, returns auth info.
/// This bypasses signup/login endpoints entirely to avoid rate limiting.
/// </summary>
public static async Task<AuthenticatedUser> CreateAndLoginUser(
WebApplicationFactory factory,
string username,
string email,
string password)
{
// 1. Create user directly in DB
var userId = await CreateUserInDb(factory, username, email, password);

// 2. Create session via ISessionService (stored in Redis)
await using var scope = factory.Services.CreateAsyncScope();
var sessionService = scope.ServiceProvider.GetRequiredService<ISessionService>();
var session = await sessionService.CreateSessionAsync(userId, "IntegrationTest", "127.0.0.1");

return new AuthenticatedUser(userId, username, email, session.Token);
}

/// <summary>
/// Creates an HttpClient that sends the session cookie for authentication.
/// </summary>
public static HttpClient CreateAuthenticatedClient(WebApplicationFactory factory, string sessionToken)
{
var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
HandleCookies = false
});
client.DefaultRequestHeaders.Add("Cookie", $"{AuthConstants.UserSessionCookieName}={sessionToken}");
return client;
}

/// <summary>
/// Creates an HttpClient that sends an API token header for authentication.
/// </summary>
public static HttpClient CreateApiTokenClient(WebApplicationFactory factory, string apiToken)
{
var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
HandleCookies = false
});
client.DefaultRequestHeaders.Add(AuthConstants.ApiTokenHeaderName, apiToken);
return client;
}

/// <summary>
/// Creates an HttpClient that sends a hub/device token header for authentication.
/// </summary>
public static HttpClient CreateHubTokenClient(WebApplicationFactory factory, string hubToken)
{
var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
HandleCookies = false
});
client.DefaultRequestHeaders.Add(AuthConstants.HubTokenHeaderName, hubToken);
return client;
}

/// <summary>
/// Creates a user directly in the DB (bypasses signup endpoint).
/// </summary>
public static async Task<Guid> CreateUserInDb(
WebApplicationFactory factory,
string username,
string email,
string password,
bool activated = true)
{
await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<OpenShockContext>();

var userId = Guid.CreateVersion7();
var hash = PasswordHashCache.GetOrAdd(password, HashingUtils.HashPassword);
db.Users.Add(new User
{
Id = userId,
Name = username,
Email = email,
PasswordHash = hash,
ActivatedAt = activated ? DateTime.UtcNow : null
});
await db.SaveChangesAsync();
return userId;
}

/// <summary>
/// Creates a device in the DB for a given user. Returns (deviceId, deviceToken).
/// </summary>
public static async Task<(Guid DeviceId, string Token)> CreateDeviceInDb(
WebApplicationFactory factory,
Guid ownerId,
string name = "TestDevice")
{
await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<OpenShockContext>();

var deviceId = Guid.CreateVersion7();
var token = CryptoUtils.RandomAlphaNumericString(256);
db.Devices.Add(new Device
{
Id = deviceId,
Name = name,
OwnerId = ownerId,
Token = token,
CreatedAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
return (deviceId, token);
}

/// <summary>
/// Creates an API token in the DB for a given user. Returns the raw token string.
/// </summary>
public static async Task<(Guid TokenId, string RawToken)> CreateApiTokenInDb(
WebApplicationFactory factory,
Guid userId,
string name = "TestToken",
List<Common.Models.PermissionType>? permissions = null)
{
await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<OpenShockContext>();

var rawToken = CryptoUtils.RandomAlphaNumericString(AuthConstants.ApiTokenLength);
var tokenId = Guid.CreateVersion7();
db.ApiTokens.Add(new ApiToken
{
Id = tokenId,
UserId = userId,
Name = name,
TokenHash = HashingUtils.HashToken(rawToken),
CreatedByIp = IPAddress.Loopback,
Permissions = permissions ?? [Common.Models.PermissionType.Shockers_Use]
});
await db.SaveChangesAsync();
return (tokenId, rawToken);
}

public static StringContent JsonContent(object obj)
{
return new StringContent(JsonSerializer.Serialize(obj, JsonOptions), Encoding.UTF8, "application/json");
}
}

public sealed record AuthenticatedUser(Guid Id, string Username, string Email, string SessionToken);
Loading
Loading