Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
root = true

[*.cs]
# Do not suggest converting constructors into primary constructors
csharp_style_prefer_primary_constructors = false
dotnet_diagnostic.IDE0290.severity = none
resharper_convert_to_primary_constructor_highlighting = none
18 changes: 9 additions & 9 deletions API.IntegrationTests/Tests/AutomationTokenTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -235,44 +235,44 @@ public async Task PasswordReset_ForNormalAccount_StartsResetAndAudits()
// --- Admin endpoints ---

[Test]
public async Task Create_ReturnsWorkingSecret()
public async Task Create_ReturnsWorkingSecret(CancellationToken cancellationToken)
{
using var client = await CreateAdminClient("autocreate");

var response = await client.PostAsync("/1/admin/automationTokens", TestHelper.JsonContent(new
{
name = "created-by-test",
types = new[] { "Turnstile" }
}));
}), cancellationToken);

await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
var id = json.RootElement.GetProperty("id").GetGuid();
var secret = json.RootElement.GetProperty("secret").GetString()!;

await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
var service = scope.ServiceProvider.GetRequiredService<IAutomationTokenService>();
var resolved = await service.ResolveAsync(secret, CancellationToken.None);
var resolved = await service.ResolveAsync(secret, cancellationToken);
await Assert.That(resolved).IsNotNull();
await Assert.That(resolved!.Id).IsEqualTo(id);
await Assert.That(resolved.Types).Contains(AutomationTokenType.Turnstile);
}

[Test]
public async Task Rotate_OldSecretStopsResolving()
public async Task Rotate_OldSecretStopsResolving(CancellationToken cancellationToken)
{
var (tokenId, oldSecret) = await CreateAutomationTokenInDb();
using var client = await CreateAdminClient("autorotate");

var response = await client.PostAsync($"/1/admin/automationTokens/{tokenId}/rotate", null);
var response = await client.PostAsync($"/1/admin/automationTokens/{tokenId}/rotate", null, cancellationToken);
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
var newSecret = json.RootElement.GetProperty("secret").GetString()!;

await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
var service = scope.ServiceProvider.GetRequiredService<IAutomationTokenService>();
await Assert.That(await service.ResolveAsync(oldSecret, CancellationToken.None)).IsNull();
await Assert.That((await service.ResolveAsync(newSecret, CancellationToken.None))?.Id).IsEqualTo(tokenId);
await Assert.That(await service.ResolveAsync(oldSecret, cancellationToken)).IsNull();
await Assert.That((await service.ResolveAsync(newSecret, cancellationToken))?.Id).IsEqualTo(tokenId);
}

[Test]
Expand Down
5 changes: 3 additions & 2 deletions API/Controller/Account/Authenticated/ChangeEmail.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public sealed partial class AuthenticatedAccountController
/// address; the change is not applied until that link is opened.
/// </summary>
/// <param name="body"></param>
/// <param name="cancellationToken"></param>
[HttpPost("email-change")]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK)]
Expand All @@ -30,7 +31,7 @@ public sealed partial class AuthenticatedAccountController
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // EmailChangeAlreadyInUse
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status429TooManyRequests, MediaTypeNames.Application.ProblemJson)] // EmailChangeTooMany
// notActivated / deactivated / notFound are blocked by UserSessionAuthentication before reaching this controller.
public async Task<IActionResult> ChangeEmail([FromBody] ChangeEmailRequest body)
public async Task<IActionResult> ChangeEmail([FromBody] ChangeEmailRequest body, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(CurrentUser.PasswordHash))
{
Expand All @@ -42,7 +43,7 @@ public async Task<IActionResult> ChangeEmail([FromBody] ChangeEmailRequest body)
return Problem(AccountError.PasswordChangeInvalidPassword);
}

var result = await _accountService.CreateEmailChangeFlowAsync(CurrentUser.Id, body.Email, actorId: CurrentUser.Id);
var result = await _accountService.CreateEmailChangeFlowAsync(CurrentUser.Id, body.Email, actorId: CurrentUser.Id, cancellationToken: cancellationToken);

return result switch
{
Expand Down
5 changes: 3 additions & 2 deletions API/Controller/Account/Authenticated/ChangePassword.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public sealed partial class AuthenticatedAccountController
/// Change the password of the current user
/// </summary>
/// <param name="body"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
[HttpPost("password")]
Expand All @@ -28,7 +29,7 @@ public sealed partial class AuthenticatedAccountController
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // PasswordChangeInvalidPassword
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // PasswordNotSet
// notActivated / deactivated / notFound are blocked by UserSessionAuthentication before reaching this controller.
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest body)
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest body, CancellationToken cancellationToken)
{
// OAuth-only accounts that have never set a password must go through the email-confirmed
// /password/set flow rather than silently setting one through this endpoint.
Expand All @@ -42,7 +43,7 @@ public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest
return Problem(AccountError.PasswordChangeInvalidPassword);
}

