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
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
17 changes: 17 additions & 0 deletions API.IntegrationTests/AssemblyAttributes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using TUnit.Core;
using TUnit.Core.Interfaces;

// Fail individual tests after 60 seconds to catch hangs without being too aggressive.
[assembly: Timeout(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);
}
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);
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,17 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage

private class CloudflareTurnstileVerifyResponseDto
{
[System.Text.Json.Serialization.JsonPropertyName("success")]
public bool Success { get; init; }
[System.Text.Json.Serialization.JsonPropertyName("error-codes")]
public required string[] ErrorCodes { get; init; }
[System.Text.Json.Serialization.JsonPropertyName("challenge_ts")]
public DateTime ChallengeTs { get; init; }
[System.Text.Json.Serialization.JsonPropertyName("hostname")]
public required string Hostname { get; init; }
[System.Text.Json.Serialization.JsonPropertyName("action")]
public required string Action { get; init; }
[System.Text.Json.Serialization.JsonPropertyName("cdata")]
public required string Cdata { get; init; }
}
}
116 changes: 116 additions & 0 deletions API.IntegrationTests/Tests/AccountAuthenticatedTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using System.Net;
using OpenShock.API.IntegrationTests.Helpers;

namespace OpenShock.API.IntegrationTests.Tests;

public sealed class AccountAuthenticatedTests
{
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerTestSession)]
public required WebApplicationFactory WebApplicationFactory { get; init; }

// --- Change Password ---

[Test]
public async Task ChangePassword_Success()
{
var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "chgpwd", "chgpwd@test.org", "OldPassword123#");
using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken);

var response = await client.PostAsync("/1/account/password", TestHelper.JsonContent(new
{
currentPassword = "OldPassword123#",
newPassword = "NewPassword456#"
}));

await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);

// Verify can login with new password
using var loginClient = WebApplicationFactory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
HandleCookies = false
});
var loginResponse = await loginClient.PostAsync("/2/account/login", TestHelper.JsonContent(new
{
usernameOrEmail = "chgpwd@test.org",
password = "NewPassword456#",
turnstileResponse = "valid-token"
}));
await Assert.That(loginResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
}

[Test]
public async Task ChangePassword_WrongCurrentPassword_Returns403()
{
var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "chgpwdbad", "chgpwdbad@test.org", "CorrectPassword123#");
using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken);

var response = await client.PostAsync("/1/account/password", TestHelper.JsonContent(new
{
currentPassword = "WrongPassword!",
newPassword = "NewPassword456#"
}));

await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
}

// --- Change Username ---

[Test]
public async Task ChangeUsername_Success()
{
var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "oldname", "chguname@test.org", "SecurePassword123#");
using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken);

var response = await client.PostAsync("/1/account/username", TestHelper.JsonContent(new
{
username = "newname"
}));

await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
}

[Test]
public async Task ChangeUsername_Taken_Returns409()
{
await TestHelper.CreateAndLoginUser(WebApplicationFactory, "takenname", "takenname@test.org", "SecurePassword123#");
var user2 = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "wantsname", "wantsname@test.org", "SecurePassword123#");
using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user2.SessionToken);

var response = await client.PostAsync("/1/account/username", TestHelper.JsonContent(new
{
username = "takenname"
}));

await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
}

// --- Unauthenticated access ---

[Test]
public async Task ChangePassword_Unauthenticated_Returns401()
{
using var client = WebApplicationFactory.CreateClient();

var response = await client.PostAsync("/1/account/password", TestHelper.JsonContent(new
{
currentPassword = "anything",
newPassword = "anything"
}));

await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
}

[Test]
public async Task ChangeUsername_Unauthenticated_Returns401()
{
using var client = WebApplicationFactory.CreateClient();

var response = await client.PostAsync("/1/account/username", TestHelper.JsonContent(new
{
username = "anything"
}));

await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
}
}
Loading
Loading