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/API.IntegrationTests.csproj b/API.IntegrationTests/API.IntegrationTests.csproj
index 61f5d08d..06b883f5 100644
--- a/API.IntegrationTests/API.IntegrationTests.csproj
+++ b/API.IntegrationTests/API.IntegrationTests.csproj
@@ -11,9 +11,11 @@
+
+
diff --git a/API.IntegrationTests/AssemblyAttributes.cs b/API.IntegrationTests/AssemblyAttributes.cs
new file mode 100644
index 00000000..3a2a1f2c
--- /dev/null
+++ b/API.IntegrationTests/AssemblyAttributes.cs
@@ -0,0 +1,17 @@
+using TUnit.Core;
+using TUnit.Core.Interfaces;
+
+// Fail individual tests after 60 seconds to catch hangs without being too aggressive.
+[assembly: Timeout(60_000)]
+
+// Limit parallel test execution to avoid thread pool starvation on CI runners.
+// BCrypt password hashing in login/signup endpoints is synchronous and CPU-bound;
+// too many concurrent tests exhaust the thread pool, causing request timeouts.
+[assembly: ParallelLimiter]
+
+namespace OpenShock.API.IntegrationTests;
+
+public record CiSafeParallelLimit : IParallelLimit
+{
+ public int Limit => Math.Max(Environment.ProcessorCount * 2, 8);
+}
diff --git a/API.IntegrationTests/Helpers/TestHelper.cs b/API.IntegrationTests/Helpers/TestHelper.cs
new file mode 100644
index 00000000..8591f669
--- /dev/null
+++ b/API.IntegrationTests/Helpers/TestHelper.cs
@@ -0,0 +1,175 @@
+using System.Collections.Concurrent;
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using OpenShock.Common.Constants;
+using OpenShock.Common.OpenShockDb;
+using OpenShock.Common.Services.Session;
+using OpenShock.Common.Utils;
+
+namespace OpenShock.API.IntegrationTests.Helpers;
+
+public static class TestHelper
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ ///
+ /// 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.
+ ///
+ 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();
+ var hash = PasswordHashCache.GetOrAdd(password, HashingUtils.HashPassword);
+ db.Users.Add(new User
+ {
+ Id = userId,
+ Name = username,
+ Email = email,
+ PasswordHash = hash,
+ ActivatedAt = activated ? DateTime.UtcNow : null
+ });
+ await db.SaveChangesAsync();
+ return userId;
+ }
+
+ ///
+ /// 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..04a66b76
--- /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_Returns403()
+ {
+ var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "chgpwdbad", "chgpwdbad@test.org", "CorrectPassword123#");
+ using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken);
+
+ var response = await client.PostAsync("/1/account/password", TestHelper.JsonContent(new
+ {
+ currentPassword = "WrongPassword!",
+ newPassword = "NewPassword456#"
+ }));
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
+ }
+
+ // --- Change Username ---
+
+ [Test]
+ public async Task ChangeUsername_Success()
+ {
+ var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "oldname", "chguname@test.org", "SecurePassword123#");
+ using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken);
+
+ var response = await client.PostAsync("/1/account/username", TestHelper.JsonContent(new
+ {
+ username = "newname"
+ }));
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ }
+
+ [Test]
+ public async Task ChangeUsername_Taken_Returns409()
+ {
+ await TestHelper.CreateAndLoginUser(WebApplicationFactory, "takenname", "takenname@test.org", "SecurePassword123#");
+ var user2 = await TestHelper.CreateAndLoginUser(WebApplicationFactory, "wantsname", "wantsname@test.org", "SecurePassword123#");
+ using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user2.SessionToken);
+
+ var response = await client.PostAsync("/1/account/username", TestHelper.JsonContent(new
+ {
+ username = "takenname"
+ }));
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
+ }
+
+ // --- Unauthenticated access ---
+
+ [Test]
+ public async Task ChangePassword_Unauthenticated_Returns401()
+ {
+ using var client = WebApplicationFactory.CreateClient();
+
+ var response = await client.PostAsync("/1/account/password", TestHelper.JsonContent(new
+ {
+ currentPassword = "anything",
+ newPassword = "anything"
+ }));
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
+ }
+
+ [Test]
+ public async Task ChangeUsername_Unauthenticated_Returns401()
+ {
+ using var client = WebApplicationFactory.CreateClient();
+
+ var response = await client.PostAsync("/1/account/username", TestHelper.JsonContent(new
+ {
+ username = "anything"
+ }));
+
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
+ }
+}
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..260e8906
--- /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 (_, 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_ReturnsShockerList()
+ {
+ 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..5de9ebfe
--- /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();
+
+ 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();
+ 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/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/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/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/API.IntegrationTests/Tests/TokensTests.cs b/API.IntegrationTests/Tests/TokensTests.cs
new file mode 100644
index 00000000..5e99e85e
--- /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 (_, 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"));
+ });
});
}
}
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/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..479c92a9 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -18,6 +18,7 @@
+
@@ -41,6 +42,7 @@
+
\ No newline at end of file