Skip to content

Commit 473c247

Browse files
committed
feat: propagate CancellationToken throughout the backend
Thread cancellation tokens from controllers, SignalR hubs, auth handlers, the LiveControlGateway receive loop and cron jobs down into every service and EF Core/HttpClient/HybridCache call that accepts one. - Services (device, OTA, configuration, control sender, webhook, device update, account, API token, session create/logout) take a token. - Hubs use Context.ConnectionAborted; auth handlers use RequestAborted. - Cron jobs use the host's ApplicationStopping token; Execute() keeps no parameters so Hangfire job signatures are unchanged. - Email outbox delivery stops on shutdown and always records a send's outcome once the email has gone out. - Add .editorconfig disabling the primary-constructor suggestion.
1 parent f4ae945 commit 473c247

94 files changed

Lines changed: 659 additions & 504 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.editorconfig‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
root = true
2+
3+
[*.cs]
4+
# Do not suggest converting constructors into primary constructors
5+
csharp_style_prefer_primary_constructors = false
6+
dotnet_diagnostic.IDE0290.severity = none
7+
resharper_convert_to_primary_constructor_highlighting = none

‎API.IntegrationTests/Tests/AutomationTokenTests.cs‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ public async Task PasswordReset_ForNormalAccount_StartsResetAndAudits()
235235
// --- Admin endpoints ---
236236

237237
[Test]
238-
public async Task Create_ReturnsWorkingSecret()
238+
public async Task Create_ReturnsWorkingSecret(CancellationToken cancellationToken)
239239
{
240240
using var client = await CreateAdminClient("autocreate");
241241

@@ -252,14 +252,14 @@ public async Task Create_ReturnsWorkingSecret()
252252

253253
await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
254254
var service = scope.ServiceProvider.GetRequiredService<IAutomationTokenService>();
255-
var resolved = await service.ResolveAsync(secret, CancellationToken.None);
255+
var resolved = await service.ResolveAsync(secret, cancellationToken);
256256
await Assert.That(resolved).IsNotNull();
257257
await Assert.That(resolved!.Id).IsEqualTo(id);
258258
await Assert.That(resolved.Types).Contains(AutomationTokenType.Turnstile);
259259
}
260260

261261
[Test]
262-
public async Task Rotate_OldSecretStopsResolving()
262+
public async Task Rotate_OldSecretStopsResolving(CancellationToken cancellationToken)
263263
{
264264
var (tokenId, oldSecret) = await CreateAutomationTokenInDb();
265265
using var client = await CreateAdminClient("autorotate");
@@ -271,8 +271,8 @@ public async Task Rotate_OldSecretStopsResolving()
271271

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

278278
[Test]

‎API/Controller/Account/Authenticated/ChangeEmail.cs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public sealed partial class AuthenticatedAccountController
2121
/// address; the change is not applied until that link is opened.
2222
/// </summary>
2323
/// <param name="body"></param>
24+
/// <param name="cancellationToken"></param>
2425
[HttpPost("email-change")]
2526
[Consumes(MediaTypeNames.Application.Json)]
2627
[ProducesResponseType(StatusCodes.Status200OK)]
@@ -30,7 +31,7 @@ public sealed partial class AuthenticatedAccountController
3031
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // EmailChangeAlreadyInUse
3132
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status429TooManyRequests, MediaTypeNames.Application.ProblemJson)] // EmailChangeTooMany
3233
// notActivated / deactivated / notFound are blocked by UserSessionAuthentication before reaching this controller.
33-
public async Task<IActionResult> ChangeEmail([FromBody] ChangeEmailRequest body)
34+
public async Task<IActionResult> ChangeEmail([FromBody] ChangeEmailRequest body, CancellationToken cancellationToken)
3435
{
3536
if (string.IsNullOrEmpty(CurrentUser.PasswordHash))
3637
{
@@ -42,7 +43,7 @@ public async Task<IActionResult> ChangeEmail([FromBody] ChangeEmailRequest body)
4243
return Problem(AccountError.PasswordChangeInvalidPassword);
4344
}
4445

45-
var result = await _accountService.CreateEmailChangeFlowAsync(CurrentUser.Id, body.Email, actorId: CurrentUser.Id);
46+
var result = await _accountService.CreateEmailChangeFlowAsync(CurrentUser.Id, body.Email, actorId: CurrentUser.Id, cancellationToken: cancellationToken);
4647

4748
return result switch
4849
{

‎API/Controller/Account/Authenticated/ChangePassword.cs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public sealed partial class AuthenticatedAccountController
2020
/// Change the password of the current user
2121
/// </summary>
2222
/// <param name="body"></param>
23+
/// <param name="cancellationToken"></param>
2324
/// <returns></returns>
2425
/// <exception cref="Exception"></exception>
2526
[HttpPost("password")]
@@ -28,7 +29,7 @@ public sealed partial class AuthenticatedAccountController
2829
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // PasswordChangeInvalidPassword
2930
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // PasswordNotSet
3031
// notActivated / deactivated / notFound are blocked by UserSessionAuthentication before reaching this controller.
31-
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest body)
32+
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest body, CancellationToken cancellationToken)
3233
{
3334
// OAuth-only accounts that have never set a password must go through the email-confirmed
3435
// /password/set flow rather than silently setting one through this endpoint.
@@ -42,7 +43,7 @@ public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest
4243
return Problem(AccountError.PasswordChangeInvalidPassword);
4344
}
4445

45-
var result = await _accountService.ChangePasswordAsync(CurrentUser.Id, body.NewPassword, actorId: CurrentUser.Id);
46+
var result = await _accountService.ChangePasswordAsync(CurrentUser.Id, body.NewPassword, actorId: CurrentUser.Id, cancellationToken: cancellationToken);
4647

