From 21a5048ae48035551e1cba7d74dec077dc50a58a Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 11 Feb 2026 13:18:14 +0100 Subject: [PATCH 1/9] Add comprehensive integration test suite (~111 tests) New test files covering all major API endpoints: - AccountSignup, AccountLogin, AccountAuthenticated - Devices CRUD, Shockers CRUD, Tokens CRUD - Sessions, Users, DeviceEndpoint (hub auth) - Public endpoints, Authorization/isolation, RateLimiter Infrastructure improvements: - TestHelper with direct DB/Redis user creation (bypasses endpoints) - Auth helpers for session cookie, API token, and hub token clients - Rate limiting disabled in test host - Cookie domain includes localhost for test server - Fixed Turnstile mock DTO JSON property names Co-Authored-By: Claude Opus 4.6 --- API.IntegrationTests/Helpers/TestHelper.cs | 166 ++++++++++++ .../InterceptedHttpMessageHandler.cs | 6 + .../Tests/AccountAuthenticatedTests.cs | 116 ++++++++ .../Tests/AccountLoginTests.cs | 202 ++++++++++++++ .../Tests/AccountSignupTests.cs | 154 +++++++++++ .../Tests/AuthorizationTests.cs | 152 +++++++++++ .../Tests/DeviceEndpointTests.cs | 111 ++++++++ API.IntegrationTests/Tests/DevicesTests.cs | 227 ++++++++++++++++ API.IntegrationTests/Tests/PublicTests.cs | 72 +++++ .../Tests/RateLimiterTests.cs | 43 +++ API.IntegrationTests/Tests/SessionsTests.cs | 83 ++++++ API.IntegrationTests/Tests/ShockersTests.cs | 249 ++++++++++++++++++ API.IntegrationTests/Tests/TokensTests.cs | 226 ++++++++++++++++ API.IntegrationTests/Tests/UsersTests.cs | 96 +++++++ API.IntegrationTests/WebApplicationFactory.cs | 48 +++- 15 files changed, 1941 insertions(+), 10 deletions(-) create mode 100644 API.IntegrationTests/Helpers/TestHelper.cs create mode 100644 API.IntegrationTests/Tests/AccountAuthenticatedTests.cs create mode 100644 API.IntegrationTests/Tests/AccountLoginTests.cs create mode 100644 API.IntegrationTests/Tests/AccountSignupTests.cs create mode 100644 API.IntegrationTests/Tests/AuthorizationTests.cs create mode 100644 API.IntegrationTests/Tests/DeviceEndpointTests.cs create mode 100644 API.IntegrationTests/Tests/DevicesTests.cs create mode 100644 API.IntegrationTests/Tests/PublicTests.cs create mode 100644 API.IntegrationTests/Tests/RateLimiterTests.cs create mode 100644 API.IntegrationTests/Tests/SessionsTests.cs create mode 100644 API.IntegrationTests/Tests/ShockersTests.cs create mode 100644 API.IntegrationTests/Tests/TokensTests.cs create mode 100644 API.IntegrationTests/Tests/UsersTests.cs diff --git a/API.IntegrationTests/Helpers/TestHelper.cs b/API.IntegrationTests/Helpers/TestHelper.cs new file mode 100644 index 00000000..834eb4bf --- /dev/null +++ b/API.IntegrationTests/Helpers/TestHelper.cs @@ -0,0 +1,166 @@ +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 + }; + + /// + /// Creates a user directly in DB, creates a session via ISessionService, returns auth info. + /// This bypasses signup/login endpoints entirely to avoid rate limiting. + /// + public static async Task 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(); + var session = await sessionService.CreateSessionAsync(userId, "IntegrationTest", "127.0.0.1"); + + return new AuthenticatedUser(userId, username, email, session.Token); + } + + /// + /// Creates an HttpClient that sends the session cookie for authentication. + /// + 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; + } + + /// + /// Creates an HttpClient that sends an API token header for authentication. + /// + 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; + } + + /// + /// Creates an HttpClient that sends a hub/device token header for authentication. + /// + 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; + } + + /// + /// Creates a user directly in the DB (bypasses signup endpoint). + /// + public static async Task CreateUserInDb( + WebApplicationFactory factory, + string username, + string email, + string password, + bool activated = true) + { + await using var scope = factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var userId = Guid.CreateVersion7(); + db.Users.Add(new User + { + Id = userId, + Name = username, + Email = email, + PasswordHash = HashingUtils.HashPassword(password), + ActivatedAt = activated ? DateTime.UtcNow : null + }); + await db.SaveChangesAsync(); + return userId; + } + + /// + /// Creates a device in the DB for a given user. Returns (deviceId, deviceToken). + /// + 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(); + + 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); + } + + /// + /// Creates an API token in the DB for a given user. Returns the raw token string. + /// + public static async Task<(Guid TokenId, string RawToken)> CreateApiTokenInDb( + WebApplicationFactory factory, + Guid userId, + string name = "TestToken", + List? permissions = null) + { + await using var scope = factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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); diff --git a/API.IntegrationTests/HttpMessageHandlers/InterceptedHttpMessageHandler.cs b/API.IntegrationTests/HttpMessageHandlers/InterceptedHttpMessageHandler.cs index d71d9b6e..b2c69775 100644 --- a/API.IntegrationTests/HttpMessageHandlers/InterceptedHttpMessageHandler.cs +++ b/API.IntegrationTests/HttpMessageHandlers/InterceptedHttpMessageHandler.cs @@ -70,11 +70,17 @@ protected override async Task 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; } } } \ No newline at end of file diff --git a/API.IntegrationTests/Tests/AccountAuthenticatedTests.cs b/API.IntegrationTests/Tests/AccountAuthenticatedTests.cs new file mode 100644 index 00000000..8bdc6691 --- /dev/null +++ b/API.IntegrationTests/Tests/AccountAuthenticatedTests.cs @@ -0,0 +1,116 @@ +using System.Net; +using OpenShock.API.IntegrationTests.Helpers; + +namespace OpenShock.API.IntegrationTests.Tests; + +public sealed class AccountAuthenticatedTests +{ + [ClassDataSource(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_Returns401() + { + 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() + { + var user1 = 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); + } +} diff --git a/API.IntegrationTests/Tests/AccountLoginTests.cs b/API.IntegrationTests/Tests/AccountLoginTests.cs new file mode 100644 index 00000000..d8462821 --- /dev/null +++ b/API.IntegrationTests/Tests/AccountLoginTests.cs @@ -0,0 +1,202 @@ +using System.Net; +using System.Text.Json; +using OpenShock.API.IntegrationTests.Helpers; +using OpenShock.Common.Constants; + +namespace OpenShock.API.IntegrationTests.Tests; + +public sealed class AccountLoginTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory WebApplicationFactory { get; init; } + + // --- V1 Login --- + + [Test] + public async Task V1Login_Success_ReturnsCookie() + { + await TestHelper.CreateUserInDb(WebApplicationFactory, "loginv1", "loginv1@test.org", "SecurePassword123#"); + + using var client = WebApplicationFactory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + var response = await client.PostAsync("/1/account/login", TestHelper.JsonContent(new + { + email = "loginv1@test.org", + password = "SecurePassword123#" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var setCookie = response.Headers.GetValues("Set-Cookie").ToArray(); + var hasSessionCookie = setCookie.Any(c => c.Contains(AuthConstants.UserSessionCookieName)); + await Assert.That(hasSessionCookie).IsTrue(); + } + + [Test] + public async Task V1Login_InvalidPassword_Returns401() + { + await TestHelper.CreateUserInDb(WebApplicationFactory, "loginv1bad", "loginv1bad@test.org", "SecurePassword123#"); + + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.PostAsync("/1/account/login", TestHelper.JsonContent(new + { + email = "loginv1bad@test.org", + password = "WrongPassword999!" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + [Test] + public async Task V1Login_NonexistentUser_Returns401() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.PostAsync("/1/account/login", TestHelper.JsonContent(new + { + email = "doesnotexist@test.org", + password = "SomePassword123#" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + // --- V2 Login --- + + [Test] + public async Task V2Login_Success_ReturnsCookieAndBody() + { + await TestHelper.CreateUserInDb(WebApplicationFactory, "loginv2", "loginv2@test.org", "SecurePassword123#"); + + using var client = WebApplicationFactory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + var response = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new + { + usernameOrEmail = "loginv2@test.org", + password = "SecurePassword123#", + turnstileResponse = "valid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + await Assert.That(root.TryGetProperty("accountId", out _)).IsTrue(); + await Assert.That(root.TryGetProperty("accountName", out _)).IsTrue(); + + var setCookie = response.Headers.GetValues("Set-Cookie").ToArray(); + var hasSessionCookie = setCookie.Any(c => c.Contains(AuthConstants.UserSessionCookieName)); + await Assert.That(hasSessionCookie).IsTrue(); + } + + [Test] + public async Task V2Login_InvalidTurnstile_Returns403() + { + await TestHelper.CreateUserInDb(WebApplicationFactory, "loginv2ts", "loginv2ts@test.org", "SecurePassword123#"); + + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new + { + usernameOrEmail = "loginv2ts@test.org", + password = "SecurePassword123#", + turnstileResponse = "invalid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } + + [Test] + public async Task V2Login_InvalidCredentials_Returns401() + { + await TestHelper.CreateUserInDb(WebApplicationFactory, "loginv2bad", "loginv2bad@test.org", "SecurePassword123#"); + + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new + { + usernameOrEmail = "loginv2bad@test.org", + password = "WrongPassword!", + turnstileResponse = "valid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + [Test] + public async Task V2Login_ByUsername_Success() + { + await TestHelper.CreateUserInDb(WebApplicationFactory, "loginbyname", "loginbyname@test.org", "SecurePassword123#"); + + using var client = WebApplicationFactory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + var response = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new + { + usernameOrEmail = "loginbyname", + password = "SecurePassword123#", + turnstileResponse = "valid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + // --- Unactivated account --- + + [Test] + public async Task V2Login_UnactivatedAccount_Returns401() + { + await TestHelper.CreateUserInDb(WebApplicationFactory, "notactivated", "notactivated@test.org", "SecurePassword123#", activated: false); + + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new + { + usernameOrEmail = "notactivated@test.org", + password = "SecurePassword123#", + turnstileResponse = "valid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + // --- Logout --- + + [Test] + public async Task Logout_ClearsCookie() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "logoutuser", "logoutuser@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PostAsync("/1/account/logout", null); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + [Test] + public async Task Logout_WithoutSession_StillReturnsOk() + { + using var client = WebApplicationFactory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + var response = await client.PostAsync("/1/account/logout", null); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } +} diff --git a/API.IntegrationTests/Tests/AccountSignupTests.cs b/API.IntegrationTests/Tests/AccountSignupTests.cs new file mode 100644 index 00000000..d8f1cb38 --- /dev/null +++ b/API.IntegrationTests/Tests/AccountSignupTests.cs @@ -0,0 +1,154 @@ +using System.Net; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.API.IntegrationTests.Helpers; +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.API.IntegrationTests.Tests; + +public sealed class AccountSignupTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory WebApplicationFactory { get; init; } + + // --- V1 Signup --- + + [Test] + public async Task V1Signup_Success_CreatesUser() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.PostAsync("/1/account/signup", TestHelper.JsonContent(new + { + username = "v1user", + password = "SecurePassword123#", + email = "v1user@test.org" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var user = await db.Users.FirstOrDefaultAsync(u => u.Email == "v1user@test.org"); + await Assert.That(user).IsNotNull(); + } + + [Test, DependsOn(nameof(V1Signup_Success_CreatesUser))] + public async Task V1Signup_DuplicateEmail_Returns409() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.PostAsync("/1/account/signup", TestHelper.JsonContent(new + { + username = "v1userDifferent", + password = "SecurePassword123#", + email = "v1user@test.org" // same email + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } + + [Test, DependsOn(nameof(V1Signup_Success_CreatesUser))] + public async Task V1Signup_DuplicateUsername_Returns409() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.PostAsync("/1/account/signup", TestHelper.JsonContent(new + { + username = "v1user", // same username + password = "SecurePassword123#", + email = "v1different@test.org" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } + + // --- V2 Signup --- + + [Test] + public async Task V2Signup_Success_CreatesUser() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username = "v2user", + password = "SecurePassword123#", + email = "v2user@test.org", + turnstileResponse = "valid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var user = await db.Users.FirstOrDefaultAsync(u => u.Email == "v2user@test.org"); + await Assert.That(user).IsNotNull(); + } + + [Test] + public async Task V2Signup_InvalidTurnstile_Returns403() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username = "v2blocked", + password = "SecurePassword123#", + email = "v2blocked@test.org", + turnstileResponse = "invalid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } + + [Test, DependsOn(nameof(V2Signup_Success_CreatesUser))] + public async Task V2Signup_DuplicateEmail_Returns409() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username = "v2userDifferent", + password = "SecurePassword123#", + email = "v2user@test.org", // same email + turnstileResponse = "valid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } + + // --- Validation --- + + [Test] + public async Task V2Signup_EmptyUsername_Returns400() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username = "", + password = "SecurePassword123#", + email = "emptyusername@test.org", + turnstileResponse = "valid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task V2Signup_EmptyPassword_Returns400() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username = "validname", + password = "", + email = "emptypass@test.org", + turnstileResponse = "valid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } +} diff --git a/API.IntegrationTests/Tests/AuthorizationTests.cs b/API.IntegrationTests/Tests/AuthorizationTests.cs new file mode 100644 index 00000000..e4cffa70 --- /dev/null +++ b/API.IntegrationTests/Tests/AuthorizationTests.cs @@ -0,0 +1,152 @@ +using System.Net; +using OpenShock.API.IntegrationTests.Helpers; + +namespace OpenShock.API.IntegrationTests.Tests; + +/// +/// Cross-cutting authorization tests verifying that auth is enforced correctly +/// across different endpoints and auth schemes, and that cross-user isolation works. +/// +public sealed class AuthorizationTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory WebApplicationFactory { get; init; } + + // --- Unauthenticated requests to protected endpoints --- + + [Test] + [Arguments("/1/devices")] + [Arguments("/1/shockers/shared")] + [Arguments("/1/tokens")] + [Arguments("/1/sessions/self")] + [Arguments("/1/users/self")] + public async Task ProtectedEndpoint_NoAuth_Returns401(string url) + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.GetAsync(url); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + [Test] + public async Task ProtectedPostEndpoint_NoAuth_Returns401() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.PostAsync("/1/devices", null); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + // --- API Token on session-only endpoints --- + + [Test] + public async Task ApiToken_OnSessionOnlyEndpoint_Returns401() + { + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, "apitoksess", "apitoksess@test.org", "SecurePassword123#"); + var (_, rawToken) = await TestHelper.CreateApiTokenInDb(WebApplicationFactory, userId, "SessionOnlyTest"); + using var client = TestHelper.CreateApiTokenClient(WebApplicationFactory, rawToken); + + // Sessions endpoint requires UserSessionCookie only + var response = await client.GetAsync("/1/sessions/self"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + // --- Session cookie on hub-only endpoint --- + + [Test] + public async Task SessionCookie_OnHubOnlyEndpoint_Returns401() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "sesshub", "sesshub@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + // Device self endpoint requires HubToken + var response = await client.GetAsync("/1/device/self"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + // --- Cross-user isolation: devices --- + + [Test] + public async Task CrossUser_CannotSeeOtherUsersDevices() + { + var user1 = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "isoown1", "isoown1@test.org", "SecurePassword123#"); + var user2 = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "isoown2", "isoown2@test.org", "SecurePassword123#"); + var (deviceId, _) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, user1.Id, "PrivateHub"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user2.SessionToken); + + var response = await client.GetAsync($"/1/devices/{deviceId}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task CrossUser_CannotEditOtherUsersDevices() + { + var user1 = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "isoedit1", "isoedit1@test.org", "SecurePassword123#"); + var user2 = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "isoedit2", "isoedit2@test.org", "SecurePassword123#"); + var (deviceId, _) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, user1.Id, "SecureHub"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user2.SessionToken); + + var response = await client.PatchAsync($"/1/devices/{deviceId}", TestHelper.JsonContent(new + { + name = "Hacked" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task CrossUser_CannotDeleteOtherUsersDevices() + { + var user1 = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "isodel1", "isodel1@test.org", "SecurePassword123#"); + var user2 = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "isodel2", "isodel2@test.org", "SecurePassword123#"); + var (deviceId, _) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, user1.Id, "ProtectedHub"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user2.SessionToken); + + var response = await client.DeleteAsync($"/1/devices/{deviceId}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + // --- Cross-user isolation: tokens --- + + [Test] + public async Task CrossUser_CannotSeeOtherUsersTokens() + { + var user1 = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "isotok1", "isotok1@test.org", "SecurePassword123#"); + var user2 = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "isotok2", "isotok2@test.org", "SecurePassword123#"); + + // Create a token for user1 via authenticated client + using var client1 = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user1.SessionToken); + var createResponse = await client1.PostAsync("/1/tokens", TestHelper.JsonContent(new + { + name = "User1Token", + permissions = new[] { "shockers.use" } + })); + var createJson = await createResponse.Content.ReadAsStringAsync(); + using var createDoc = System.Text.Json.JsonDocument.Parse(createJson); + var tokenId = createDoc.RootElement.GetProperty("id").GetString(); + + // User2 tries to access it + using var client2 = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user2.SessionToken); + var response = await client2.GetAsync($"/1/tokens/{tokenId}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + // --- Invalid session token --- + + [Test] + public async Task InvalidSessionToken_Returns401() + { + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, "totally-invalid-session-token-abc123"); + + var response = await client.GetAsync("/1/devices"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } +} diff --git a/API.IntegrationTests/Tests/DeviceEndpointTests.cs b/API.IntegrationTests/Tests/DeviceEndpointTests.cs new file mode 100644 index 00000000..b8eef1e7 --- /dev/null +++ b/API.IntegrationTests/Tests/DeviceEndpointTests.cs @@ -0,0 +1,111 @@ +using System.Net; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.API.IntegrationTests.Helpers; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Utils; + +namespace OpenShock.API.IntegrationTests.Tests; + +/// +/// Tests for the hub/device-authenticated endpoints (/device/*). +/// These endpoints use Device-Token (HubToken) auth. +/// +public sealed class DeviceEndpointTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory WebApplicationFactory { get; init; } + + // --- Get Device Self --- + + [Test] + public async Task GetDeviceSelf_ReturnsDeviceInfo() + { + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, "hubself", "hubself@test.org", "SecurePassword123#"); + var (deviceId, hubToken) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, userId, "MyHub"); + using var client = TestHelper.CreateHubTokenClient(WebApplicationFactory, hubToken); + + var response = await client.GetAsync("/1/device/self"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var data = doc.RootElement.GetProperty("data"); + await Assert.That(data.GetProperty("name").GetString()).IsEqualTo("MyHub"); + await Assert.That(data.TryGetProperty("shockers", out _)).IsTrue(); + } + + [Test] + public async Task GetDeviceSelf_WithShockers_ReturnsShokerList() + { + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, "hubshockers", "hubshockers@test.org", "SecurePassword123#"); + var (deviceId, hubToken) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, userId, "HubWithShockers"); + + // Add a shocker to this device + await using (var scope = WebApplicationFactory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Shockers.Add(new Shocker + { + Id = Guid.CreateVersion7(), + Name = "HubShocker", + RfId = 500, + DeviceId = deviceId, + Model = ShockerModelType.CaiXianlin + }); + await db.SaveChangesAsync(); + } + + using var client = TestHelper.CreateHubTokenClient(WebApplicationFactory, hubToken); + + var response = await client.GetAsync("/1/device/self"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var shockers = doc.RootElement.GetProperty("data").GetProperty("shockers"); + await Assert.That(shockers.GetArrayLength()).IsGreaterThanOrEqualTo(1); + } + + // --- Invalid Hub Token --- + + [Test] + public async Task GetDeviceSelf_InvalidToken_Returns401() + { + using var client = TestHelper.CreateHubTokenClient(WebApplicationFactory, "completely-invalid-token"); + + var response = await client.GetAsync("/1/device/self"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + // --- Hub token on session-only endpoint should fail --- + + [Test] + public async Task HubToken_OnSessionOnlyEndpoint_Returns401() + { + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, "hubwrong", "hubwrong@test.org", "SecurePassword123#"); + var (_, hubToken) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, userId, "WrongHub"); + using var client = TestHelper.CreateHubTokenClient(WebApplicationFactory, hubToken); + + // Tokens endpoint requires UserSessionCookie, not HubToken + var response = await client.GetAsync("/1/tokens"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + // --- No auth --- + + [Test] + public async Task GetDeviceSelf_NoAuth_Returns401() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.GetAsync("/1/device/self"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } +} diff --git a/API.IntegrationTests/Tests/DevicesTests.cs b/API.IntegrationTests/Tests/DevicesTests.cs new file mode 100644 index 00000000..f13a826a --- /dev/null +++ b/API.IntegrationTests/Tests/DevicesTests.cs @@ -0,0 +1,227 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using OpenShock.API.IntegrationTests.Helpers; + +namespace OpenShock.API.IntegrationTests.Tests; + +public sealed class DevicesTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory WebApplicationFactory { get; init; } + + // --- List Devices --- + + [Test] + public async Task ListDevices_Empty_ReturnsEmptyArray() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "devempty", "devempty@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.GetAsync("/1/devices"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var data = doc.RootElement.GetProperty("data"); + await Assert.That(data.GetArrayLength()).IsEqualTo(0); + } + + // --- Create Device (V1 + V2) --- + + [Test] + public async Task CreateDeviceV1_Returns201() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "devv1create", "devv1create@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PostAsync("/1/devices", null); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + } + + [Test] + public async Task CreateDeviceV2_WithName_Returns201() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "devv2create", "devv2create@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PostAsync("/2/devices", TestHelper.JsonContent(new + { + name = "My Custom Hub" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + } + + // --- Get Device by ID --- + + [Test] + public async Task GetDeviceById_Success() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "devgetone", "devgetone@test.org", "SecurePassword123#"); + var (deviceId, _) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, user.Id, "TestHub1"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.GetAsync($"/1/devices/{deviceId}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var data = doc.RootElement.GetProperty("data"); + await Assert.That(data.GetProperty("name").GetString()).IsEqualTo("TestHub1"); + } + + [Test] + public async Task GetDeviceById_NonexistentId_Returns404() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "devget404", "devget404@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.GetAsync($"/1/devices/{Guid.CreateVersion7()}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task GetDeviceById_OtherUsersDevice_Returns404() + { + var user1 = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "devowner", "devowner@test.org", "SecurePassword123#"); + var user2 = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "devother", "devother@test.org", "SecurePassword123#"); + var (deviceId, _) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, user1.Id, "OwnerHub"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user2.SessionToken); + + var response = await client.GetAsync($"/1/devices/{deviceId}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + // --- Edit Device --- + + [Test] + public async Task EditDevice_Rename_Success() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "devedit", "devedit@test.org", "SecurePassword123#"); + var (deviceId, _) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, user.Id, "OldName"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PatchAsync($"/1/devices/{deviceId}", TestHelper.JsonContent(new + { + name = "RenamedHub" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // Verify name changed + var getResponse = await client.GetAsync($"/1/devices/{deviceId}"); + var json = await getResponse.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var name = doc.RootElement.GetProperty("data").GetProperty("name").GetString(); + await Assert.That(name).IsEqualTo("RenamedHub"); + } + + [Test] + public async Task EditDevice_Nonexistent_Returns404() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "devedit404", "devedit404@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PatchAsync($"/1/devices/{Guid.CreateVersion7()}", TestHelper.JsonContent(new + { + name = "Whatever" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + // --- Delete Device --- + + [Test] + public async Task DeleteDevice_Success() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "devdel", "devdel@test.org", "SecurePassword123#"); + var (deviceId, _) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, user.Id, "ToDelete"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.DeleteAsync($"/1/devices/{deviceId}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // Verify it no longer exists + var getResponse = await client.GetAsync($"/1/devices/{deviceId}"); + await Assert.That(getResponse.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task DeleteDevice_Nonexistent_Returns404() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "devdel404", "devdel404@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.DeleteAsync($"/1/devices/{Guid.CreateVersion7()}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + // --- Regenerate Device Token --- + + [Test] + public async Task RegenerateDeviceToken_ReturnsNewToken() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "devregen", "devregen@test.org", "SecurePassword123#"); + var (deviceId, originalToken) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, user.Id, "RegenHub"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PutAsync($"/1/devices/{deviceId}", null); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var newToken = await response.Content.ReadAsStringAsync(); + await Assert.That(newToken).IsNotNullOrWhiteSpace(); + await Assert.That(newToken).IsNotEqualTo(originalToken); + } + + // --- Get Device Shockers --- + + [Test] + public async Task GetDeviceShockers_EmptyDevice_ReturnsEmptyList() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "devshock0", "devshock0@test.org", "SecurePassword123#"); + var (deviceId, _) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, user.Id, "EmptyHub"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.GetAsync($"/1/devices/{deviceId}/shockers"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var data = doc.RootElement.GetProperty("data"); + await Assert.That(data.GetArrayLength()).IsEqualTo(0); + } + + [Test] + public async Task GetDeviceShockers_WrongDevice_Returns404() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "devshock404", "devshock404@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.GetAsync($"/1/devices/{Guid.CreateVersion7()}/shockers"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + // --- Unauthorized --- + + [Test] + public async Task ListDevices_Unauthenticated_Returns401() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.GetAsync("/1/devices"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } +} diff --git a/API.IntegrationTests/Tests/PublicTests.cs b/API.IntegrationTests/Tests/PublicTests.cs new file mode 100644 index 00000000..02d02144 --- /dev/null +++ b/API.IntegrationTests/Tests/PublicTests.cs @@ -0,0 +1,72 @@ +using System.Net; +using System.Text.Json; + +namespace OpenShock.API.IntegrationTests.Tests; + +public sealed class PublicTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory WebApplicationFactory { get; init; } + + // --- Metadata / Version --- + + [Test] + public async Task GetMetadataV1_ReturnsValidResponse() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.GetAsync("/1"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var mediaType = response.Content.Headers.ContentType?.MediaType; + await Assert.That(mediaType).IsEqualTo("application/json"); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var data = doc.RootElement.GetProperty("data"); + await Assert.That(data.GetProperty("version").GetString()).IsNotNullOrWhiteSpace(); + await Assert.That(data.GetProperty("currentTime").GetDateTimeOffset()).IsBetween( + DateTimeOffset.UtcNow.AddSeconds(-10), + DateTimeOffset.UtcNow.AddSeconds(10)); + } + + // --- Public Stats --- + + [Test] + public async Task GetStats_ReturnsDevicesOnlineCount() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.GetAsync("/1/public/stats"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var data = doc.RootElement.GetProperty("data"); + var devicesOnline = data.GetProperty("devicesOnline").GetInt64(); + await Assert.That(devicesOnline).IsGreaterThanOrEqualTo(0); + } + + // --- Check Username (public endpoint) --- + + [Test] + public async Task CheckUsername_Available_ReturnsAvailable() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.PostAsync("/1/account/username/check", + new StringContent( + JsonSerializer.Serialize(new { username = "totallyuniquename123" }), + System.Text.Encoding.UTF8, + "application/json")); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var availability = doc.RootElement.GetProperty("availability").GetString(); + await Assert.That(availability).IsEqualTo("Available"); + } +} diff --git a/API.IntegrationTests/Tests/RateLimiterTests.cs b/API.IntegrationTests/Tests/RateLimiterTests.cs new file mode 100644 index 00000000..2094867b --- /dev/null +++ b/API.IntegrationTests/Tests/RateLimiterTests.cs @@ -0,0 +1,43 @@ +using System.Net; +using OpenShock.API.IntegrationTests.Helpers; + +namespace OpenShock.API.IntegrationTests.Tests; + +/// +/// Smoke tests that the rate limiter middleware is registered and policies are configured. +/// Rate limiting is disabled in the test server (NoLimiter policies) to avoid interference +/// between tests. Detailed rate limiter behavior is covered by unit tests. +/// +public sealed class RateLimiterTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory WebApplicationFactory { get; init; } + + [Test] + public async Task AuthEndpoint_WithRateLimiterPolicy_DoesNotReturn500() + { + using var client = WebApplicationFactory.CreateClient(); + + // The "auth" rate limiter policy is applied to login/signup endpoints. + // Verify the policy is correctly registered (no 500 from missing policy). + var response = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new + { + usernameOrEmail = "ratelimitertest@test.org", + password = "SomePassword123#", + turnstileResponse = "valid-token" + })); + + await Assert.That(response.StatusCode).IsNotEqualTo(HttpStatusCode.InternalServerError); + } + + [Test] + public async Task GlobalEndpoint_WithRateLimiterMiddleware_DoesNotReturn500() + { + using var client = WebApplicationFactory.CreateClient(); + + // Verify that the global rate limiter middleware doesn't cause errors. + var response = await client.GetAsync("/1"); + + await Assert.That(response.StatusCode).IsNotEqualTo(HttpStatusCode.InternalServerError); + } +} diff --git a/API.IntegrationTests/Tests/SessionsTests.cs b/API.IntegrationTests/Tests/SessionsTests.cs new file mode 100644 index 00000000..5c2aa353 --- /dev/null +++ b/API.IntegrationTests/Tests/SessionsTests.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Text.Json; +using OpenShock.API.IntegrationTests.Helpers; + +namespace OpenShock.API.IntegrationTests.Tests; + +public sealed class SessionsTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory WebApplicationFactory { get; init; } + + // --- Get Self Session --- + + [Test] + public async Task GetSelfSession_ReturnsSessionInfo() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "sessself", "sessself@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.GetAsync("/1/sessions/self"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + await Assert.That(root.TryGetProperty("id", out _)).IsTrue(); + } + + // --- Delete Session --- + + [Test] + public async Task DeleteSession_Nonexistent_Returns404() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "sessdel404", "sessdel404@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.DeleteAsync($"/1/sessions/{Guid.CreateVersion7()}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task DeleteSession_OwnSession_Success() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "sessdel", "sessdel@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + // Get self session to get session ID + var selfResponse = await client.GetAsync("/1/sessions/self"); + await Assert.That(selfResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + var selfJson = await selfResponse.Content.ReadAsStringAsync(); + using var selfDoc = JsonDocument.Parse(selfJson); + var sessionId = selfDoc.RootElement.GetProperty("id").GetString(); + + // Delete it + var response = await client.DeleteAsync($"/1/sessions/{sessionId}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + // --- Unauthenticated --- + + [Test] + public async Task GetSelfSession_Unauthenticated_Returns401() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.GetAsync("/1/sessions/self"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + [Test] + public async Task DeleteSession_Unauthenticated_Returns401() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.DeleteAsync($"/1/sessions/{Guid.CreateVersion7()}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } +} diff --git a/API.IntegrationTests/Tests/ShockersTests.cs b/API.IntegrationTests/Tests/ShockersTests.cs new file mode 100644 index 00000000..b6f430c3 --- /dev/null +++ b/API.IntegrationTests/Tests/ShockersTests.cs @@ -0,0 +1,249 @@ +using System.Net; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.API.IntegrationTests.Helpers; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.API.IntegrationTests.Tests; + +public sealed class ShockersTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory WebApplicationFactory { get; init; } + + // --- Register Shocker --- + + [Test] + public async Task RegisterShocker_Success_Returns201() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "shkreg", "shkreg@test.org", "SecurePassword123#"); + var (deviceId, _) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, user.Id, "ShkHub"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PostAsync("/1/shockers", TestHelper.JsonContent(new + { + name = "TestShocker", + rfId = 1234, + device = deviceId, + model = "CaiXianlin" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var data = doc.RootElement.GetProperty("data"); + // Should return a GUID + await Assert.That(Guid.TryParse(data.GetString(), out _)).IsTrue(); + } + + [Test] + public async Task RegisterShocker_NonexistentDevice_Returns404() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "shkregbad", "shkregbad@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PostAsync("/1/shockers", TestHelper.JsonContent(new + { + name = "Ghost", + rfId = 9999, + device = Guid.CreateVersion7(), + model = "CaiXianlin" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + // --- Get Shocker by ID --- + + [Test] + public async Task GetShockerById_Success() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "shkget", "shkget@test.org", "SecurePassword123#"); + var (deviceId, _) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, user.Id); + var shockerId = await CreateShockerInDb(user.Id, deviceId, "MyShocker", 100); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.GetAsync($"/1/shockers/{shockerId}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var data = doc.RootElement.GetProperty("data"); + await Assert.That(data.GetProperty("name").GetString()).IsEqualTo("MyShocker"); + } + + [Test] + public async Task GetShockerById_Nonexistent_Returns404() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "shkget404", "shkget404@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.GetAsync($"/1/shockers/{Guid.CreateVersion7()}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + // --- Edit Shocker --- + + [Test] + public async Task EditShocker_Rename_Success() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "shkedit", "shkedit@test.org", "SecurePassword123#"); + var (deviceId, _) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, user.Id); + var shockerId = await CreateShockerInDb(user.Id, deviceId, "OldShockerName", 200); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PatchAsync($"/1/shockers/{shockerId}", TestHelper.JsonContent(new + { + name = "RenamedShocker", + rfId = 200, + device = deviceId, + model = "CaiXianlin" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // Verify the name changed + var getResponse = await client.GetAsync($"/1/shockers/{shockerId}"); + var json = await getResponse.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var name = doc.RootElement.GetProperty("data").GetProperty("name").GetString(); + await Assert.That(name).IsEqualTo("RenamedShocker"); + } + + [Test] + public async Task EditShocker_Nonexistent_Returns404() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "shkedit404", "shkedit404@test.org", "SecurePassword123#"); + var (deviceId, _) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, user.Id); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PatchAsync($"/1/shockers/{Guid.CreateVersion7()}", TestHelper.JsonContent(new + { + name = "Whatever", + rfId = 100, + device = deviceId, + model = "CaiXianlin" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + // --- Pause / Unpause --- + + [Test] + public async Task PauseShocker_Success() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "shkpause", "shkpause@test.org", "SecurePassword123#"); + var (deviceId, _) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, user.Id); + var shockerId = await CreateShockerInDb(user.Id, deviceId, "PauseShocker", 300); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + // Pause + var pauseResponse = await client.PostAsync($"/1/shockers/{shockerId}/pause", TestHelper.JsonContent(new + { + pause = true + })); + await Assert.That(pauseResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // Verify paused + var getResponse = await client.GetAsync($"/1/shockers/{shockerId}"); + var json = await getResponse.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var isPaused = doc.RootElement.GetProperty("data").GetProperty("isPaused").GetBoolean(); + await Assert.That(isPaused).IsTrue(); + + // Unpause + var unpauseResponse = await client.PostAsync($"/1/shockers/{shockerId}/pause", TestHelper.JsonContent(new + { + pause = false + })); + await Assert.That(unpauseResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + [Test] + public async Task PauseShocker_Nonexistent_Returns404() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "shkpause404", "shkpause404@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PostAsync($"/1/shockers/{Guid.CreateVersion7()}/pause", TestHelper.JsonContent(new + { + pause = true + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + // --- Delete Shocker --- + + [Test] + public async Task DeleteShocker_Success() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "shkdel", "shkdel@test.org", "SecurePassword123#"); + var (deviceId, _) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, user.Id); + var shockerId = await CreateShockerInDb(user.Id, deviceId, "ToDelete", 400); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.DeleteAsync($"/1/shockers/{shockerId}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // Verify it no longer exists + var getResponse = await client.GetAsync($"/1/shockers/{shockerId}"); + await Assert.That(getResponse.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task DeleteShocker_Nonexistent_Returns404() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "shkdel404", "shkdel404@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.DeleteAsync($"/1/shockers/{Guid.CreateVersion7()}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + // --- Unauthorized --- + + [Test] + public async Task RegisterShocker_Unauthenticated_Returns401() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.PostAsync("/1/shockers", TestHelper.JsonContent(new + { + name = "Test", + rfId = 1, + device = Guid.CreateVersion7(), + model = "CaiXianlin" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + // --- Helper --- + + private async Task CreateShockerInDb(Guid userId, Guid deviceId, string name, ushort rfId) + { + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var shockerId = Guid.CreateVersion7(); + db.Shockers.Add(new Shocker + { + Id = shockerId, + Name = name, + RfId = rfId, + DeviceId = deviceId, + Model = ShockerModelType.CaiXianlin + }); + await db.SaveChangesAsync(); + return shockerId; + } +} diff --git a/API.IntegrationTests/Tests/TokensTests.cs b/API.IntegrationTests/Tests/TokensTests.cs new file mode 100644 index 00000000..4a7ac67a --- /dev/null +++ b/API.IntegrationTests/Tests/TokensTests.cs @@ -0,0 +1,226 @@ +using System.Net; +using System.Text.Json; +using OpenShock.API.IntegrationTests.Helpers; + +namespace OpenShock.API.IntegrationTests.Tests; + +public sealed class TokensTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory WebApplicationFactory { get; init; } + + // --- Create Token --- + + [Test] + public async Task CreateToken_Success_ReturnsTokenString() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "tokcreate", "tokcreate@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PostAsync("/1/tokens", TestHelper.JsonContent(new + { + name = "MyToken", + permissions = new[] { "shockers.use" } + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + await Assert.That(root.GetProperty("token").GetString()).IsNotNullOrWhiteSpace(); + await Assert.That(root.GetProperty("name").GetString()).IsEqualTo("MyToken"); + await Assert.That(root.TryGetProperty("id", out _)).IsTrue(); + } + + // --- List Tokens --- + + [Test] + public async Task ListTokens_ReturnsCreatedTokens() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "toklist", "toklist@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + // Create two tokens + await client.PostAsync("/1/tokens", TestHelper.JsonContent(new { name = "Token1", permissions = new[] { "shockers.use" } })); + await client.PostAsync("/1/tokens", TestHelper.JsonContent(new { name = "Token2", permissions = new[] { "shockers.use" } })); + + var response = await client.GetAsync("/1/tokens"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + await Assert.That(doc.RootElement.GetArrayLength()).IsGreaterThanOrEqualTo(2); + } + + // --- Get Token by ID --- + + [Test] + public async Task GetTokenById_Success() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "tokgetid", "tokgetid@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + // Create a token + var createResponse = await client.PostAsync("/1/tokens", TestHelper.JsonContent(new + { + name = "GetMe", + permissions = new[] { "shockers.use" } + })); + var createJson = await createResponse.Content.ReadAsStringAsync(); + using var createDoc = JsonDocument.Parse(createJson); + var tokenId = createDoc.RootElement.GetProperty("id").GetString(); + + var response = await client.GetAsync($"/1/tokens/{tokenId}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + await Assert.That(doc.RootElement.GetProperty("name").GetString()).IsEqualTo("GetMe"); + } + + [Test] + public async Task GetTokenById_Nonexistent_Returns404() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "tokget404", "tokget404@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.GetAsync($"/1/tokens/{Guid.CreateVersion7()}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + // --- Edit Token --- + + [Test] + public async Task EditToken_ChangeName_Success() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "tokedit", "tokedit@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + // Create + var createResponse = await client.PostAsync("/1/tokens", TestHelper.JsonContent(new + { + name = "OldName", + permissions = new[] { "shockers.use" } + })); + var createJson = await createResponse.Content.ReadAsStringAsync(); + using var createDoc = JsonDocument.Parse(createJson); + var tokenId = createDoc.RootElement.GetProperty("id").GetString(); + + // Edit + var editResponse = await client.PatchAsync($"/1/tokens/{tokenId}", TestHelper.JsonContent(new + { + name = "NewName", + permissions = new[] { "shockers.use", "shockers.edit" } + })); + + await Assert.That(editResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // Verify name changed + var getResponse = await client.GetAsync($"/1/tokens/{tokenId}"); + var json = await getResponse.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + await Assert.That(doc.RootElement.GetProperty("name").GetString()).IsEqualTo("NewName"); + } + + [Test] + public async Task EditToken_Nonexistent_Returns404() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "tokedit404", "tokedit404@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PatchAsync($"/1/tokens/{Guid.CreateVersion7()}", TestHelper.JsonContent(new + { + name = "Nope", + permissions = new[] { "shockers.use" } + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + // --- Delete Token --- + + [Test] + public async Task DeleteToken_Success() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "tokdel", "tokdel@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + // Create + var createResponse = await client.PostAsync("/1/tokens", TestHelper.JsonContent(new + { + name = "ToDelete", + permissions = new[] { "shockers.use" } + })); + var createJson = await createResponse.Content.ReadAsStringAsync(); + using var createDoc = JsonDocument.Parse(createJson); + var tokenId = createDoc.RootElement.GetProperty("id").GetString(); + + // Delete + var deleteResponse = await client.DeleteAsync($"/1/tokens/{tokenId}"); + await Assert.That(deleteResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // Verify it's gone + var getResponse = await client.GetAsync($"/1/tokens/{tokenId}"); + await Assert.That(getResponse.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task DeleteToken_Nonexistent_Returns404() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "tokdel404", "tokdel404@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.DeleteAsync($"/1/tokens/{Guid.CreateVersion7()}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + // --- Token Self (API Token Auth) --- + + [Test] + public async Task GetTokenSelf_WithApiToken_ReturnsInfo() + { + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, "tokself", "tokself@test.org", "SecurePassword123#"); + var (tokenId, rawToken) = await TestHelper.CreateApiTokenInDb(WebApplicationFactory, userId, "SelfToken"); + using var client = TestHelper.CreateApiTokenClient(WebApplicationFactory, rawToken); + + var response = await client.GetAsync("/1/tokens/self"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + await Assert.That(doc.RootElement.GetProperty("name").GetString()).IsEqualTo("SelfToken"); + } + + // --- API Token Auth for other endpoints --- + + [Test] + public async Task ApiTokenAuth_CanAccessDevices() + { + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, "tokauth", "tokauth@test.org", "SecurePassword123#"); + var (_, rawToken) = await TestHelper.CreateApiTokenInDb(WebApplicationFactory, userId, "AuthToken", + [Common.Models.PermissionType.Shockers_Use, Common.Models.PermissionType.Devices_Edit]); + using var client = TestHelper.CreateApiTokenClient(WebApplicationFactory, rawToken); + + var response = await client.GetAsync("/1/devices"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + // --- Unauthorized --- + + [Test] + public async Task ListTokens_Unauthenticated_Returns401() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.GetAsync("/1/tokens"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } +} diff --git a/API.IntegrationTests/Tests/UsersTests.cs b/API.IntegrationTests/Tests/UsersTests.cs new file mode 100644 index 00000000..cecb2b40 --- /dev/null +++ b/API.IntegrationTests/Tests/UsersTests.cs @@ -0,0 +1,96 @@ +using System.Net; +using System.Text.Json; +using OpenShock.API.IntegrationTests.Helpers; + +namespace OpenShock.API.IntegrationTests.Tests; + +public sealed class UsersTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory WebApplicationFactory { get; init; } + + // --- Get Self --- + + [Test] + public async Task GetSelf_ReturnsCurrentUserInfo() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "userself", "userself@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.GetAsync("/1/users/self"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var data = doc.RootElement.GetProperty("data"); + await Assert.That(data.GetProperty("name").GetString()).IsEqualTo("userself"); + await Assert.That(data.GetProperty("email").GetString()).IsEqualTo("userself@test.org"); + await Assert.That(data.TryGetProperty("id", out _)).IsTrue(); + await Assert.That(data.TryGetProperty("image", out _)).IsTrue(); + await Assert.That(data.TryGetProperty("roles", out _)).IsTrue(); + } + + [Test] + public async Task GetSelf_WithApiToken_ReturnsUserInfo() + { + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, "userselfapi", "userselfapi@test.org", "SecurePassword123#"); + var (_, rawToken) = await TestHelper.CreateApiTokenInDb(WebApplicationFactory, userId, "SelfApiToken"); + using var client = TestHelper.CreateApiTokenClient(WebApplicationFactory, rawToken); + + var response = await client.GetAsync("/1/users/self"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var data = doc.RootElement.GetProperty("data"); + await Assert.That(data.GetProperty("name").GetString()).IsEqualTo("userselfapi"); + } + + // --- Lookup by Name --- + + [Test] + public async Task LookupByName_Found_ReturnsUserInfo() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "lookupme", "lookupme@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.GetAsync("/1/users/by-name/lookupme"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + [Test] + public async Task LookupByName_NotFound_Returns404() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "lookupexist", "lookupexist@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.GetAsync("/1/users/by-name/nonexistentuser12345"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + // --- Unauthenticated --- + + [Test] + public async Task GetSelf_Unauthenticated_Returns401() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.GetAsync("/1/users/self"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + [Test] + public async Task LookupByName_Unauthenticated_Returns401() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.GetAsync("/1/users/by-name/anyone"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } +} diff --git a/API.IntegrationTests/WebApplicationFactory.cs b/API.IntegrationTests/WebApplicationFactory.cs index 3afcc8c2..69465620 100644 --- a/API.IntegrationTests/WebApplicationFactory.cs +++ b/API.IntegrationTests/WebApplicationFactory.cs @@ -1,8 +1,11 @@ -using Microsoft.AspNetCore.Hosting; +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Http; +using Microsoft.Extensions.Options; using OpenShock.API.IntegrationTests.Docker; using OpenShock.API.IntegrationTests.HttpMessageHandlers; using Serilog; @@ -15,7 +18,7 @@ public class WebApplicationFactory : WebApplicationFactory, IAsyncIniti { [ClassDataSource(Shared = SharedType.PerTestSession)] public required InMemoryDatabase PostgreSql { get; init; } - + [ClassDataSource(Shared = SharedType.PerTestSession)] public required InMemoryRedis Redis { get; init; } @@ -41,17 +44,17 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) var environmentVariables = new Dictionary { { "ASPNETCORE_UNDER_INTEGRATION_TEST", "1" }, - + { "OPENSHOCK__DB__CONN", PostgreSql.Container.GetConnectionString() }, { "OPENSHOCK__DB__SKIPMIGRATION", "false" }, { "OPENSHOCK__DB__DEBUG", "false" }, - + { "OPENSHOCK__REDIS__CONN", Redis.Container.GetConnectionString() }, - + { "OPENSHOCK__FRONTEND__BASEURL", "https://openshock.app" }, { "OPENSHOCK__FRONTEND__SHORTURL", "https://openshock.app" }, - { "OPENSHOCK__FRONTEND__COOKIEDOMAIN", "openshock.app" }, - + { "OPENSHOCK__FRONTEND__COOKIEDOMAIN", "openshock.app,localhost" }, + { "OPENSHOCK__MAIL__TYPE", "MAILJET" }, { "OPENSHOCK__MAIL__SENDER__EMAIL", "system@openshock.org" }, { "OPENSHOCK__MAIL__SENDER__NAME", "OpenShock" }, @@ -61,11 +64,11 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { "OPENSHOCK__MAIL__MAILJET__TEMPLATE__PASSWORDRESETCOMPLETE", "87654321" }, { "OPENSHOCK__MAIL__MAILJET__TEMPLATE__VERIFYEMAIL", "11223344" }, { "OPENSHOCK__MAIL__MAILJET__TEMPLATE__VERIFYEMAILCOMPLETE", "44332211" }, - + { "OPENSHOCK__TURNSTILE__ENABLED", "true" }, { "OPENSHOCK__TURNSTILE__SECRETKEY", "turnstile-secret-key" }, { "OPENSHOCK__TURNSTILE__SITEKEY", "turnstile-site-key" }, - + { "OPENSHOCK__LCG__FQDN", "de1-gateway.my-openshock-instance.net" }, { "OPENSHOCK__LCG__COUNTRYCODE", "DE" } }; @@ -82,10 +85,35 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) configuration.WriteTo.Console(LogEventLevel.Warning); }); }); - + builder.ConfigureTestServices(services => { services.AddTransient(); + + // Disable rate limiting for integration tests so auth-endpoint tests + // don't interfere with each other (10 req/min is too restrictive for test suites). + // Rate limiter behavior is covered by dedicated unit tests. + var rateLimiterDescriptors = services + .Where(d => d.ServiceType.IsGenericType + && d.ServiceType.GetGenericTypeDefinition() == typeof(IConfigureOptions<>) + && d.ServiceType.GetGenericArguments()[0] == typeof(RateLimiterOptions)) + .ToList(); + foreach (var descriptor in rateLimiterDescriptors) + { + services.Remove(descriptor); + } + + services.Configure(options => + { + options.GlobalLimiter = PartitionedRateLimiter.Create( + _ => RateLimitPartition.GetNoLimiter("test-no-limit")); + options.AddPolicy("auth", _ => + RateLimitPartition.GetNoLimiter("test-auth-no-limit")); + options.AddPolicy("token-reporting", _ => + RateLimitPartition.GetNoLimiter("test-token-reporting-no-limit")); + options.AddPolicy("shocker-logs", _ => + RateLimitPartition.GetNoLimiter("test-shocker-logs-no-limit")); + }); }); } } From d7c84f7a40f32d0416884191d833dfa074106d8d Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 11 Feb 2026 13:37:36 +0100 Subject: [PATCH 2/9] Add unit tests and SignalR/shares integration tests Unit tests (Common.Tests): - MathUtils: Haversine distance calculations - StringUtils: Truncate edge cases - PermissionUtils: IsAllowed with all ControlType/permission combos - CryptoUtils: random string length, charset, uniqueness - GravatarUtils: URL format and email hashing - HashingUtils: password hash/verify roundtrip, token hashing, algorithm detection - UsernameValidator: boundary values, special characters Integration tests (API.IntegrationTests): - SignalRUserHubTests: negotiate endpoint auth (session, API token, hub token, invalid) - ShareLinksTests: public share link CRUD, cross-user isolation Co-Authored-By: Claude Opus 4.6 --- .../API.IntegrationTests.csproj | 1 + API.IntegrationTests/Tests/ShareLinksTests.cs | 170 +++++++++++++++++ .../Tests/SignalRUserHubTests.cs | 100 ++++++++++ Common.Tests/Utils/CryptoUtilsTests.cs | 67 +++++++ Common.Tests/Utils/GravatarUtilsTests.cs | 49 +++++ Common.Tests/Utils/HashingUtilsTests.cs | 173 +++++++++++++++++- Common.Tests/Utils/MathUtilsTests.cs | 65 +++++++ Common.Tests/Utils/PermissionUtilsTests.cs | 156 ++++++++++++++++ Common.Tests/Utils/StringUtilsTests.cs | 48 +++++ .../Validation/UsernameValidatorTests.cs | 92 ++++++++++ Directory.Packages.props | 1 + 11 files changed, 918 insertions(+), 4 deletions(-) create mode 100644 API.IntegrationTests/Tests/ShareLinksTests.cs create mode 100644 API.IntegrationTests/Tests/SignalRUserHubTests.cs create mode 100644 Common.Tests/Utils/CryptoUtilsTests.cs create mode 100644 Common.Tests/Utils/GravatarUtilsTests.cs create mode 100644 Common.Tests/Utils/MathUtilsTests.cs create mode 100644 Common.Tests/Utils/PermissionUtilsTests.cs create mode 100644 Common.Tests/Utils/StringUtilsTests.cs diff --git a/API.IntegrationTests/API.IntegrationTests.csproj b/API.IntegrationTests/API.IntegrationTests.csproj index 61f5d08d..383a2120 100644 --- a/API.IntegrationTests/API.IntegrationTests.csproj +++ b/API.IntegrationTests/API.IntegrationTests.csproj @@ -11,6 +11,7 @@ + diff --git a/API.IntegrationTests/Tests/ShareLinksTests.cs b/API.IntegrationTests/Tests/ShareLinksTests.cs new file mode 100644 index 00000000..c1bb57dd --- /dev/null +++ b/API.IntegrationTests/Tests/ShareLinksTests.cs @@ -0,0 +1,170 @@ +using System.Net; +using System.Text.Json; +using OpenShock.API.IntegrationTests.Helpers; + +namespace OpenShock.API.IntegrationTests.Tests; + +public sealed class ShareLinksTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory WebApplicationFactory { get; init; } + + // --- List share links --- + + [Test] + public async Task ListShareLinks_Empty_ReturnsEmptyArray() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "sharelinklist", "sharelinklist@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.GetAsync("/1/shares/links"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var data = doc.RootElement.GetProperty("data"); + await Assert.That(data.GetArrayLength()).IsEqualTo(0); + } + + // --- Create share link --- + + [Test] + public async Task CreateShareLink_Success() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "sharelinkcreat", "sharelinkcreat@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PostAsync("/1/shares/links", TestHelper.JsonContent(new + { + name = "My Public Share" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var data = doc.RootElement.GetProperty("data"); + await Assert.That(Guid.TryParse(data.GetString(), out _)).IsTrue(); + } + + [Test] + public async Task CreateShareLink_ThenListContainsIt() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "sharelinkcrls", "sharelinkcrls@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + await client.PostAsync("/1/shares/links", TestHelper.JsonContent(new + { + name = "Test Share" + })); + + var response = await client.GetAsync("/1/shares/links"); + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + var data = doc.RootElement.GetProperty("data"); + await Assert.That(data.GetArrayLength()).IsGreaterThanOrEqualTo(1); + } + + // --- Delete share link --- + + [Test] + public async Task DeleteShareLink_Success() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "sharelinkdel", "sharelinkdel@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var createResp = await client.PostAsync("/1/shares/links", TestHelper.JsonContent(new + { + name = "To Delete" + })); + var createJson = await createResp.Content.ReadAsStringAsync(); + using var createDoc = JsonDocument.Parse(createJson); + var shareId = createDoc.RootElement.GetProperty("data").GetString(); + + var response = await client.DeleteAsync($"/1/shares/links/{shareId}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + [Test] + public async Task DeleteShareLink_NotFound() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "sharelinkdnf", "sharelinkdnf@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.DeleteAsync($"/1/shares/links/{Guid.NewGuid()}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + // --- Add shocker to share link --- + + [Test] + public async Task AddShockerToShareLink_Success() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "sharelinkshock", "sharelinkshock@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + // Create device and shocker via DB helpers + var (deviceId, _) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, user.Id, "ShareDevice"); + + var shockerResp = await client.PostAsync("/1/shockers", TestHelper.JsonContent(new + { + name = "ShareShocker", + rfId = 12345, + model = 0, + device = deviceId + })); + await Assert.That(shockerResp.StatusCode).IsEqualTo(HttpStatusCode.Created); + var shockerJson = await shockerResp.Content.ReadAsStringAsync(); + using var shockerDoc = JsonDocument.Parse(shockerJson); + var shockerId = shockerDoc.RootElement.GetProperty("data").GetString(); + + // Create public share + var createResp = await client.PostAsync("/1/shares/links", TestHelper.JsonContent(new + { + name = "Share With Shocker" + })); + await Assert.That(createResp.StatusCode).IsEqualTo(HttpStatusCode.OK); + var createJson = await createResp.Content.ReadAsStringAsync(); + using var createDoc = JsonDocument.Parse(createJson); + var shareId = createDoc.RootElement.GetProperty("data").GetString(); + + // Add shocker to share + var response = await client.PostAsync($"/1/shares/links/{shareId}/{shockerId}", null); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + // --- Unauthenticated access --- + + [Test] + public async Task ListShareLinks_Unauthenticated_Returns401() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.GetAsync("/1/shares/links"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + // --- Cross-user isolation --- + + [Test] + public async Task DeleteShareLink_OtherUser_Returns404() + { + var user1 = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "sharelinkown", "sharelinkown@test.org", "SecurePassword123#"); + var user2 = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "sharelinkoth", "sharelinkoth@test.org", "SecurePassword123#"); + + using var client1 = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user1.SessionToken); + using var client2 = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user2.SessionToken); + + var createResp = await client1.PostAsync("/1/shares/links", TestHelper.JsonContent(new + { + name = "User1's Share" + })); + var createJson = await createResp.Content.ReadAsStringAsync(); + using var createDoc = JsonDocument.Parse(createJson); + var shareId = createDoc.RootElement.GetProperty("data").GetString(); + + // User2 tries to delete user1's share + var response = await client2.DeleteAsync($"/1/shares/links/{shareId}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } +} diff --git a/API.IntegrationTests/Tests/SignalRUserHubTests.cs b/API.IntegrationTests/Tests/SignalRUserHubTests.cs new file mode 100644 index 00000000..2a1b1534 --- /dev/null +++ b/API.IntegrationTests/Tests/SignalRUserHubTests.cs @@ -0,0 +1,100 @@ +using System.Net; +using OpenShock.API.IntegrationTests.Helpers; +using OpenShock.Common.Constants; + +namespace OpenShock.API.IntegrationTests.Tests; + +public sealed class SignalRUserHubTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory WebApplicationFactory { get; init; } + + // --- Negotiate endpoint tests (HTTP-based, works with TestServer) --- + + [Test] + public async Task Negotiate_WithValidSession_ReturnsOk() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "hubneg", "hubneg@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PostAsync("/1/hubs/user/negotiate?negotiateVersion=1", null); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + [Test] + public async Task Negotiate_WithApiToken_ReturnsOk() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "hubnegapi", "hubnegapi@test.org", "SecurePassword123#"); + var (_, rawToken) = await TestHelper.CreateApiTokenInDb(WebApplicationFactory, user.Id, "hub-negotiate-token"); + using var client = TestHelper.CreateApiTokenClient(WebApplicationFactory, rawToken); + + var response = await client.PostAsync("/1/hubs/user/negotiate?negotiateVersion=1", null); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + [Test] + public async Task Negotiate_WithoutAuth_Returns401() + { + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.PostAsync("/1/hubs/user/negotiate?negotiateVersion=1", null); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + [Test] + public async Task Negotiate_WithInvalidSession_Returns401() + { + using var client = WebApplicationFactory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + client.DefaultRequestHeaders.Add("Cookie", $"{AuthConstants.UserSessionCookieName}=invalid-session-token"); + + var response = await client.PostAsync("/1/hubs/user/negotiate?negotiateVersion=1", null); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + [Test] + public async Task Negotiate_WithHubToken_Returns401() + { + // Hub/device tokens should NOT work on user hub + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "hubneghub", "hubneghub@test.org", "SecurePassword123#"); + var (_, hubToken) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, user.Id, "NegTestDevice"); + using var client = TestHelper.CreateHubTokenClient(WebApplicationFactory, hubToken); + + var response = await client.PostAsync("/1/hubs/user/negotiate?negotiateVersion=1", null); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + // --- Negotiate response content --- + + [Test] + public async Task Negotiate_ReturnsTransportInfo() + { + var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "hubneginfo", "hubneginfo@test.org", "SecurePassword123#"); + using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken); + + var response = await client.PostAsync("/1/hubs/user/negotiate?negotiateVersion=1", null); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = System.Text.Json.JsonDocument.Parse(json); + // Negotiate response should contain connectionId and available transports + await Assert.That(doc.RootElement.TryGetProperty("connectionId", out _)).IsTrue(); + await Assert.That(doc.RootElement.TryGetProperty("availableTransports", out _)).IsTrue(); + } + + // --- Public share hub --- + + [Test] + public async Task PublicShareHub_Negotiate_WithoutAuth_ReturnsOk() + { + // PublicShareHub doesn't require auth at the negotiate level + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.PostAsync($"/1/hubs/share/link/{Guid.NewGuid()}/negotiate?negotiateVersion=1", null); + // Should return OK (negotiate doesn't check auth or share existence) + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } +} diff --git a/Common.Tests/Utils/CryptoUtilsTests.cs b/Common.Tests/Utils/CryptoUtilsTests.cs new file mode 100644 index 00000000..f4073f0a --- /dev/null +++ b/Common.Tests/Utils/CryptoUtilsTests.cs @@ -0,0 +1,67 @@ +using OpenShock.Common.Utils; + +namespace OpenShock.Common.Tests.Utils; + +public class CryptoUtilsTests +{ + [Test] + [Arguments(1)] + [Arguments(10)] + [Arguments(64)] + [Arguments(256)] + public async Task RandomAlphaNumericString_CorrectLength(int length) + { + var result = CryptoUtils.RandomAlphaNumericString(length); + await Assert.That(result.Length).IsEqualTo(length); + } + + [Test] + public async Task RandomAlphaNumericString_OnlyAlphaNumericChars() + { + var result = CryptoUtils.RandomAlphaNumericString(1000); + await Assert.That(result.All(c => char.IsLetterOrDigit(c))).IsTrue(); + } + + [Test] + public async Task RandomAlphaNumericString_ContainsVariety() + { + // With 1000 chars, should have both letters and digits + var result = CryptoUtils.RandomAlphaNumericString(1000); + await Assert.That(result.Any(char.IsLetter)).IsTrue(); + await Assert.That(result.Any(char.IsDigit)).IsTrue(); + } + + [Test] + public async Task RandomAlphaNumericString_TwoCallsProduceDifferentResults() + { + var a = CryptoUtils.RandomAlphaNumericString(32); + var b = CryptoUtils.RandomAlphaNumericString(32); + await Assert.That(a).IsNotEqualTo(b); + } + + [Test] + [Arguments(1)] + [Arguments(6)] + [Arguments(10)] + [Arguments(100)] + public async Task RandomNumericString_CorrectLength(int length) + { + var result = CryptoUtils.RandomNumericString(length); + await Assert.That(result.Length).IsEqualTo(length); + } + + [Test] + public async Task RandomNumericString_OnlyDigits() + { + var result = CryptoUtils.RandomNumericString(1000); + await Assert.That(result.All(char.IsDigit)).IsTrue(); + } + + [Test] + public async Task RandomNumericString_TwoCallsProduceDifferentResults() + { + var a = CryptoUtils.RandomNumericString(32); + var b = CryptoUtils.RandomNumericString(32); + await Assert.That(a).IsNotEqualTo(b); + } +} diff --git a/Common.Tests/Utils/GravatarUtilsTests.cs b/Common.Tests/Utils/GravatarUtilsTests.cs new file mode 100644 index 00000000..4c670c66 --- /dev/null +++ b/Common.Tests/Utils/GravatarUtilsTests.cs @@ -0,0 +1,49 @@ +using OpenShock.Common.Utils; + +namespace OpenShock.Common.Tests.Utils; + +public class GravatarUtilsTests +{ + [Test] + public async Task GuestImageUrl_IsGravatarUrl() + { + await Assert.That(GravatarUtils.GuestImageUrl.Host).IsEqualTo("www.gravatar.com"); + } + + [Test] + public async Task GuestImageUrl_UsesZeroHash() + { + await Assert.That(GravatarUtils.GuestImageUrl.AbsolutePath).IsEqualTo("/avatar/0"); + } + + [Test] + public async Task GetUserImageUrl_IsGravatarUrl() + { + var url = GravatarUtils.GetUserImageUrl("test@example.com"); + await Assert.That(url.Host).IsEqualTo("www.gravatar.com"); + } + + [Test] + public async Task GetUserImageUrl_ContainsEmailHash() + { + var email = "test@example.com"; + var expectedHash = HashingUtils.HashSha256(email); + var url = GravatarUtils.GetUserImageUrl(email); + await Assert.That(url.AbsolutePath).IsEqualTo($"/avatar/{expectedHash}"); + } + + [Test] + public async Task GetUserImageUrl_ContainsDefaultImageParam() + { + var url = GravatarUtils.GetUserImageUrl("test@example.com"); + await Assert.That(url.Query).Contains("d="); + } + + [Test] + public async Task GetUserImageUrl_DifferentEmails_ProduceDifferentUrls() + { + var url1 = GravatarUtils.GetUserImageUrl("a@example.com"); + var url2 = GravatarUtils.GetUserImageUrl("b@example.com"); + await Assert.That(url1).IsNotEqualTo(url2); + } +} diff --git a/Common.Tests/Utils/HashingUtilsTests.cs b/Common.Tests/Utils/HashingUtilsTests.cs index 71dd091d..698a2a0e 100644 --- a/Common.Tests/Utils/HashingUtilsTests.cs +++ b/Common.Tests/Utils/HashingUtilsTests.cs @@ -1,18 +1,183 @@ -using OpenShock.Common.Utils; +using OpenShock.Common.Models; +using OpenShock.Common.Utils; namespace OpenShock.Common.Tests.Utils; public class HashingUtilsTests { + // --- HashSha256 --- + [Test] [Arguments("test", "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")] [Arguments("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in", "2fac5f5f1d048a84fbb75c389f4596e05023ac17da4fcf45a5954d2d9a394301")] public async Task HashSha256(string str, string expectedHash) { - // Act var result = HashingUtils.HashSha256(str); - - // Assert await Assert.That(result).IsEqualTo(expectedHash); } + + [Test] + public async Task HashSha256_EmptyString_ReturnsKnownHash() + { + // SHA-256 of empty string + var result = HashingUtils.HashSha256(""); + await Assert.That(result).IsEqualTo("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + } + + [Test] + public async Task HashSha256_ReturnsLowercaseHex() + { + var result = HashingUtils.HashSha256("test"); + await Assert.That(result).IsEqualTo(result.ToLowerInvariant()); + } + + [Test] + public async Task HashSha256_Returns64CharHex() + { + var result = HashingUtils.HashSha256("anything"); + await Assert.That(result.Length).IsEqualTo(64); + } + + // --- HashPassword / VerifyPassword --- + + [Test] + public async Task HashPassword_VerifyPassword_Roundtrip() + { + var password = "MySecureP@ssw0rd!"; + var hash = HashingUtils.HashPassword(password); + + var result = HashingUtils.VerifyPassword(password, hash); + await Assert.That(result.Verified).IsTrue(); + await Assert.That(result.NeedsRehash).IsFalse(); + } + + [Test] + public async Task HashPassword_StartsWithBcryptPrefix() + { + var hash = HashingUtils.HashPassword("test"); + await Assert.That(hash.StartsWith("bcrypt:")).IsTrue(); + } + + [Test] + public async Task VerifyPassword_WrongPassword_ReturnsFalse() + { + var hash = HashingUtils.HashPassword("correct"); + var result = HashingUtils.VerifyPassword("wrong", hash); + await Assert.That(result.Verified).IsFalse(); + } + + [Test] + public async Task VerifyPassword_InvalidHashFormat_ReturnsFalse() + { + var result = HashingUtils.VerifyPassword("test", "notahash"); + await Assert.That(result.Verified).IsFalse(); + } + + [Test] + public async Task VerifyPassword_EmptyHash_ReturnsFalse() + { + var result = HashingUtils.VerifyPassword("test", ""); + await Assert.That(result.Verified).IsFalse(); + } + + [Test] + public async Task HashPassword_DifferentPasswords_DifferentHashes() + { + var hash1 = HashingUtils.HashPassword("password1"); + var hash2 = HashingUtils.HashPassword("password2"); + await Assert.That(hash1).IsNotEqualTo(hash2); + } + + [Test] + public async Task HashPassword_SamePassword_DifferentSalts() + { + var hash1 = HashingUtils.HashPassword("same"); + var hash2 = HashingUtils.HashPassword("same"); + await Assert.That(hash1).IsNotEqualTo(hash2); + } + + // --- GetPasswordHashingAlgorithm --- + + [Test] + public async Task GetPasswordHashingAlgorithm_BCryptPrefix_ReturnsBCrypt() + { + var result = HashingUtils.GetPasswordHashingAlgorithm("bcrypt:$2a$11$..."); + await Assert.That(result).IsEqualTo(PasswordHashingAlgorithm.BCrypt); + } + + [Test] + public async Task GetPasswordHashingAlgorithm_Pbkdf2Prefix_ReturnsPBKDF2() + { + var result = HashingUtils.GetPasswordHashingAlgorithm("pbkdf2:somehash"); + await Assert.That(result).IsEqualTo(PasswordHashingAlgorithm.PBKDF2); + } + + [Test] + public async Task GetPasswordHashingAlgorithm_UnknownPrefix_ReturnsUnknown() + { + var result = HashingUtils.GetPasswordHashingAlgorithm("argon2:hash"); + await Assert.That(result).IsEqualTo(PasswordHashingAlgorithm.Unknown); + } + + [Test] + public async Task GetPasswordHashingAlgorithm_NoColon_ReturnsUnknown() + { + var result = HashingUtils.GetPasswordHashingAlgorithm("nocolonhere"); + await Assert.That(result).IsEqualTo(PasswordHashingAlgorithm.Unknown); + } + + [Test] + public async Task GetPasswordHashingAlgorithm_Empty_ReturnsUnknown() + { + var result = HashingUtils.GetPasswordHashingAlgorithm(""); + await Assert.That(result).IsEqualTo(PasswordHashingAlgorithm.Unknown); + } + + // --- HashToken / VerifyToken --- + + [Test] + public async Task HashToken_ReturnsSha256OfToken() + { + var token = "my-api-token"; + var hash = HashingUtils.HashToken(token); + var expected = HashingUtils.HashSha256(token); + await Assert.That(hash).IsEqualTo(expected); + } + + [Test] + public async Task VerifyToken_CorrectToken_Verified() + { + var token = "test-token-123"; + var hash = HashingUtils.HashToken(token); + var result = HashingUtils.VerifyToken(token, hash); + await Assert.That(result.Verified).IsTrue(); + await Assert.That(result.NeedsRehash).IsFalse(); + } + + [Test] + public async Task VerifyToken_WrongToken_NotVerified() + { + var hash = HashingUtils.HashToken("correct-token"); + var result = HashingUtils.VerifyToken("wrong-token", hash); + await Assert.That(result.Verified).IsFalse(); + } + + [Test] + public async Task VerifyToken_EmptyToken_NotVerified() + { + var hash = HashingUtils.HashToken("something"); + var result = HashingUtils.VerifyToken("", hash); + await Assert.That(result.Verified).IsFalse(); + } + + [Test] + public async Task VerifyToken_LegacyBcryptHash_NeedsRehash() + { + // Legacy tokens stored with bcrypt (contains '$' in hash) + var token = "legacy-token"; + var bcryptHash = HashingUtils.HashPassword(token); + var result = HashingUtils.VerifyToken(token, bcryptHash); + // Whether verified depends on bcrypt, but NeedsRehash should be true + await Assert.That(result.NeedsRehash).IsTrue(); + } } \ No newline at end of file diff --git a/Common.Tests/Utils/MathUtilsTests.cs b/Common.Tests/Utils/MathUtilsTests.cs new file mode 100644 index 00000000..73497fc0 --- /dev/null +++ b/Common.Tests/Utils/MathUtilsTests.cs @@ -0,0 +1,65 @@ +using OpenShock.Common.Utils; + +namespace OpenShock.Common.Tests.Utils; + +public class MathUtilsTests +{ + [Test] + public async Task SamePoint_ReturnsZero() + { + var result = MathUtils.CalculateHaversineDistance(0f, 0f, 0f, 0f); + await Assert.That(result).IsEqualTo(0f); + } + + [Test] + public async Task SameCoordinates_ReturnsZero() + { + var result = MathUtils.CalculateHaversineDistance(52.52f, 13.405f, 52.52f, 13.405f); + await Assert.That(result).IsEqualTo(0f); + } + + [Test] + public async Task NewYork_To_London_ApproximatelyCorrect() + { + // NYC: 40.7128, -74.0060 London: 51.5074, -0.1278 + // Expected: ~5570 km + var result = MathUtils.CalculateHaversineDistance(40.7128f, -74.006f, 51.5074f, -0.1278f); + await Assert.That(result).IsGreaterThan(5500f); + await Assert.That(result).IsLessThan(5650f); + } + + [Test] + public async Task NorthPole_To_SouthPole_ApproximatelyHalfCircumference() + { + // ~20015 km + var result = MathUtils.CalculateHaversineDistance(90f, 0f, -90f, 0f); + await Assert.That(result).IsGreaterThan(19900f); + await Assert.That(result).IsLessThan(20100f); + } + + [Test] + public async Task Equator_QuarterWayAround_ApproximatelyCorrect() + { + // 0,0 to 0,90 — quarter circumference at equator ~10008 km + var result = MathUtils.CalculateHaversineDistance(0f, 0f, 0f, 90f); + await Assert.That(result).IsGreaterThan(9900f); + await Assert.That(result).IsLessThan(10100f); + } + + [Test] + public async Task IsSymmetric() + { + var ab = MathUtils.CalculateHaversineDistance(48.8566f, 2.3522f, 35.6762f, 139.6503f); + var ba = MathUtils.CalculateHaversineDistance(35.6762f, 139.6503f, 48.8566f, 2.3522f); + await Assert.That(MathF.Abs(ab - ba)).IsLessThan(0.01f); + } + + [Test] + public async Task AntipodalPoints_ApproximatelyHalfCircumference() + { + // 0,0 to 0,180 — half circumference ~20015 km + var result = MathUtils.CalculateHaversineDistance(0f, 0f, 0f, 180f); + await Assert.That(result).IsGreaterThan(19900f); + await Assert.That(result).IsLessThan(20100f); + } +} diff --git a/Common.Tests/Utils/PermissionUtilsTests.cs b/Common.Tests/Utils/PermissionUtilsTests.cs new file mode 100644 index 00000000..de808de2 --- /dev/null +++ b/Common.Tests/Utils/PermissionUtilsTests.cs @@ -0,0 +1,156 @@ +using OpenShock.Common.Models; +using OpenShock.Common.Utils; + +namespace OpenShock.Common.Tests.Utils; + +public class PermissionUtilsTests +{ + [Test] + public async Task NullPerms_AlwaysAllowed() + { + await Assert.That(PermissionUtils.IsAllowed(ControlType.Shock, false, null)).IsTrue(); + await Assert.That(PermissionUtils.IsAllowed(ControlType.Vibrate, false, null)).IsTrue(); + await Assert.That(PermissionUtils.IsAllowed(ControlType.Sound, false, null)).IsTrue(); + await Assert.That(PermissionUtils.IsAllowed(ControlType.Stop, false, null)).IsTrue(); + await Assert.That(PermissionUtils.IsAllowed(ControlType.Shock, true, null)).IsTrue(); + } + + [Test] + public async Task Shock_Allowed_WhenShockPermTrue() + { + var perms = new SharePermsAndLimits + { + Shock = true, Vibrate = false, Sound = false, Live = false, + Duration = null, Intensity = null + }; + await Assert.That(PermissionUtils.IsAllowed(ControlType.Shock, false, perms)).IsTrue(); + } + + [Test] + public async Task Shock_Denied_WhenShockPermFalse() + { + var perms = new SharePermsAndLimits + { + Shock = false, Vibrate = true, Sound = true, Live = true, + Duration = null, Intensity = null + }; + await Assert.That(PermissionUtils.IsAllowed(ControlType.Shock, false, perms)).IsFalse(); + } + + [Test] + public async Task Vibrate_Allowed_WhenVibratePermTrue() + { + var perms = new SharePermsAndLimits + { + Shock = false, Vibrate = true, Sound = false, Live = false, + Duration = null, Intensity = null + }; + await Assert.That(PermissionUtils.IsAllowed(ControlType.Vibrate, false, perms)).IsTrue(); + } + + [Test] + public async Task Vibrate_Denied_WhenVibratePermFalse() + { + var perms = new SharePermsAndLimits + { + Shock = true, Vibrate = false, Sound = true, Live = true, + Duration = null, Intensity = null + }; + await Assert.That(PermissionUtils.IsAllowed(ControlType.Vibrate, false, perms)).IsFalse(); + } + + [Test] + public async Task Sound_Allowed_WhenSoundPermTrue() + { + var perms = new SharePermsAndLimits + { + Shock = false, Vibrate = false, Sound = true, Live = false, + Duration = null, Intensity = null + }; + await Assert.That(PermissionUtils.IsAllowed(ControlType.Sound, false, perms)).IsTrue(); + } + + [Test] + public async Task Sound_Denied_WhenSoundPermFalse() + { + var perms = new SharePermsAndLimits + { + Shock = true, Vibrate = true, Sound = false, Live = true, + Duration = null, Intensity = null + }; + await Assert.That(PermissionUtils.IsAllowed(ControlType.Sound, false, perms)).IsFalse(); + } + + [Test] + public async Task Stop_Allowed_WhenAnyPermTrue() + { + var shockOnly = new SharePermsAndLimits + { + Shock = true, Vibrate = false, Sound = false, Live = false, + Duration = null, Intensity = null + }; + await Assert.That(PermissionUtils.IsAllowed(ControlType.Stop, false, shockOnly)).IsTrue(); + + var vibrateOnly = new SharePermsAndLimits + { + Shock = false, Vibrate = true, Sound = false, Live = false, + Duration = null, Intensity = null + }; + await Assert.That(PermissionUtils.IsAllowed(ControlType.Stop, false, vibrateOnly)).IsTrue(); + + var soundOnly = new SharePermsAndLimits + { + Shock = false, Vibrate = false, Sound = true, Live = false, + Duration = null, Intensity = null + }; + await Assert.That(PermissionUtils.IsAllowed(ControlType.Stop, false, soundOnly)).IsTrue(); + } + + [Test] + public async Task Stop_Denied_WhenAllPermsFalse() + { + var perms = new SharePermsAndLimits + { + Shock = false, Vibrate = false, Sound = false, Live = true, + Duration = null, Intensity = null + }; + await Assert.That(PermissionUtils.IsAllowed(ControlType.Stop, false, perms)).IsFalse(); + } + + [Test] + public async Task Live_Denied_WhenLivePermFalse() + { + var perms = new SharePermsAndLimits + { + Shock = true, Vibrate = true, Sound = true, Live = false, + Duration = null, Intensity = null + }; + await Assert.That(PermissionUtils.IsAllowed(ControlType.Shock, true, perms)).IsFalse(); + await Assert.That(PermissionUtils.IsAllowed(ControlType.Vibrate, true, perms)).IsFalse(); + await Assert.That(PermissionUtils.IsAllowed(ControlType.Sound, true, perms)).IsFalse(); + } + + [Test] + public async Task Live_Allowed_WhenLivePermTrue() + { + var perms = new SharePermsAndLimits + { + Shock = true, Vibrate = true, Sound = true, Live = true, + Duration = null, Intensity = null + }; + await Assert.That(PermissionUtils.IsAllowed(ControlType.Shock, true, perms)).IsTrue(); + await Assert.That(PermissionUtils.IsAllowed(ControlType.Vibrate, true, perms)).IsTrue(); + await Assert.That(PermissionUtils.IsAllowed(ControlType.Sound, true, perms)).IsTrue(); + } + + [Test] + public async Task UnknownControlType_ReturnsFalse() + { + var perms = new SharePermsAndLimits + { + Shock = true, Vibrate = true, Sound = true, Live = true, + Duration = null, Intensity = null + }; + await Assert.That(PermissionUtils.IsAllowed((ControlType)999, false, perms)).IsFalse(); + } +} diff --git a/Common.Tests/Utils/StringUtilsTests.cs b/Common.Tests/Utils/StringUtilsTests.cs new file mode 100644 index 00000000..d8b8ad03 --- /dev/null +++ b/Common.Tests/Utils/StringUtilsTests.cs @@ -0,0 +1,48 @@ +using OpenShock.Common.Utils; + +namespace OpenShock.Common.Tests.Utils; + +public class StringUtilsTests +{ + [Test] + public async Task Truncate_ShorterThanMax_ReturnsOriginal() + { + var result = "hello".Truncate(10); + await Assert.That(result).IsEqualTo("hello"); + } + + [Test] + public async Task Truncate_ExactLength_ReturnsOriginal() + { + var result = "hello".Truncate(5); + await Assert.That(result).IsEqualTo("hello"); + } + + [Test] + public async Task Truncate_LongerThanMax_Truncates() + { + var result = "hello world".Truncate(5); + await Assert.That(result).IsEqualTo("hello"); + } + + [Test] + public async Task Truncate_EmptyString_ReturnsEmpty() + { + var result = "".Truncate(5); + await Assert.That(result).IsEqualTo(""); + } + + [Test] + public async Task Truncate_MaxZero_ReturnsEmpty() + { + var result = "hello".Truncate(0); + await Assert.That(result).IsEqualTo(""); + } + + [Test] + public async Task Truncate_MaxOne_ReturnsSingleChar() + { + var result = "hello".Truncate(1); + await Assert.That(result).IsEqualTo("h"); + } +} diff --git a/Common.Tests/Validation/UsernameValidatorTests.cs b/Common.Tests/Validation/UsernameValidatorTests.cs index 88fe721d..d91dc7e6 100644 --- a/Common.Tests/Validation/UsernameValidatorTests.cs +++ b/Common.Tests/Validation/UsernameValidatorTests.cs @@ -100,4 +100,96 @@ public async Task Validate_ContainsObnoxiousCharacters_ReturnsError() await Assert.That(result.IsT1).IsTrue(); await Assert.That(result.AsT1.Type).IsEqualTo(UsernameErrorType.ObnoxiousCharacters); } + + // --- Boundary value tests --- + + [Test] + public async Task Validate_ExactMinLength_ReturnsSuccess() + { + // HardLimits.UsernameMinLength = 3 + var result = UsernameValidator.Validate("abc"); + await Assert.That(result.IsT0).IsTrue(); + } + + [Test] + public async Task Validate_OneBelowMinLength_ReturnsTooShort() + { + // 2 chars = below min of 3 + var result = UsernameValidator.Validate("ab"); + await Assert.That(result.IsT1).IsTrue(); + await Assert.That(result.AsT1.Type).IsEqualTo(UsernameErrorType.TooShort); + } + + [Test] + public async Task Validate_ExactMaxLength_ReturnsSuccess() + { + // HardLimits.UsernameMaxLength = 32 + var result = UsernameValidator.Validate(new string('a', 32)); + await Assert.That(result.IsT0).IsTrue(); + } + + [Test] + public async Task Validate_OneAboveMaxLength_ReturnsTooLong() + { + // 33 chars = above max of 32 + var result = UsernameValidator.Validate(new string('a', 33)); + await Assert.That(result.IsT1).IsTrue(); + await Assert.That(result.AsT1.Type).IsEqualTo(UsernameErrorType.TooLong); + } + + [Test] + public async Task Validate_WithHyphensAndUnderscores_ReturnsSuccess() + { + var result = UsernameValidator.Validate("test-user_123"); + await Assert.That(result.IsT0).IsTrue(); + } + + [Test] + public async Task Validate_WithDots_ReturnsSuccess() + { + var result = UsernameValidator.Validate("test.user"); + await Assert.That(result.IsT0).IsTrue(); + } + + [Test] + public async Task Validate_WithMiddleSpaces_ReturnsSuccess() + { + // Middle spaces are allowed, only leading/trailing are rejected + var result = UsernameValidator.Validate("test user"); + await Assert.That(result.IsT0).IsTrue(); + } + + [Test] + public async Task Validate_TabAtStart_ReturnsWhitespaceError() + { + var result = UsernameValidator.Validate("\tTestUser"); + await Assert.That(result.IsT1).IsTrue(); + await Assert.That(result.AsT1.Type).IsEqualTo(UsernameErrorType.StartOrEndWithWhitespace); + } + + [Test] + public async Task Validate_AtSignInMiddle_ReturnsResembleEmail() + { + var result = UsernameValidator.Validate("user@name"); + await Assert.That(result.IsT1).IsTrue(); + await Assert.That(result.AsT1.Type).IsEqualTo(UsernameErrorType.ResembleEmail); + } + + [Test] + public async Task Validate_ZeroWidthJoiner_ReturnsObnoxious() + { + // Zero-width joiner U+200D + var result = UsernameValidator.Validate("test\u200Duser"); + await Assert.That(result.IsT1).IsTrue(); + await Assert.That(result.AsT1.Type).IsEqualTo(UsernameErrorType.ObnoxiousCharacters); + } + + [Test] + public async Task Validate_RightToLeftOverride_ReturnsObnoxious() + { + // U+202E Right-to-left override + var result = UsernameValidator.Validate("test\u202Euser"); + await Assert.That(result.IsT1).IsTrue(); + await Assert.That(result.AsT1.Type).IsEqualTo(UsernameErrorType.ObnoxiousCharacters); + } } diff --git a/Directory.Packages.props b/Directory.Packages.props index 7f7a3b25..bcddd936 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,6 +18,7 @@ + From bf17988fdb1f829d3c1ce547d0e0e193855b790b Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 11 Feb 2026 13:52:50 +0100 Subject: [PATCH 3/9] Fix integration test flakiness from BCrypt thread pool starvation Cache BCrypt password hashes in TestHelper so each unique password is only hashed once instead of ~100+ times. Add 30s per-test timeout as a safety net. Co-Authored-By: Claude Opus 4.6 --- API.IntegrationTests/AssemblyAttributes.cs | 5 +++++ API.IntegrationTests/Helpers/TestHelper.cs | 11 ++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 API.IntegrationTests/AssemblyAttributes.cs diff --git a/API.IntegrationTests/AssemblyAttributes.cs b/API.IntegrationTests/AssemblyAttributes.cs new file mode 100644 index 00000000..d3aa9ca6 --- /dev/null +++ b/API.IntegrationTests/AssemblyAttributes.cs @@ -0,0 +1,5 @@ +using TUnit.Core; + +// Fail individual tests after 30 seconds to prevent thread pool starvation +// from causing long hangs (BCrypt is synchronous and CPU-bound). +[assembly: Timeout(30_000)] diff --git a/API.IntegrationTests/Helpers/TestHelper.cs b/API.IntegrationTests/Helpers/TestHelper.cs index 834eb4bf..8591f669 100644 --- a/API.IntegrationTests/Helpers/TestHelper.cs +++ b/API.IntegrationTests/Helpers/TestHelper.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Net; using System.Text; using System.Text.Json; @@ -16,6 +17,13 @@ public static class TestHelper PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + /// + /// 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. + /// + private static readonly ConcurrentDictionary PasswordHashCache = new(); + /// /// Creates a user directly in DB, creates a session via ISessionService, returns auth info. /// This bypasses signup/login endpoints entirely to avoid rate limiting. @@ -93,12 +101,13 @@ public static async Task CreateUserInDb( var db = scope.ServiceProvider.GetRequiredService(); var userId = Guid.CreateVersion7(); + var hash = PasswordHashCache.GetOrAdd(password, HashingUtils.HashPassword); db.Users.Add(new User { Id = userId, Name = username, Email = email, - PasswordHash = HashingUtils.HashPassword(password), + PasswordHash = hash, ActivatedAt = activated ? DateTime.UtcNow : null }); await db.SaveChangesAsync(); From cc5a84a916d24f5ac28d4bdb4b95892eaa51b79b Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 11 Feb 2026 14:01:22 +0100 Subject: [PATCH 4/9] Fix integration test flakiness with parallelism limiter Limit parallel test execution to ProcessorCount*2 (min 8) to prevent thread pool starvation from synchronous BCrypt in login/signup endpoints. Also bump per-test timeout to 60s and add coverage gitignore entries. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 6 +++++- API.IntegrationTests/AssemblyAttributes.cs | 18 +++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 4430e4d2..64e53b6b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,8 @@ Dev/dragonfly Dev/postgres # Claude Code -.claude/ \ No newline at end of file +.claude/ + +# Code coverage +TestResults/ +*.cobertura.xml \ No newline at end of file diff --git a/API.IntegrationTests/AssemblyAttributes.cs b/API.IntegrationTests/AssemblyAttributes.cs index d3aa9ca6..3a2a1f2c 100644 --- a/API.IntegrationTests/AssemblyAttributes.cs +++ b/API.IntegrationTests/AssemblyAttributes.cs @@ -1,5 +1,17 @@ using TUnit.Core; +using TUnit.Core.Interfaces; -// Fail individual tests after 30 seconds to prevent thread pool starvation -// from causing long hangs (BCrypt is synchronous and CPU-bound). -[assembly: Timeout(30_000)] +// 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] + +namespace OpenShock.API.IntegrationTests; + +public record CiSafeParallelLimit : IParallelLimit +{ + public int Limit => Math.Max(Environment.ProcessorCount * 2, 8); +} From 6fa001a94ffc3e5eb887aef5fbd1d562ecf77c8a Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 11 Feb 2026 14:07:47 +0100 Subject: [PATCH 5/9] Update API.IntegrationTests/Tests/TokensTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- API.IntegrationTests/Tests/TokensTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/API.IntegrationTests/Tests/TokensTests.cs b/API.IntegrationTests/Tests/TokensTests.cs index 4a7ac67a..5e99e85e 100644 --- a/API.IntegrationTests/Tests/TokensTests.cs +++ b/API.IntegrationTests/Tests/TokensTests.cs @@ -185,7 +185,7 @@ public async Task DeleteToken_Nonexistent_Returns404() public async Task GetTokenSelf_WithApiToken_ReturnsInfo() { var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, "tokself", "tokself@test.org", "SecurePassword123#"); - var (tokenId, rawToken) = await TestHelper.CreateApiTokenInDb(WebApplicationFactory, userId, "SelfToken"); + var (_, rawToken) = await TestHelper.CreateApiTokenInDb(WebApplicationFactory, userId, "SelfToken"); using var client = TestHelper.CreateApiTokenClient(WebApplicationFactory, rawToken); var response = await client.GetAsync("/1/tokens/self"); From eb5c282fbab5e8f0c5cc7f1ad567e2bb9b8ba556 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 11 Feb 2026 14:07:59 +0100 Subject: [PATCH 6/9] Update API.IntegrationTests/Tests/DeviceEndpointTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- API.IntegrationTests/Tests/DeviceEndpointTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/API.IntegrationTests/Tests/DeviceEndpointTests.cs b/API.IntegrationTests/Tests/DeviceEndpointTests.cs index b8eef1e7..80fd5a99 100644 --- a/API.IntegrationTests/Tests/DeviceEndpointTests.cs +++ b/API.IntegrationTests/Tests/DeviceEndpointTests.cs @@ -23,7 +23,7 @@ public sealed class DeviceEndpointTests public async Task GetDeviceSelf_ReturnsDeviceInfo() { var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, "hubself", "hubself@test.org", "SecurePassword123#"); - var (deviceId, hubToken) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, userId, "MyHub"); + var (_, hubToken) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, userId, "MyHub"); using var client = TestHelper.CreateHubTokenClient(WebApplicationFactory, hubToken); var response = await client.GetAsync("/1/device/self"); From d04df20006cb06f5985535183f1b8538b4f4669d Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 11 Feb 2026 14:08:09 +0100 Subject: [PATCH 7/9] Update API.IntegrationTests/Tests/AccountAuthenticatedTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- API.IntegrationTests/Tests/AccountAuthenticatedTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/API.IntegrationTests/Tests/AccountAuthenticatedTests.cs b/API.IntegrationTests/Tests/AccountAuthenticatedTests.cs index 8bdc6691..829edb5e 100644 --- a/API.IntegrationTests/Tests/AccountAuthenticatedTests.cs +++ b/API.IntegrationTests/Tests/AccountAuthenticatedTests.cs @@ -73,7 +73,7 @@ public async Task ChangeUsername_Success() [Test] public async Task ChangeUsername_Taken_Returns409() { - var user1 = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "takenname", "takenname@test.org", "SecurePassword123#"); + 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); From 4593c79a4b46fbee2eae8de0c4a55c747055279c Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 11 Feb 2026 14:09:33 +0100 Subject: [PATCH 8/9] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Tests/AccountAuthenticatedTests.cs | 2 +- API.IntegrationTests/Tests/DeviceEndpointTests.cs | 2 +- API.IntegrationTests/Tests/PublicTests.cs | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/API.IntegrationTests/Tests/AccountAuthenticatedTests.cs b/API.IntegrationTests/Tests/AccountAuthenticatedTests.cs index 829edb5e..04a66b76 100644 --- a/API.IntegrationTests/Tests/AccountAuthenticatedTests.cs +++ b/API.IntegrationTests/Tests/AccountAuthenticatedTests.cs @@ -40,7 +40,7 @@ public async Task ChangePassword_Success() } [Test] - public async Task ChangePassword_WrongCurrentPassword_Returns401() + 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); diff --git a/API.IntegrationTests/Tests/DeviceEndpointTests.cs b/API.IntegrationTests/Tests/DeviceEndpointTests.cs index 80fd5a99..260e8906 100644 --- a/API.IntegrationTests/Tests/DeviceEndpointTests.cs +++ b/API.IntegrationTests/Tests/DeviceEndpointTests.cs @@ -38,7 +38,7 @@ public async Task GetDeviceSelf_ReturnsDeviceInfo() } [Test] - public async Task GetDeviceSelf_WithShockers_ReturnsShokerList() + public async Task GetDeviceSelf_WithShockers_ReturnsShockerList() { var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, "hubshockers", "hubshockers@test.org", "SecurePassword123#"); var (deviceId, hubToken) = await TestHelper.CreateDeviceInDb(WebApplicationFactory, userId, "HubWithShockers"); diff --git a/API.IntegrationTests/Tests/PublicTests.cs b/API.IntegrationTests/Tests/PublicTests.cs index 02d02144..5de9ebfe 100644 --- a/API.IntegrationTests/Tests/PublicTests.cs +++ b/API.IntegrationTests/Tests/PublicTests.cs @@ -56,12 +56,12 @@ public async Task CheckUsername_Available_ReturnsAvailable() { using var client = WebApplicationFactory.CreateClient(); - var response = await client.PostAsync("/1/account/username/check", - new StringContent( - JsonSerializer.Serialize(new { username = "totallyuniquename123" }), - System.Text.Encoding.UTF8, - "application/json")); + using var content = new StringContent( + JsonSerializer.Serialize(new { username = "totallyuniquename123" }), + System.Text.Encoding.UTF8, + "application/json"); + var response = await client.PostAsync("/1/account/username/check", content); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); var json = await response.Content.ReadAsStringAsync(); From 37aca8b62be24779286c14c02b2b8999a1a822ef Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 11 Feb 2026 14:33:08 +0100 Subject: [PATCH 9/9] Add code coverage extension --- API.IntegrationTests/API.IntegrationTests.csproj | 1 + Common.Tests/Common.Tests.csproj | 1 + Directory.Packages.props | 1 + 3 files changed, 3 insertions(+) diff --git a/API.IntegrationTests/API.IntegrationTests.csproj b/API.IntegrationTests/API.IntegrationTests.csproj index 383a2120..06b883f5 100644 --- a/API.IntegrationTests/API.IntegrationTests.csproj +++ b/API.IntegrationTests/API.IntegrationTests.csproj @@ -15,6 +15,7 @@ + diff --git a/Common.Tests/Common.Tests.csproj b/Common.Tests/Common.Tests.csproj index ed2ba0a5..d8b13314 100644 --- a/Common.Tests/Common.Tests.csproj +++ b/Common.Tests/Common.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index bcddd936..479c92a9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -42,6 +42,7 @@ + \ No newline at end of file