var result = await _accountService.ChangePasswordAsync(CurrentUser.Id, body.NewPassword, actorId: CurrentUser.Id);
var result = await _accountService.ChangePasswordAsync(CurrentUser.Id, body.NewPassword, actorId: CurrentUser.Id, cancellationToken: cancellationToken);

return result switch
{
Expand Down
6 changes: 4 additions & 2 deletions API/Controller/Account/Authenticated/ChangeUsername.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public sealed partial class AuthenticatedAccountController
/// Change the username of the current user
/// </summary>
/// <param name="body"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <exception cref="Exception"></exception>
[HttpPost("username")]
Expand All @@ -27,10 +28,11 @@ public sealed partial class AuthenticatedAccountController
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // UsernameTaken
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] // UsernameInvalid
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // UsernameRecentlyChanged
public async Task<IActionResult> ChangeUsername([FromBody] ChangeUsernameRequest body)
public async Task<IActionResult> ChangeUsername([FromBody] ChangeUsernameRequest body, CancellationToken cancellationToken)
{
var result = await _accountService.ChangeUsernameAsync(CurrentUser.Id, body.Username, actorId: CurrentUser.Id,
ignoreLimit: CurrentUser.Roles.Any(r => r is RoleType.Staff or RoleType.Admin or RoleType.System));
ignoreLimit: CurrentUser.Roles.Any(r => r is RoleType.Staff or RoleType.Admin or RoleType.System),
cancellationToken: cancellationToken);

