Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 5 additions & 45 deletions API.IntegrationTests/Tests/AccountLoginTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,60 +10,20 @@ public sealed class AccountLoginTests
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerTestSession)]
public required WebApplicationFactory WebApplicationFactory { get; init; }

// --- V1 Login ---
// --- V1 Login (retired) ---

[Test]
public async Task V1Login_Success_ReturnsCookie()
public async Task V1Login_Retired_Returns410Gone()
{
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#"
email = "whatever@test.org",
password = "SecurePassword123#"
}));

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

// --- V2 Login ---
Expand Down
61 changes: 21 additions & 40 deletions API.IntegrationTests/Tests/AccountSignupTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,56 +11,21 @@ public sealed class AccountSignupTests
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerTestSession)]
public required WebApplicationFactory WebApplicationFactory { get; init; }

// --- V1 Signup ---
// --- V1 Signup (retired) ---

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

var response = await client.PostAsync("/1/account/signup", TestHelper.JsonContent(new
{
username = "v1user",
username = "v1retired",
password = "SecurePassword123#",
email = "v1user@test.org"
email = "v1retired@test.org"
}));

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

await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<OpenShockContext>();
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);
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Gone);
}

// --- V2 Signup ---
Expand Down Expand Up @@ -118,6 +83,22 @@ public async Task V2Signup_DuplicateEmail_Returns409()
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
}

[Test, DependsOn(nameof(V2Signup_Success_CreatesUser))]
public async Task V2Signup_DuplicateUsername_Returns409()
{
using var client = WebApplicationFactory.CreateClient();

var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new
{
username = "v2user", // same username
password = "SecurePassword123#",
email = "v2different@test.org",
turnstileResponse = "valid-token"
}));

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

// --- Validation ---

[Test]
Expand Down
98 changes: 34 additions & 64 deletions API.IntegrationTests/Tests/MailTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,22 +86,25 @@ public async Task ActivationFlow_ViaEmailLink_ActivatesAccount()
// --- Password Reset ---

[Test]
public async Task V1PasswordReset_SendsPasswordResetEmail()
public async Task V1PasswordReset_Retired_Returns410Gone()
{
var email = TestHelper.UniqueEmail("mail-pwreset");
var username = TestHelper.UniqueUsername("mailpwreset");
using var mailpit = WebApplicationFactory.CreateMailpitHelper();
using var client = WebApplicationFactory.CreateClient();
var response = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email = "whatever@test.org" }));

await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Gone);
}

[Test]
public async Task ResetPasswordAlias_Retired_Returns410Gone()
{
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 response = await client.PostAsync("/2/account/reset-password", TestHelper.JsonContent(new
{
email = "whatever@test.org",
turnstileResponse = "valid-token"
}));

var message = await mailpit.WaitForMessageAsync(email);
await Assert.That(message).IsNotNull();
await Assert.That(message!.To?.Select(c => c.Address)).Contains(email);
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Gone);
}

[Test]
Expand Down Expand Up @@ -140,7 +143,7 @@ public async Task PasswordResetFlow_ViaEmailLink_ChangesPassword()
using var client = WebApplicationFactory.CreateClient();

// Initiate password reset
var resetResponse = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email }));
var resetResponse = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" }));
await Assert.That(resetResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);

// Wait for reset email and extract the link
Expand All @@ -166,10 +169,11 @@ public async Task PasswordResetFlow_ViaEmailLink_ChangesPassword()
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
var loginResponse = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new
{
email,
password = newPassword
usernameOrEmail = email,
password = newPassword,
turnstileResponse = "valid-token"
}));
await Assert.That(loginResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
}
Expand Down Expand Up @@ -320,61 +324,26 @@ public async Task ChangeEmail_Unchanged_Returns400_AndSendsNoEmail()
}

[Test]
public async Task PasswordResetComplete_LegacyRecoverRoute_StillWorks()
public async Task PasswordResetComplete_LegacyRecoverRoute_Returns410Gone()
{
var email = TestHelper.UniqueEmail("mail-pwreset-legacy");
var username = TestHelper.UniqueUsername("mailpwresetlegacy");
const string newPassword = "LegacyNewPassword456#";
using var mailpit = WebApplicationFactory.CreateMailpitHelper();

await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#");

using var client = WebApplicationFactory.CreateClient();

var resetResponse = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email }));
await Assert.That(resetResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);

var message = await mailpit.WaitForMessageAsync(email);
await Assert.That(message).IsNotNull();
var fullMessage = await mailpit.GetMessageAsync(message!.Id);
var (resetId, secret) = ExtractPasswordResetParams(fullMessage!.Html);
await Assert.That(resetId).IsNotNull().And.IsNotEmpty();

// Hit the deprecated route directly — must still complete the reset.
var completeResponse = await client.PostAsync(
$"/1/account/recover/{resetId}/{secret}",
TestHelper.JsonContent(new { password = newPassword }));
await Assert.That(completeResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
var response = await client.PostAsync(
$"/1/account/recover/{Guid.CreateVersion7()}/somesecret",
TestHelper.JsonContent(new { password = "LegacyNewPassword456#" }));

var loginResponse = await client.PostAsync("/1/account/login", TestHelper.JsonContent(new
{
email,
password = newPassword
}));
await Assert.That(loginResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Gone);
}

