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..d7d85793 --- /dev/null +++ b/API.IntegrationTests/AssemblyAttributes.cs @@ -0,0 +1,19 @@ +using TUnit.Core; +using TUnit.Core.Interfaces; + +// Allow up to 3 minutes per test — integration tests can be slow in CI when Docker images +// are cold-pulled and EF migrations run for the first time. The execution timer in TUnit +// may include class-data-source initialization time for the first test that uses the factory. +[assembly: Timeout(3 * 60_000)] + +// Limit parallel test execution to avoid thread pool starvation on CI runners. +// BCrypt password hashing in login/signup endpoints is synchronous and CPU-bound; +// too many concurrent tests exhaust the thread pool, causing request timeouts. +[assembly: ParallelLimiter] + +namespace OpenShock.API.IntegrationTests; + +public record CiSafeParallelLimit : IParallelLimit +{ + public int Limit => Math.Max(Environment.ProcessorCount * 2, 8); +} diff --git a/API.IntegrationTests/Docker/TestMailServer.cs b/API.IntegrationTests/Docker/TestMailServer.cs new file mode 100644 index 00000000..53ec2366 --- /dev/null +++ b/API.IntegrationTests/Docker/TestMailServer.cs @@ -0,0 +1,36 @@ +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using TUnit.Core.Interfaces; + +namespace OpenShock.API.IntegrationTests.Docker; + +public sealed class TestMailServer : IAsyncInitializer, IAsyncDisposable +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required DockerNetwork DockerNetwork { get; init; } + + private IContainer? _container; + public IContainer Container + { + get + { + _container ??= new ContainerBuilder("axllent/mailpit:latest") + .WithNetwork(DockerNetwork.Instance) + .WithName($"tunit-mailpit-{Guid.CreateVersion7()}") + .WithPortBinding(1025, true) + .WithPortBinding(8025, true) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(r => r.ForPort(8025).ForPath("/api/v1/info"))) + .Build(); + + return _container; + } + } + + public string SmtpHost => Container.Hostname; + public int SmtpPort => Container.GetMappedPublicPort(1025); + public string ApiBaseUrl => $"http://{Container.Hostname}:{Container.GetMappedPublicPort(8025)}"; + + public Task InitializeAsync() => Container.StartAsync(); + public ValueTask DisposeAsync() => Container.DisposeAsync(); +} diff --git a/API.IntegrationTests/Helpers/MailpitHelper.cs b/API.IntegrationTests/Helpers/MailpitHelper.cs new file mode 100644 index 00000000..08af81e2 --- /dev/null +++ b/API.IntegrationTests/Helpers/MailpitHelper.cs @@ -0,0 +1,129 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; + +namespace OpenShock.API.IntegrationTests.Helpers; + +/// +/// Helper for querying the Mailpit HTTP API in integration tests. +/// +public sealed class MailpitHelper : IDisposable +{ + private readonly HttpClient _client; + + public MailpitHelper(string apiBaseUrl) + { + _client = new HttpClient { BaseAddress = new Uri(apiBaseUrl) }; + } + + /// + /// Polls until at least one email arrives for the given recipient address, or the timeout elapses. + /// Returns null if no message arrived within the timeout. + /// + public async Task WaitForMessageAsync( + string toAddress, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default) + { + var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(15)); + while (DateTime.UtcNow < deadline && !cancellationToken.IsCancellationRequested) + { + var response = await _client.GetFromJsonAsync( + "/api/v1/messages?limit=50", cancellationToken); + + var match = response?.Messages?.FirstOrDefault(m => + m.To?.Any(c => c.Address.Equals(toAddress, StringComparison.OrdinalIgnoreCase)) == true); + + if (match is not null) + return match; + + await Task.Delay(300, cancellationToken); + } + return null; + } + + /// + /// Returns all messages in Mailpit (no filtering). + /// + public async Task> GetAllMessagesAsync( + int limit = 50, + CancellationToken cancellationToken = default) + { + var response = await _client.GetFromJsonAsync( + $"/api/v1/messages?limit={limit}", cancellationToken); + return response?.Messages ?? []; + } + + /// + /// Fetches the full HTML body of a message by its ID. + /// + public async Task GetMessageAsync(string messageId, CancellationToken cancellationToken = default) + { + return await _client.GetFromJsonAsync( + $"/api/v1/message/{messageId}", + cancellationToken); + } + + /// + /// Deletes all messages from Mailpit (useful for test isolation between test classes). + /// + public Task DeleteAllMessagesAsync(CancellationToken cancellationToken = default) + => _client.DeleteAsync("/api/v1/messages", cancellationToken); + + public void Dispose() => _client.Dispose(); + + // --- DTOs --- + + public sealed class MailpitSearchResponse + { + [JsonPropertyName("messages")] + public List Messages { get; init; } = []; + } + + public sealed class MailpitMessage + { + [JsonPropertyName("ID")] + public string Id { get; init; } = string.Empty; + + [JsonPropertyName("Subject")] + public string Subject { get; init; } = string.Empty; + + [JsonPropertyName("From")] + public MailpitContact? From { get; init; } + + [JsonPropertyName("To")] + public List? To { get; init; } + + [JsonPropertyName("Snippet")] + public string Snippet { get; init; } = string.Empty; + } + + public sealed class MailpitFullMessage + { + [JsonPropertyName("ID")] + public string Id { get; init; } = string.Empty; + + [JsonPropertyName("Subject")] + public string Subject { get; init; } = string.Empty; + + [JsonPropertyName("From")] + public MailpitContact? From { get; init; } + + [JsonPropertyName("To")] + public List? To { get; init; } + + [JsonPropertyName("HTML")] + public string Html { get; init; } = string.Empty; + + [JsonPropertyName("Text")] + public string Text { get; init; } = string.Empty; + } + + public sealed class MailpitContact + { + [JsonPropertyName("Name")] + public string Name { get; init; } = string.Empty; + + [JsonPropertyName("Address")] + public string Address { get; init; } = string.Empty; + } +} 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..36f7932c 100644 --- a/API.IntegrationTests/HttpMessageHandlers/InterceptedHttpMessageHandler.cs +++ b/API.IntegrationTests/HttpMessageHandlers/InterceptedHttpMessageHandler.cs @@ -53,28 +53,28 @@ private async Task HandleCloudflareTurnstileRequest(HttpReq return responseMessage; } - private Task HandleMailJetApiHost(HttpRequestMessage request, CancellationToken cancellationToken) - { - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); - } - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { return request.RequestUri switch { { Host: "challenges.cloudflare.com", AbsolutePath: "/turnstile/v0/siteverify" } => await HandleCloudflareTurnstileRequest(request, cancellationToken), - { Host: "api.mailjet.com" } => await HandleMailJetApiHost(request, cancellationToken), _ => new HttpResponseMessage(HttpStatusCode.NotFound) }; } 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/MailTests.cs b/API.IntegrationTests/Tests/MailTests.cs new file mode 100644 index 00000000..544f2c99 --- /dev/null +++ b/API.IntegrationTests/Tests/MailTests.cs @@ -0,0 +1,213 @@ +using System.Net; +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.API.IntegrationTests.Helpers; +using OpenShock.Common.OpenShockDb; + +namespace OpenShock.API.IntegrationTests.Tests; + +/// +/// Tests that verify emails are actually delivered via SMTP to Mailpit. +/// Each test uses a unique email address so messages can be filtered by recipient. +/// +public sealed partial class MailTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory WebApplicationFactory { get; init; } + + // --- Account Activation --- + + [Test] + public async Task V2Signup_SendsAccountActivationEmail() + { + const string email = "mail-activation@test.org"; + using var mailpit = WebApplicationFactory.CreateMailpitHelper(); + using var client = WebApplicationFactory.CreateClient(); + + var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username = "mailactivationuser", + password = "SecurePassword123#", + email, + turnstileResponse = "valid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var message = await mailpit.WaitForMessageAsync(email); + await Assert.That(message).IsNotNull(); + await Assert.That(message!.To?.Select(c => c.Address)).Contains(email); + } + + [Test] + public async Task ActivationFlow_ViaEmailLink_ActivatesAccount() + { + const string email = "mail-activate-flow@test.org"; + using var mailpit = WebApplicationFactory.CreateMailpitHelper(); + using var client = WebApplicationFactory.CreateClient(); + + // Sign up — this triggers an activation email + var signupResponse = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new + { + username = "mailactivateflowuser", + password = "SecurePassword123#", + email, + turnstileResponse = "valid-token" + })); + await Assert.That(signupResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // Wait for and retrieve the activation email + var message = await mailpit.WaitForMessageAsync(email); + await Assert.That(message).IsNotNull(); + + var fullMessage = await mailpit.GetMessageAsync(message!.Id); + await Assert.That(fullMessage).IsNotNull(); + + // Extract the activation token from the link in the email HTML + var token = ExtractQueryParam(fullMessage!.Html, "token"); + await Assert.That(token).IsNotNull().And.IsNotEmpty(); + + // Use the token to activate the account + var activateResponse = await client.PostAsync($"/1/account/activate?token={token}", null); + await Assert.That(activateResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // Confirm the user is now activated in the DB + await using var scope = WebApplicationFactory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var user = await db.Users.FirstOrDefaultAsync(u => u.Email == email); + await Assert.That(user).IsNotNull(); + await Assert.That(user!.ActivatedAt).IsNotNull(); + } + + // --- Password Reset --- + + [Test] + public async Task V1PasswordReset_SendsPasswordResetEmail() + { + const string email = "mail-pwreset@test.org"; + using var mailpit = WebApplicationFactory.CreateMailpitHelper(); + + await TestHelper.CreateUserInDb(WebApplicationFactory, "mailpwresetuser", email, "OldPassword123#"); + + using var client = WebApplicationFactory.CreateClient(); + var response = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new + { + email + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var message = await mailpit.WaitForMessageAsync(email); + await Assert.That(message).IsNotNull(); + await Assert.That(message!.To?.Select(c => c.Address)).Contains(email); + } + + [Test] + public async Task V2PasswordReset_SendsPasswordResetEmail() + { + const string email = "mail-pwreset-v2@test.org"; + using var mailpit = WebApplicationFactory.CreateMailpitHelper(); + + await TestHelper.CreateUserInDb(WebApplicationFactory, "mailpwresetv2user", email, "OldPassword123#"); + + using var client = WebApplicationFactory.CreateClient(); + var response = await client.PostAsync("/2/account/reset-password", TestHelper.JsonContent(new + { + email, + turnstileResponse = "valid-token" + })); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var message = await mailpit.WaitForMessageAsync(email); + await Assert.That(message).IsNotNull(); + await Assert.That(message!.To?.Select(c => c.Address)).Contains(email); + } + + [Test] + public async Task PasswordResetFlow_ViaEmailLink_ChangesPassword() + { + const string email = "mail-pwreset-flow@test.org"; + const string newPassword = "NewSecurePassword456#"; + using var mailpit = WebApplicationFactory.CreateMailpitHelper(); + + await TestHelper.CreateUserInDb(WebApplicationFactory, "mailpwresetflowuser", email, "OldPassword123#"); + + using var client = WebApplicationFactory.CreateClient(); + + // Initiate password reset + var resetResponse = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email })); + await Assert.That(resetResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // Wait for reset email and extract the link + var message = await mailpit.WaitForMessageAsync(email); + await Assert.That(message).IsNotNull(); + + var fullMessage = await mailpit.GetMessageAsync(message!.Id); + await Assert.That(fullMessage).IsNotNull(); + + // Link format: /#/account/password/recover/{id}/{secret} + var (resetId, secret) = ExtractPasswordResetParams(fullMessage!.Html); + await Assert.That(resetId).IsNotNull().And.IsNotEmpty(); + await Assert.That(secret).IsNotNull().And.IsNotEmpty(); + + // Verify the reset token is valid + var checkResponse = await client.SendAsync(new HttpRequestMessage( + HttpMethod.Head, $"/1/account/recover/{resetId}/{secret}")); + await Assert.That(checkResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // Complete the reset with a new password + var completeResponse = await client.PostAsync( + $"/1/account/recover/{resetId}/{secret}", + TestHelper.JsonContent(new { password = newPassword })); + await Assert.That(completeResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // Confirm we can log in with the new password + var loginResponse = await client.PostAsync("/1/account/login", TestHelper.JsonContent(new + { + email, + password = newPassword + })); + await Assert.That(loginResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + // --- Helpers --- + + /// + /// Extracts a query parameter value from a URL embedded in HTML (first <a href> containing the param). + /// + private static string? ExtractQueryParam(string html, string paramName) + { + var hrefMatch = HrefRegex().Match(html); + while (hrefMatch.Success) + { + var href = hrefMatch.Groups[1].Value; + if (Uri.TryCreate(href, UriKind.Absolute, out var uri)) + { + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + var value = query[paramName]; + if (value is not null) return value; + } + hrefMatch = hrefMatch.NextMatch(); + } + return null; + } + + /// + /// Extracts the (passwordResetId, secret) pair from the password-reset URL embedded in email HTML. + /// URL pattern: /account/password/recover/{guid}/{secret} + /// + private static (string? ResetId, string? Secret) ExtractPasswordResetParams(string html) + { + var match = PasswordResetPathRegex().Match(html); + if (!match.Success) return (null, null); + return (match.Groups[1].Value, match.Groups[2].Value); + } + + [GeneratedRegex(@"href=""([^""]+)""", RegexOptions.IgnoreCase)] + private static partial Regex HrefRegex(); + + [GeneratedRegex(@"/account/password/recover/([0-9a-fA-F\-]+)/([A-Za-z0-9]+)", RegexOptions.IgnoreCase)] + private static partial Regex PasswordResetPathRegex(); +} 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..25203424 100644 --- a/API.IntegrationTests/WebApplicationFactory.cs +++ b/API.IntegrationTests/WebApplicationFactory.cs @@ -1,9 +1,13 @@ -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.Helpers; using OpenShock.API.IntegrationTests.HttpMessageHandlers; using Serilog; using Serilog.Events; @@ -15,10 +19,15 @@ 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; } + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required TestMailServer Mailpit { get; init; } + + public MailpitHelper CreateMailpitHelper() => new(Mailpit.ApiBaseUrl); + public Task InitializeAsync() { _ = Server; @@ -41,31 +50,29 @@ 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__MAIL__TYPE", "MAILJET" }, + { "OPENSHOCK__FRONTEND__COOKIEDOMAIN", "openshock.app,localhost" }, + + { "OPENSHOCK__MAIL__TYPE", "SMTP" }, { "OPENSHOCK__MAIL__SENDER__EMAIL", "system@openshock.org" }, { "OPENSHOCK__MAIL__SENDER__NAME", "OpenShock" }, - { "OPENSHOCK__MAIL__MAILJET__KEY", "mailjet-key" }, - { "OPENSHOCK__MAIL__MAILJET__SECRET", "mailjet-secret" }, - { "OPENSHOCK__MAIL__MAILJET__TEMPLATE__PASSWORDRESET", "12345678" }, - { "OPENSHOCK__MAIL__MAILJET__TEMPLATE__PASSWORDRESETCOMPLETE", "87654321" }, - { "OPENSHOCK__MAIL__MAILJET__TEMPLATE__VERIFYEMAIL", "11223344" }, - { "OPENSHOCK__MAIL__MAILJET__TEMPLATE__VERIFYEMAILCOMPLETE", "44332211" }, - + { "OPENSHOCK__MAIL__SMTP__HOST", Mailpit.SmtpHost }, + { "OPENSHOCK__MAIL__SMTP__PORT", Mailpit.SmtpPort.ToString() }, + { "OPENSHOCK__MAIL__SMTP__ENABLESSL", "false" }, + { "OPENSHOCK__MAIL__SMTP__VERIFYCERTIFICATE", "false" }, + { "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 +89,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/API/Properties/launchSettings.json b/API/Properties/launchSettings.json index abfc1566..71df3baf 100644 --- a/API/Properties/launchSettings.json +++ b/API/Properties/launchSettings.json @@ -6,6 +6,14 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } + }, + "APITest": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "OPENSHOCK_DISABLE_RATE_LIMITING": "1" + } } } } diff --git a/API/Services/Email/Smtp/SmtpEmailService.cs b/API/Services/Email/Smtp/SmtpEmailService.cs index 0303178a..d93e9a3d 100644 --- a/API/Services/Email/Smtp/SmtpEmailService.cs +++ b/API/Services/Email/Smtp/SmtpEmailService.cs @@ -5,7 +5,6 @@ using MimeKit.Text; using OpenShock.API.Options; using OpenShock.API.Services.Email.Mailjet.Mail; -using OpenShock.Common.Utils; namespace OpenShock.API.Services.Email.Smtp; @@ -44,46 +43,15 @@ ILogger logger } public Task ActivateAccount(Contact to, Uri activationLink, CancellationToken cancellationToken = default) - { - var data = new - { - To = to, - ActivationLink = activationLink - }; - - SendMailAndForget(to, _templates.AccountActivation, data, cancellationToken); - return Task.CompletedTask; - } + => SendMail(to, _templates.AccountActivation, new { To = to, ActivationLink = activationLink }, cancellationToken); /// public Task PasswordReset(Contact to, Uri resetLink, CancellationToken cancellationToken = default) - { - var data = new - { - To = to, - ResetLink = resetLink - }; - - SendMailAndForget(to, _templates.PasswordReset, data, cancellationToken); - return Task.CompletedTask; - } + => SendMail(to, _templates.PasswordReset, new { To = to, ResetLink = resetLink }, cancellationToken); /// public Task VerifyEmail(Contact to, Uri verificationLink, CancellationToken cancellationToken = default) - { - var data = new - { - To = to, - ActivationLink = verificationLink - }; - - SendMailAndForget(to, _templates.EmailVerification, data, cancellationToken); - return Task.CompletedTask; - } - - private void SendMailAndForget(Contact to, SmtpTemplate template, T data, - CancellationToken cancellationToken = default) => - OsTask.Run(() => SendMail(to, template, data, cancellationToken)); + => SendMail(to, _templates.EmailVerification, new { To = to, ActivationLink = verificationLink }, cancellationToken); private async Task SendMail(Contact to, SmtpTemplate template, T data, @@ -96,7 +64,9 @@ private async Task SendMail(Contact to, SmtpTemplate template, T data, await using var buffer = new MemoryStream(); await using (var textStreamWriter = new StreamWriter(buffer, leaveOpen: true)) await template.Body.RenderAsync(textStreamWriter, HtmlEncoder.Default, context); - + + buffer.Position = 0; + var message = new MimeMessage { From = { _sender }, @@ -119,7 +89,8 @@ private async Task SendMail(Contact to, SmtpTemplate template, T data, await smtpClient.ConnectAsync(_options.Host, _options.Port, _options.EnableSsl, cancellationToken); _logger.LogTrace("Authenticating..."); - await smtpClient.AuthenticateAsync(_options.Username, _options.Password, cancellationToken); + if (smtpClient.Capabilities.HasFlag(SmtpCapabilities.Authentication)) + await smtpClient.AuthenticateAsync(_options.Username, _options.Password, cancellationToken); _logger.LogTrace("Smtp client connected, sending email..."); 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/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs index 19b41ed6..232a2202 100644 --- a/Common/OpenShockServiceHelper.cs +++ b/Common/OpenShockServiceHelper.cs @@ -208,9 +208,26 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se (BatchUpdateService)provider.GetRequiredService()); // <---- Rate Limiter Setup ----> - + + // Disable rate limiting when: + // - ASPNETCORE_UNDER_INTEGRATION_TEST=1 (.NET WebApplicationFactory tests, in-process) + // - OPENSHOCK_DISABLE_RATE_LIMITING=1 (external Playwright tests against a real server) + var underIntegrationTest = + Environment.GetEnvironmentVariable("ASPNETCORE_UNDER_INTEGRATION_TEST") == "1" || + Environment.GetEnvironmentVariable("OPENSHOCK_DISABLE_RATE_LIMITING") == "1"; + services.AddRateLimiter(options => { + if (underIntegrationTest) + { + options.GlobalLimiter = PartitionedRateLimiter.Create( + _ => RateLimitPartition.GetNoLimiter("test-nolimit")); + options.AddPolicy("auth", _ => RateLimitPartition.GetNoLimiter("test-nolimit")); + options.AddPolicy("token-reporting", _ => RateLimitPartition.GetNoLimiter("test-nolimit")); + options.AddPolicy("shocker-logs", _ => RateLimitPartition.GetNoLimiter("test-nolimit")); + return; + } + options.OnRejected = async (context, cancellationToken) => { var logger = context.HttpContext.RequestServices.GetRequiredService() diff --git a/Dev/devSecrets.json b/Dev/devSecrets.json index 78d17cd1..140d26cf 100644 --- a/Dev/devSecrets.json +++ b/Dev/devSecrets.json @@ -5,6 +5,14 @@ "OPENSHOCK:FRONTEND:BASEURL": "https://openshock.local", "OPENSHOCK:FRONTEND:COOKIEDOMAIN": "openshock.local,localhost", "OPENSHOCK:TURNSTILE:ENABLE": "false", - "OPENSHOCK:MAIL:TYPE": "NONE", + "OPENSHOCK:MAIL:TYPE": "SMTP", + "OPENSHOCK:MAIL:SENDER:NAME": "OpenShock Dev", + "OPENSHOCK:MAIL:SENDER:EMAIL": "dev@openshock.dev", + "OPENSHOCK:MAIL:SMTP:HOST": "localhost", + "OPENSHOCK:MAIL:SMTP:PORT": "1025", + "OPENSHOCK:MAIL:SMTP:USERNAME": "dev", + "OPENSHOCK:MAIL:SMTP:PASSWORD": "dev", + "OPENSHOCK:MAIL:SMTP:ENABLESSL": "false", + "OPENSHOCK:MAIL:SMTP:VERIFYCERTIFICATE": "false", "OPENSHOCK:LCG:COUNTRYCODE": "DE" } diff --git a/Dev/docker-compose.yml b/Dev/docker-compose.yml index b37a816e..ecec4895 100644 --- a/Dev/docker-compose.yml +++ b/Dev/docker-compose.yml @@ -42,5 +42,15 @@ services: ports: - 8080:80 + mailpit: + image: axllent/mailpit:latest + container_name: openshock-mailpit + restart: unless-stopped + networks: + - openshock + ports: + - "8025:8025" # Web UI and HTTP API → http://localhost:8025 + - "1025:1025" # SMTP (no auth required) + networks: openshock: \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 89319c51..224f6843 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,6 +18,7 @@ + @@ -41,6 +42,7 @@ + \ No newline at end of file