return result switch
{
Expand Down
4 changes: 2 additions & 2 deletions API/Controller/Account/Authenticated/Deactivate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ public sealed partial class AuthenticatedAccountController
[HttpDelete]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // CannotDeactivatePrivledgedAccount
public async Task<IActionResult> Deactivate()
public async Task<IActionResult> Deactivate(CancellationToken cancellationToken)
{
var deactivationResult = await _accountService.DeactivateAccountAsync(CurrentUser.Id, CurrentUser.Id, deleteLater: true);
var deactivationResult = await _accountService.DeactivateAccountAsync(CurrentUser.Id, CurrentUser.Id, deleteLater: true, cancellationToken: cancellationToken);
return deactivationResult switch
{
Results.Success => NoContent(),
Expand Down
2 changes: 1 addition & 1 deletion API/Controller/Account/LoginV2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public async Task<IActionResult> LoginV2(
if (!await automationTokens.TryRecordUseAsync(account.Id, AutomationTokenFlow.Login, cancellationToken))
return Problem(AutomationTokenError.NotAllowedForAccount);

await CreateSession(account.Id, cookieDomain);
await CreateSession(account.Id, cookieDomain, cancellationToken);

return Ok(LoginV2OkResponse.FromUser(account));
}
Expand Down
4 changes: 2 additions & 2 deletions API/Controller/Account/Logout.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ public sealed partial class AccountController
[HttpPost("logout")]
[ProducesResponseType(StatusCodes.Status200OK)]
[MapToApiVersion("1")]
public async Task<IActionResult> Logout([FromServices] ISessionService sessionService)
public async Task<IActionResult> Logout([FromServices] ISessionService sessionService, CancellationToken cancellationToken)
{
// Remove session if valid
if (HttpContext.TryGetUserSessionToken(out var sessionToken))
{
var session = await sessionService.GetSessionByTokenAsync(sessionToken);
if (session is not null)
{
await sessionService.LogoutSessionAsync(session);
await sessionService.LogoutSessionAsync(session, cancellationToken);
}
}

Expand Down
6 changes: 4 additions & 2 deletions API/Controller/Account/PasswordResetComplete.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public IActionResult PasswordResetCompleteLegacy([FromRoute] Guid passwordResetI
/// <param name="passwordResetId">The id of the password reset</param>
/// <param name="secret">The secret of the password reset</param>
/// <param name="body"></param>
/// <param name="cancellationToken"></param>
/// <response code="200">Password successfully changed</response>
/// <response code="404">Password reset process not found</response>
[HttpPost("password-reset/{passwordResetId}/{secret}/complete")]
Expand All @@ -43,9 +44,10 @@ public IActionResult PasswordResetCompleteLegacy([FromRoute] Guid passwordResetI
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // PasswordResetNotFound
[MapToApiVersion("1")]
public async Task<IActionResult> PasswordResetComplete([FromRoute] Guid passwordResetId,
[FromRoute] string secret, [FromBody] PasswordResetProcessData body)
[FromRoute] string secret, [FromBody] PasswordResetProcessData body,
CancellationToken cancellationToken)
{
var passwordResetComplete = await _accountService.CompletePasswordResetFlowAsync(passwordResetId, secret, body.Password);
var passwordResetComplete = await _accountService.CompletePasswordResetFlowAsync(passwordResetId, secret, body.Password, cancellationToken);

return passwordResetComplete switch
{
Expand Down
2 changes: 1 addition & 1 deletion API/Controller/Account/PasswordResetInitiateV2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public async Task<IActionResult> PasswordResetInitiateV2([FromBody] PasswordRese
return Ok();
}

await _accountService.CreatePasswordResetFlowAsync(body.Email);
await _accountService.CreatePasswordResetFlowAsync(body.Email, cancellationToken);

return Ok();
}
Expand Down
5 changes: 3 additions & 2 deletions API/Controller/Admin/AutomationTokenList.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OpenShock.API.Controller.Admin.DTOs;
Expand All @@ -10,11 +11,11 @@ public sealed partial class AdminController
/// Lists all automation tokens
/// </summary>
[HttpGet("automationTokens")]
public async IAsyncEnumerable<AutomationTokenDto> ListAutomationTokens()
public async IAsyncEnumerable<AutomationTokenDto> ListAutomationTokens([EnumeratorCancellation] CancellationToken cancellationToken)
{
PedanticallyEnsureAdmin();

await foreach (var token in _db.AutomationTokens.AsNoTracking().AsAsyncEnumerable())
await foreach (var token in _db.AutomationTokens.AsNoTracking().AsAsyncEnumerable().WithCancellation(cancellationToken))
{
yield return AutomationTokenDto.FromEntity(token);
}
Expand Down
8 changes: 5 additions & 3 deletions API/Controller/Admin/Configuration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ public async Task<IActionResult> ConfigurationAdd([FromBody] ConfigurationAddIte
body.Name,
body.Description,
body.Type,
body.Value
body.Value,
cancellationToken
);

return result switch
Expand Down Expand Up @@ -90,7 +91,8 @@ public async Task<IActionResult> ConfigurationUpdate([FromBody] ConfigurationUpd
var result = await configurationService.TryUpdateItemAsync(
body.Name,
body.Description,
body.Value
body.Value,
cancellationToken
);

return result switch
Expand All @@ -117,7 +119,7 @@ public async Task<IActionResult> ConfigurationDelete([FromRoute] string name, [F
{
PedanticallyEnsureAdmin();

var result = await configurationService.TryDeleteItemAsync(name);
var result = await configurationService.TryDeleteItemAsync(name, cancellationToken);

return result switch
{
Expand Down
5 changes: 3 additions & 2 deletions API/Controller/Admin/DeactivateUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ public async Task<IActionResult> DeactivateUser(
[FromRoute] Guid userId,
[FromQuery(Name = "deleteLater")] bool deleteLater,
[FromQuery][MaxLength(ApiHardLimits.AuditReasonMaxLength)] string? reason,
[FromServices] IAccountService accountService)
[FromServices] IAccountService accountService,
CancellationToken cancellationToken)
{
PedanticallyEnsureAdmin();

var deactivationResult = await accountService.DeactivateAccountAsync(CurrentUser.Id, userId, deleteLater, reason);
var deactivationResult = await accountService.DeactivateAccountAsync(CurrentUser.Id, userId, deleteLater, reason, cancellationToken);
return deactivationResult switch
{
Results.Success => Ok("Account deactivated"),
Expand Down
5 changes: 3 additions & 2 deletions API/Controller/Admin/DeleteUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ public sealed partial class AdminController
public async Task<IActionResult> DeleteUser(
[FromRoute] Guid userId,
[FromQuery][MaxLength(ApiHardLimits.AuditReasonMaxLength)] string? reason,
[FromServices] IAccountService accountService)
[FromServices] IAccountService accountService,
CancellationToken cancellationToken)
{
PedanticallyEnsureAdmin();

var result = await accountService.DeleteAccountAsync(CurrentUser.Id, userId, reason);
var result = await accountService.DeleteAccountAsync(CurrentUser.Id, userId, reason, cancellationToken);
return result switch
{
Results.Success => Ok("Account deleted"),
Expand Down
7 changes: 4 additions & 3 deletions API/Controller/Admin/EmailOutboxList.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ public async Task<IActionResult> GetEmailOutbox(
[FromQuery(Name = "$filter")] string filterQuery = "",
[FromQuery(Name = "$orderby")] string orderbyQuery = "",
[FromQuery(Name = "$offset")][Range(0, int.MaxValue)] int offset = 0,
[FromQuery(Name = "$limit")][Range(1, 1000)] int limit = 100
[FromQuery(Name = "$limit")][Range(1, 1000)] int limit = 100,
CancellationToken cancellationToken = default
)
{
PedanticallyEnsureAdmin();
Expand Down Expand Up @@ -67,14 +68,14 @@ public async Task<IActionResult> GetEmailOutbox(
// its custom data reader can't apply Npgsql's jsonb -> Dictionary<string,string> conversion for
// the Payload column and blindly casts the raw jsonb string to the dictionary, throwing
// InvalidCastException. Plain async queries let Npgsql materialize Payload correctly.
var total = await query.LongCountAsync();
var total = await query.LongCountAsync(cancellationToken);

if (offset != 0)
{
query = query.Skip(offset);
}

var messages = await query.Take(limit).ToArrayAsync();
var messages = await query.Take(limit).ToArrayAsync(cancellationToken);

return Ok(new Paginated<EmailOutboxMessageDto>
{
Expand Down
4 changes: 2 additions & 2 deletions API/Controller/Admin/EmailOutboxStats.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@ public sealed partial class AdminController
/// <response code="401">Unauthorized</response>
[HttpGet("email/outbox/stats")]
[ProducesResponseType<EmailOutboxStatsDto>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
public async Task<EmailOutboxStatsDto> GetEmailOutboxStats()
public async Task<EmailOutboxStatsDto> GetEmailOutboxStats(CancellationToken cancellationToken)
{
PedanticallyEnsureAdmin();

var counts = await _db.EmailOutbox
.AsNoTracking()
.GroupBy(m => m.Status)
.Select(g => new { Status = g.Key, Count = g.LongCount() })
.ToDictionaryAsync(x => x.Status, x => x.Count);
.ToDictionaryAsync(x => x.Status, x => x.Count, cancellationToken);

long Count(EmailStatus status) => counts.GetValueOrDefault(status);

Expand Down
5 changes: 3 additions & 2 deletions API/Controller/Admin/EmailProviderBlacklistList.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
using System.Net.Mail;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
Expand All @@ -8,7 +9,7 @@ namespace OpenShock.API.Controller.Admin;
public sealed partial class AdminController
{
[HttpGet("blacklist/emailProviders")]
public async IAsyncEnumerable<EmailProviderBlacklistDto> ListEmailProviderBlacklist([FromQuery(Name="match")] string? match)
public async IAsyncEnumerable<EmailProviderBlacklistDto> ListEmailProviderBlacklist([FromQuery(Name="match")] string? match, [EnumeratorCancellation] CancellationToken cancellationToken)
{
PedanticallyEnsureAdmin();

Expand All @@ -23,7 +24,7 @@ public async IAsyncEnumerable<EmailProviderBlacklistDto> ListEmailProviderBlackl
match = parsed.Host.ToLowerInvariant();
}

await foreach (var item in _db.EmailProviderBlacklists.AsNoTracking().AsAsyncEnumerable())
await foreach (var item in _db.EmailProviderBlacklists.AsNoTracking().AsAsyncEnumerable().WithCancellation(cancellationToken))
{
if (match is not null && !match.EndsWith(item.Domain)) continue;

Expand Down
4 changes: 2 additions & 2 deletions API/Controller/Admin/GetOnlineDevices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public sealed partial class AdminController
/// <response code="401">Unauthorized</response>
[HttpGet("monitoring/onlineDevices")]
[ProducesResponseType<LegacyDataResponse<AdminOnlineDeviceResponse[]>>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
public async Task<IActionResult> GetOnlineDevices()
public async Task<IActionResult> GetOnlineDevices(CancellationToken cancellationToken)
{
PedanticallyEnsureAdmin();

Expand All @@ -40,7 +40,7 @@ public async Task<IActionResult> GetOnlineDevices()
Name = x.Owner.Name
}
})
.ToArrayAsync();
.ToArrayAsync(cancellationToken);

return LegacyDataOk(
allOnlineDevices
Expand Down
Loading
Loading