[Test]
public async Task PasswordResetCheck_LegacyHeadRecoverRoute_StillWorks()
public async Task PasswordResetCheck_LegacyHeadRecoverRoute_Returns410Gone()
{
var email = TestHelper.UniqueEmail("mail-pwreset-check-legacy");
var username = TestHelper.UniqueUsername("mailpwresetchecklegacy");
using var mailpit = WebApplicationFactory.CreateMailpitHelper();

await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#");

using var client = WebApplicationFactory.CreateClient();
var resetResponse = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email }));
await Assert.That(resetResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);

var message = await mailpit.WaitForMessageAsync(email);
await Assert.That(message).IsNotNull();
var fullMessage = await mailpit.GetMessageAsync(message!.Id);
var (resetId, secret) = ExtractPasswordResetParams(fullMessage!.Html);
var response = await client.SendAsync(new HttpRequestMessage(
HttpMethod.Head, $"/1/account/recover/{Guid.CreateVersion7()}/somesecret"));

var legacyCheck = await client.SendAsync(new HttpRequestMessage(
HttpMethod.Head, $"/1/account/recover/{resetId}/{secret}"));
await Assert.That(legacyCheck.StatusCode).IsEqualTo(HttpStatusCode.OK);
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Gone);
}

[Test]
Expand Down Expand Up @@ -463,10 +432,10 @@ public async Task PasswordResetFlow_SecondPendingResetInvalidatedAfterFirstCompl
using var client = WebApplicationFactory.CreateClient();

// Fire two reset requests back-to-back, then wait for both emails to land.
var firstInit = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email }));
var firstInit = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" }));
await Assert.That(firstInit.StatusCode).IsEqualTo(HttpStatusCode.OK);

var secondInit = await client.PostAsync("/1/account/reset", TestHelper.JsonContent(new { email }));
var secondInit = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" }));
await Assert.That(secondInit.StatusCode).IsEqualTo(HttpStatusCode.OK);

var messages = await mailpit.WaitForMessagesAsync(email, minCount: 2);
Expand Down Expand Up @@ -500,10 +469,11 @@ public async Task PasswordResetFlow_SecondPendingResetInvalidatedAfterFirstCompl
await Assert.That(completeB.StatusCode).IsEqualTo(HttpStatusCode.NotFound);

// Password from the winning reset works
var loginResponse = await client.PostAsync("/1/account/login", TestHelper.JsonContent(new
var loginResponse = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new
{
email,
password = firstNewPassword
usernameOrEmail = email,
password = firstNewPassword,
turnstileResponse = "valid-token"
}));
await Assert.That(loginResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
}
Expand Down
19 changes: 0 additions & 19 deletions API.IntegrationTests/Tests/RegistrationDisabledTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,6 @@ public sealed class RegistrationDisabledTests
[ClassDataSource<RegistrationDisabledWebApplicationFactory>(Shared = SharedType.PerTestSession)]
public required RegistrationDisabledWebApplicationFactory WebApplicationFactory { get; init; }

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

var response = await client.PostAsync("/1/account/signup", TestHelper.JsonContent(new
{
username = "disabledv1",
password = "SecurePassword123#",
email = "disabledv1@test.org"
}));

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

var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
await Assert.That(problem).IsNotNull();
await Assert.That(problem!.Type).IsEqualTo("Signup.RegistrationDisabled");
}

[Test]
public async Task V2Signup_RegistrationDisabled_Returns403WithProblemType()
{
Expand Down
42 changes: 6 additions & 36 deletions API/Controller/Account/Login.cs
Original file line number Diff line number Diff line change
@@ -1,47 +1,17 @@
using Asp.Versioning;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using OpenShock.API.Models.Requests;
using OpenShock.Common.Errors;
using OpenShock.Common.Models;
using OpenShock.Common.Problems;
using System.Net.Mime;
using Microsoft.AspNetCore.RateLimiting;

namespace OpenShock.API.Controller.Account;

public sealed partial class AccountController
{
/// <summary>
/// Authenticate a user
/// Authenticate a user. Retired: use POST /2/account/login instead.
/// </summary>
/// <response code="200">User successfully logged in</response>
/// <response code="401">Invalid username or password</response>
[HttpPost("login")]
[EnableRateLimiting("auth")]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType<LegacyEmptyResponse>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status401Unauthorized, MediaTypeNames.Application.ProblemJson)] // InvalidCredentials
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // InvalidDomain
[Obsolete("Retired. Use POST /2/account/login instead.")]
[ApiExplorerSettings(IgnoreApi = true)]
[MapToApiVersion("1")]
public async Task<IActionResult> Login(
[FromBody] Login body,
CancellationToken cancellationToken)
{
var cookieDomain = GetCurrentCookieDomain();
if (cookieDomain is null) return Problem(LoginError.InvalidDomain);

var getAccountResult = await _accountService.GetAccountByCredentialsAsync(body.Email, body.Password, cancellationToken);
if (!getAccountResult.TryPickT0(out var account, out var errors))
{
return errors.Match(
notFound => Problem(LoginError.InvalidCredentials),
deactivated => Problem(AccountError.AccountDeactivated),
notActivated => Problem(AccountError.AccountNotActivated),
oauthOnly => Problem(AccountError.AccountOAuthOnly)
);
}

await CreateSession(account.Id, cookieDomain);
return LegacyEmptyOk("Successfully logged in");
}
}
public IActionResult Login() => Problem(GoneError.EndpointRetired("POST /2/account/login"));
}
Loading
Loading