4748
return result switch
4849
{

‎API/Controller/Account/Authenticated/ChangeUsername.cs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public sealed partial class AuthenticatedAccountController
1919
/// Change the username of the current user
2020
/// </summary>
2121
/// <param name="body"></param>
22+
/// <param name="cancellationToken"></param>
2223
/// <returns></returns>
2324
/// <exception cref="Exception"></exception>
2425
[HttpPost("username")]
@@ -27,10 +28,11 @@ public sealed partial class AuthenticatedAccountController
2728
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status409Conflict, MediaTypeNames.Application.ProblemJson)] // UsernameTaken
2829
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] // UsernameInvalid
2930
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // UsernameRecentlyChanged
30-
public async Task<IActionResult> ChangeUsername([FromBody] ChangeUsernameRequest body)
31+
public async Task<IActionResult> ChangeUsername([FromBody] ChangeUsernameRequest body, CancellationToken cancellationToken)
3132
{
3233
var result = await _accountService.ChangeUsernameAsync(CurrentUser.Id, body.Username, actorId: CurrentUser.Id,
33-
ignoreLimit: CurrentUser.Roles.Any(r => r is RoleType.Staff or RoleType.Admin or RoleType.System));
34+
ignoreLimit: CurrentUser.Roles.Any(r => r is RoleType.Staff or RoleType.Admin or RoleType.System),
35+
cancellationToken: cancellationToken);
3436

3537
return result switch
3638
{

‎API/Controller/Account/Authenticated/Deactivate.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ public sealed partial class AuthenticatedAccountController
2020
[HttpDelete]
2121
[ProducesResponseType(StatusCodes.Status204NoContent)]
2222
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status403Forbidden, MediaTypeNames.Application.ProblemJson)] // CannotDeactivatePrivledgedAccount
23-
public async Task<IActionResult> Deactivate()
23+
public async Task<IActionResult> Deactivate(CancellationToken cancellationToken)
2424
{
25-
var deactivationResult = await _accountService.DeactivateAccountAsync(CurrentUser.Id, CurrentUser.Id, deleteLater: true);
25+
var deactivationResult = await _accountService.DeactivateAccountAsync(CurrentUser.Id, CurrentUser.Id, deleteLater: true, cancellationToken: cancellationToken);
2626
return deactivationResult switch
2727
{
2828
Results.Success => NoContent(),

‎API/Controller/Account/LoginV2.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ public async Task<IActionResult> LoginV2(
6565
if (!await automationTokens.TryRecordUseAsync(account.Id, AutomationTokenFlow.Login, cancellationToken))
6666
return Problem(AutomationTokenError.NotAllowedForAccount);
6767

68-
await CreateSession(account.Id, cookieDomain);
68+
await CreateSession(account.Id, cookieDomain, cancellationToken);
6969

7070
return Ok(LoginV2OkResponse.FromUser(account));
7171
}

‎API/Controller/Account/Logout.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ public sealed partial class AccountController
1010
[HttpPost("logout")]
1111
[ProducesResponseType(StatusCodes.Status200OK)]
1212
[MapToApiVersion("1")]
13-
public async Task<IActionResult> Logout([FromServices] ISessionService sessionService)
13+
public async Task<IActionResult> Logout([FromServices] ISessionService sessionService, CancellationToken cancellationToken)
1414
{
1515
// Remove session if valid
1616
if (HttpContext.TryGetUserSessionToken(out var sessionToken))
1717
{
1818
var session = await sessionService.GetSessionByTokenAsync(sessionToken);
1919
if (session is not null)
2020
{
21-
await sessionService.LogoutSessionAsync(session);
21+
await sessionService.LogoutSessionAsync(session, cancellationToken);
2222
}
2323
}
2424

‎API/Controller/Account/PasswordResetComplete.cs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public IActionResult PasswordResetCompleteLegacy([FromRoute] Guid passwordResetI
3434
/// <param name="passwordResetId">The id of the password reset</param>
3535
/// <param name="secret">The secret of the password reset</param>
3636
/// <param name="body"></param>
37+
/// <param name="cancellationToken"></param>
3738
/// <response code="200">Password successfully changed</response>
3839
/// <response code="404">Password reset process not found</response>
3940
[HttpPost("password-reset/{passwordResetId}/{secret}/complete")]
@@ -43,9 +44,10 @@ public IActionResult PasswordResetCompleteLegacy([FromRoute] Guid passwordResetI
4344
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status404NotFound, MediaTypeNames.Application.ProblemJson)] // PasswordResetNotFound
4445
[MapToApiVersion("1")]
4546
public async Task<IActionResult> PasswordResetComplete([FromRoute] Guid passwordResetId,
46-
[FromRoute] string secret, [FromBody] PasswordResetProcessData body)
47+
[FromRoute] string secret, [FromBody] PasswordResetProcessData body,
48+
CancellationToken cancellationToken)
4749
{
48-
var passwordResetComplete = await _accountService.CompletePasswordResetFlowAsync(passwordResetId, secret, body.Password);
50+
var passwordResetComplete = await _accountService.CompletePasswordResetFlowAsync(passwordResetId, secret, body.Password, cancellationToken);
4951

5052
return passwordResetComplete switch
5153
{

‎API/Controller/Account/PasswordResetInitiateV2.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ public async Task<IActionResult> PasswordResetInitiateV2([FromBody] PasswordRese
4848
return Ok();
4949
}
5050

51-
await _accountService.CreatePasswordResetFlowAsync(body.Email);
51+
await _accountService.CreatePasswordResetFlowAsync(body.Email, cancellationToken);
5252

5353
return Ok();
5454
}

0 commit comments

Comments
 (0)