From 263d5e8b0871291e95e5afe12380fe68c7331ef8 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sun, 12 Jul 2026 08:06:18 -0300 Subject: [PATCH] fix(api): clear Google OAuth tokens on account deactivation Deactivate() left the encrypted Google access + refresh tokens (and GoogleCalendarAutoSyncEnabled) populated on the deactivated row for the 7-day deletion grace, keeping a refreshable OAuth secret alive on an account otherwise hidden by the #324 global query filter. Null the tokens and disable auto-sync at the deactivation transition, mirroring the existing MarkCalendarSyncReconnectRequired/ResetAccount convention; reactivation re-establishes them via SetGoogleTokens when the client re-grants calendar access. FriendGraphService.ResolveTargetAsync already excludes deactivated users (it uses the filtered FindAsync, not IgnoreQueryFilters) - added a regression test locking that behaviour rather than a source change. Refs thomasluizon/orbit-ui-mobile#243 Co-Authored-By: Claude Opus 4.8 --- src/Orbit.Domain/Entities/User.cs | 3 + ...nfirmAccountDeletionCommandHandlerTests.cs | 30 +++++ .../Orbit.Domain.Tests/Entities/UserTests.cs | 30 +++++ .../FriendGraphServiceDeactivationTests.cs | 107 ++++++++++++++++++ 4 files changed, 170 insertions(+) create mode 100644 tests/Orbit.Infrastructure.Tests/Persistence/FriendGraphServiceDeactivationTests.cs diff --git a/src/Orbit.Domain/Entities/User.cs b/src/Orbit.Domain/Entities/User.cs index 0b7c0f69..ff9878a8 100644 --- a/src/Orbit.Domain/Entities/User.cs +++ b/src/Orbit.Domain/Entities/User.cs @@ -365,6 +365,9 @@ public void Deactivate(DateTime scheduledDeletion) IsDeactivated = true; DeactivatedAt = DateTime.UtcNow; ScheduledDeletionAt = scheduledDeletion; + GoogleAccessToken = null; + GoogleRefreshToken = null; + GoogleCalendarAutoSyncEnabled = false; } public void CancelDeactivation() diff --git a/tests/Orbit.Application.Tests/Commands/Auth/ConfirmAccountDeletionCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Auth/ConfirmAccountDeletionCommandHandlerTests.cs index 4b0bcee2..c58cd2e6 100644 --- a/tests/Orbit.Application.Tests/Commands/Auth/ConfirmAccountDeletionCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Auth/ConfirmAccountDeletionCommandHandlerTests.cs @@ -41,6 +41,36 @@ public async Task Handle_ValidCode_DeactivatesUserAndReturnsScheduledDate() _cache.TryGetValue($"delete:{TestEmail}", out _).Should().BeFalse(); } + [Fact] + public async Task Handle_ValidCode_ClearsGoogleOAuthTokens() + { + var user = User.Create("Test", TestEmail).Value; + user.SetGoogleTokens("access-token", "refresh-token"); + SetupUser(user); + SetupDeletionCode(TestEmail, "123456"); + + var result = await _handler.Handle(new ConfirmAccountDeletionCommand(UserId, "123456"), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + user.GoogleAccessToken.Should().BeNull(); + user.GoogleRefreshToken.Should().BeNull(); + } + + [Fact] + public async Task Handle_InvalidCode_LeavesGoogleOAuthTokensIntact() + { + var user = User.Create("Test", TestEmail).Value; + user.SetGoogleTokens("access-token", "refresh-token"); + SetupUser(user); + SetupDeletionCode(TestEmail, "123456"); + + var result = await _handler.Handle(new ConfirmAccountDeletionCommand(UserId, "999999"), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + user.GoogleAccessToken.Should().Be("access-token"); + user.GoogleRefreshToken.Should().Be("refresh-token"); + } + [Fact] public async Task Handle_InvalidCode_ReturnsFailureAndIncrementsAttempts() { diff --git a/tests/Orbit.Domain.Tests/Entities/UserTests.cs b/tests/Orbit.Domain.Tests/Entities/UserTests.cs index cef623af..7ce03bcb 100644 --- a/tests/Orbit.Domain.Tests/Entities/UserTests.cs +++ b/tests/Orbit.Domain.Tests/Entities/UserTests.cs @@ -903,4 +903,34 @@ public void CompleteOnboardingChecklist_SetsFlagAndIsIdempotent() user.HasCompletedOnboardingChecklist.Should().BeTrue(); } + + [Fact] + public void Deactivate_ClearsGoogleCalendarConnection() + { + var user = CreateValidUser(); + typeof(User).GetProperty(nameof(User.IsLifetimePro))!.SetValue(user, true); + user.SetGoogleTokens("access-token", "refresh-token"); + user.EnableCalendarAutoSync().IsSuccess.Should().BeTrue(); + + user.Deactivate(DateTime.UtcNow.AddDays(7)); + + user.IsDeactivated.Should().BeTrue(); + user.GoogleAccessToken.Should().BeNull(); + user.GoogleRefreshToken.Should().BeNull(); + user.GoogleCalendarAutoSyncEnabled.Should().BeFalse(); + } + + [Fact] + public void CancelDeactivation_LeavesGoogleTokensCleared_ReconnectRequired() + { + var user = CreateValidUser(); + user.SetGoogleTokens("access-token", "refresh-token"); + user.Deactivate(DateTime.UtcNow.AddDays(7)); + + user.CancelDeactivation(); + + user.IsDeactivated.Should().BeFalse(); + user.GoogleAccessToken.Should().BeNull(); + user.GoogleRefreshToken.Should().BeNull(); + } } diff --git a/tests/Orbit.Infrastructure.Tests/Persistence/FriendGraphServiceDeactivationTests.cs b/tests/Orbit.Infrastructure.Tests/Persistence/FriendGraphServiceDeactivationTests.cs new file mode 100644 index 00000000..40697028 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/Persistence/FriendGraphServiceDeactivationTests.cs @@ -0,0 +1,107 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using NSubstitute; +using Orbit.Application.Social.Services; +using Orbit.Domain.Entities; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.Persistence; + +namespace Orbit.Infrastructure.Tests.Persistence; + +public class FriendGraphServiceDeactivationTests +{ + [Fact] + public async Task ResolveTargetAsync_ByHandle_ReturnsNullForDeactivatedUser() + { + var deactivated = DeactivatedUser("Gone", "handle@example.com", "gonehandle"); + var service = await CreateServiceAsync(deactivated); + + var resolved = await service.ResolveTargetAsync("gonehandle", null, CancellationToken.None); + + resolved.Should().BeNull(); + } + + [Fact] + public async Task ResolveTargetAsync_ByReferralCode_ReturnsNullForDeactivatedUser() + { + var deactivated = DeactivatedUser("Gone", "referral@example.com", "gonehandle2"); + deactivated.SetReferralCode("REF12345"); + var service = await CreateServiceAsync(deactivated); + + var resolved = await service.ResolveTargetAsync(null, "REF12345", CancellationToken.None); + + resolved.Should().BeNull(); + } + + [Fact] + public async Task ResolveTargetAsync_ByHandle_ResolvesActiveUser() + { + var active = ActiveUser("Active", "active@example.com", "activehandle"); + var service = await CreateServiceAsync(active); + + var resolved = await service.ResolveTargetAsync("activehandle", null, CancellationToken.None); + + resolved.Should().NotBeNull(); + resolved!.Id.Should().Be(active.Id); + } + + [Fact] + public async Task ResolveTargetAsync_ByReferralCode_ResolvesActiveUser() + { + var active = ActiveUser("Active", "active-ref@example.com", "activehandle2"); + active.SetReferralCode("ACTIVE01"); + var service = await CreateServiceAsync(active); + + var resolved = await service.ResolveTargetAsync(null, "ACTIVE01", CancellationToken.None); + + resolved.Should().NotBeNull(); + resolved!.Id.Should().Be(active.Id); + } + + private static User ActiveUser(string name, string email, string handle) + { + var user = User.Create(name, email).Value; + user.SetHandle(handle).IsSuccess.Should().BeTrue(); + return user; + } + + private static User DeactivatedUser(string name, string email, string handle) + { + var user = ActiveUser(name, email, handle); + user.Deactivate(DateTime.UtcNow.AddDays(7)); + return user; + } + + private static async Task CreateServiceAsync(params User[] users) + { + var context = CreateContext(); + context.Users.AddRange(users); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + return new FriendGraphService( + new GenericRepository(context), + Substitute.For>(), + Substitute.For>()); + } + + private static OrbitDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .ReplaceService() + .Options; + + return new OrbitDbContext(options); + } + + private sealed class EncryptionAwareModelCacheKeyFactory : IModelCacheKeyFactory + { + public object Create(DbContext context, bool designTime) + { + var hasEncryption = context is OrbitDbContext orbit && orbit.HasEncryptionService; + return (context.GetType(), hasEncryption, designTime); + } + } +}