diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 5531e8f5c..28c3db5f3 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1914,7 +1914,7 @@ struct AppScene: View { private func retryPendingPaykitEndpointRemoval() async { guard let session = pubkyProfile.automaticPaykitSession else { return } - await ContactPaymentsService.reconcilePendingEndpoints { + await ContactPaymentsService.reconcilePendingEndpoints(reconcilePrivate: { guard pubkyProfile.automaticPaykitSession == session else { return } let privateCleanupPending = UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey) await PrivatePaykitService.shared.retryPendingEndpointReconciliation( @@ -1924,7 +1924,7 @@ struct AppScene: View { if privateCleanupPending, !UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey) { PublicPaykitService.setCleanupPending(true) } - + }) { if PublicPaykitService.isCleanupPending, pubkyProfile.automaticPaykitSession == session { do { switch PublicPaykitService.pendingReconciliationMode() { diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index ed4076ac4..9facbd3b3 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -60,6 +60,7 @@ class PubkyProfileManager: ObservableObject { typealias RemoteProfileResolver = @Sendable (String) async throws -> PubkyProfile typealias ProfilePublisher = @Sendable (_ profile: PubkyProfileData, _ expectedIdentity: String?) async throws -> Void typealias AvatarUploader = @Sendable (_ jpegData: Data, _ expectedIdentity: String?) async throws -> String + typealias ProfileDeleter = @Sendable (_ expectedIdentity: String) async throws -> Void private enum SessionInitializationMode { case userVisible @@ -114,9 +115,11 @@ class PubkyProfileManager: ObservableObject { private static var isRingAdoptionInFlight = false private static var sessionRevision = UUID() private static var sessionMutationCount = 0 + private static var isProfileDeletionInFlight = false private let remoteProfileResolver: RemoteProfileResolver private let profilePublisher: ProfilePublisher private let avatarUploader: AvatarUploader + private let profileDeleter: ProfileDeleter /// Bumped whenever a profile save starts, `profile` is written or the identity changes, so a remote read that started /// earlier is dropped. private var profileWriteGeneration = 0 @@ -134,7 +137,8 @@ class PubkyProfileManager: ObservableObject { private typealias RingIdentityLookup = (id: UUID, task: Task, loaders: RingIdentityLookupLoaders) private var ringIdentityLookups: [String: RingIdentityLookup] = [:] - private static func beginSessionMutation() { + private static func beginSessionMutation(allowDuringProfileDeletion: Bool = false) throws { + guard !isProfileDeletionInFlight || allowDuringProfileDeletion else { throw PubkyServiceError.identityChanged } sessionRevision = UUID() sessionMutationCount += 1 } @@ -150,11 +154,15 @@ class PubkyProfileManager: ObservableObject { }, avatarUploader: @escaping AvatarUploader = { try await PubkyService.uploadProfileAvatar(bytes: $0, contentType: "image/jpeg", expectedIdentity: $1) + }, + profileDeleter: @escaping ProfileDeleter = { + try await PubkyService.deletePaykitProfile(expectedIdentity: $0) } ) { self.remoteProfileResolver = remoteProfileResolver self.profilePublisher = profilePublisher self.avatarUploader = avatarUploader + self.profileDeleter = profileDeleter cachedName = UserDefaults.standard.string(forKey: Self.cachedNameKey) cachedImageUri = UserDefaults.standard.string(forKey: Self.cachedImageUriKey) cachedProfileOwner = UserDefaults.standard.string(forKey: Self.cachedProfileOwnerKey) @@ -209,7 +217,7 @@ class PubkyProfileManager: ObservableObject { await initializationTask.value return } - guard Self.sessionMutationCount == 0 else { return } + guard Self.sessionMutationCount == 0, !Self.isProfileDeletionInFlight else { return } isRestoringSession = true let revision = Self.sessionRevision let task = Task { @@ -217,7 +225,7 @@ class PubkyProfileManager: ObservableObject { initializationTask = nil isRestoringSession = false } - guard Self.sessionMutationCount == 0 else { return } + guard Self.sessionMutationCount == 0, !Self.isProfileDeletionInFlight else { return } await initializeSessionState(mode: mode, initializeSession: initializeSession) if case .automaticRecovery = mode, revision == Self.sessionRevision { completedRecoveryVersion += 1 @@ -364,7 +372,7 @@ class PubkyProfileManager: ObservableObject { /// Drops the adopted identity when its owner app has provably removed the shared record. func checkAdoptedSource() async { - guard let adopted = AdoptedPubkyReference.current, + guard !Self.isProfileDeletionInFlight, let adopted = AdoptedPubkyReference.current, SharedPubkyKeychain.isDefinitelyMissing(sourceApp: adopted.sourceApp, pubky: adopted.pubky) else { return } @@ -457,7 +465,7 @@ class PubkyProfileManager: ObservableObject { await Task.detached { PubkyProfileManager.activeSecretKeyHex() }.value } ) async throws { - Self.beginSessionMutation() + try Self.beginSessionMutation() defer { Self.endSessionMutation() } if let publicKey, isProfileSetupPending || AdoptedPubkyReference.current != nil { try await createProfile( @@ -560,8 +568,8 @@ class PubkyProfileManager: ObservableObject { guard !Self.isRingAdoptionInFlight else { throw PubkyServiceError.authFailed("Pubky Ring sign-in already in progress") } + try Self.beginSessionMutation() Self.isRingAdoptionInFlight = true - Self.beginSessionMutation() defer { Self.isRingAdoptionInFlight = false isAdoptingRingIdentity = false @@ -785,8 +793,8 @@ class PubkyProfileManager: ObservableObject { authorizationTimeout: Duration = .seconds(30) ) async throws { guard !isSignupInFlight else { throw PubkySignupError.inProgress } + try Self.beginSessionMutation() isSignupInFlight = true - Self.beginSessionMutation() let revision = Self.sessionRevision defer { isSignupInFlight = false @@ -979,10 +987,17 @@ class PubkyProfileManager: ObservableObject { return true } - func performProfileDeletion(_ operation: () async throws -> Void) async rethrows { - guard !isDeletingProfile else { return } + func performProfileDeletion(_ operation: () async throws -> Void) async throws { + try Task.checkCancellation() + guard !Self.isProfileDeletionInFlight, Self.sessionMutationCount == 0, initializationTask == nil else { + throw PubkyServiceError.identityChanged + } + Self.isProfileDeletionInFlight = true isDeletingProfile = true - defer { isDeletingProfile = false } + defer { + isDeletingProfile = false + Self.isProfileDeletionInFlight = false + } await PrivatePaykitService.shared.beginProfileDeletion() do { try await operation() @@ -993,13 +1008,33 @@ class PubkyProfileManager: ObservableObject { await PrivatePaykitService.shared.endProfileDeletion() } - func deleteProfile(cleanPrivatePaykitEndpoints: Bool = true) async throws { + func deleteProfile(cleanPrivatePaykitEndpoints: Bool = true, expectedSession: SignedInSession? = nil) async throws { + guard let session = expectedSession ?? currentSession else { throw PubkyServiceError.sessionNotActive } + try requireCurrentSession(session) + if expectedSession != nil { + // The UI supplies its captured session from inside performProfileDeletion. + guard isDeletingProfile else { throw PubkyServiceError.identityChanged } + try await deleteProfile(for: session, cleanPrivatePaykitEndpoints: cleanPrivatePaykitEndpoints) + } else { + try await performProfileDeletion { + try await deleteProfile(for: session, cleanPrivatePaykitEndpoints: cleanPrivatePaykitEndpoints) + } + } + } + + private func deleteProfile(for session: SignedInSession, cleanPrivatePaykitEndpoints: Bool) async throws { + try requireCurrentSession(session) if cleanPrivatePaykitEndpoints { - await Self.removePrivatePaykitEndpointsBestEffort(context: "PubkyProfileManager.deleteProfile") + await Self.removePrivatePaykitEndpointsBestEffort( + context: "PubkyProfileManager.deleteProfile", deferAppSync: true, + isSessionCurrent: { self.currentSession == session } + ) } + try requireCurrentSession(session) + let delete = profileDeleter do { try await Task.detached { - try await PubkyService.deletePaykitProfile() + try await delete(session.publicKey) }.value } catch { guard Self.isMissingBitkitProfileStorageError(error) else { @@ -1009,9 +1044,10 @@ class PubkyProfileManager: ObservableObject { Logger.info("Bitkit profile storage already missing, continuing sign out", context: "PubkyProfileManager") } + try requireCurrentSession(session) invalidateProfileLoads() Self.clearPaykitSharingAfterProfileDeletion() - try await signOut(cleanPrivatePaykitEndpoints: false) + try await signOut(cleanPrivatePaykitEndpoints: false, allowDuringProfileDeletion: true) } private func writeProfile( @@ -1346,7 +1382,8 @@ class PubkyProfileManager: ObservableObject { // MARK: - Sign Out static func clearLocalState() async { - beginSessionMutation() + // A wallet reset may revoke deletion's owner, but no replacement identity can start until deletion drains. + try? beginSessionMutation(allowDuringProfileDeletion: true) defer { endSessionMutation() } do { try await PubkyService.forgetSessionAccess() @@ -1412,32 +1449,45 @@ class PubkyProfileManager: ObservableObject { } } - static func removePrivatePaykitEndpoints(context: String) async throws { + static func removePrivatePaykitEndpoints( + context: String, deferAppSync: Bool = false, isSessionCurrent: (@MainActor () -> Bool)? = nil + ) async throws { + try await removePrivatePaykitEndpoints(context: context, isSessionCurrent: isSessionCurrent) { + try await PrivatePaykitService.shared.removePublishedEndpoints(isSessionCurrent: isSessionCurrent, deferAppSync: deferAppSync) + } + } + + static func removePrivatePaykitEndpoints( + context: String, isSessionCurrent: (@MainActor () -> Bool)?, operation: () async throws -> Void + ) async throws { + guard isSessionCurrent?() != false else { throw PubkyServiceError.identityChanged } do { - try await PrivatePaykitService.shared.removePublishedEndpoints() + try Task.checkCancellation() + try await operation() + guard isSessionCurrent?() != false else { throw PubkyServiceError.identityChanged } + try Task.checkCancellation() PrivatePaykitService.setContactSharingCleanupPending(false) } catch { - PrivatePaykitService.setContactSharingCleanupPending(true) + if isSessionCurrent?() != false { + PrivatePaykitService.setContactSharingCleanupPending(true) + } Logger.warn("Failed to remove private Paykit endpoints before clearing session: \(error)", context: context) throw error } } - static func removePrivatePaykitEndpointsBestEffort(context: String) async { - do { - try await removePrivatePaykitEndpoints(context: context) - PrivatePaykitService.setContactSharingCleanupPending(false) - } catch { - PrivatePaykitService.setContactSharingCleanupPending(true) - } + static func removePrivatePaykitEndpointsBestEffort( + context: String, deferAppSync: Bool = false, isSessionCurrent: (@MainActor () -> Bool)? = nil + ) async { + try? await removePrivatePaykitEndpoints(context: context, deferAppSync: deferAppSync, isSessionCurrent: isSessionCurrent) } func signOut() async throws { try await signOut(cleanPrivatePaykitEndpoints: true) } - private func signOut(cleanPrivatePaykitEndpoints: Bool) async throws { - try await signOut { + private func signOut(cleanPrivatePaykitEndpoints: Bool, allowDuringProfileDeletion: Bool = false) async throws { + try await signOut(allowDuringProfileDeletion: allowDuringProfileDeletion) { if cleanPrivatePaykitEndpoints { try await Self.removePrivatePaykitEndpoints(context: "PubkyProfileManager.signOut") } @@ -1447,8 +1497,14 @@ class PubkyProfileManager: ObservableObject { } func signOut(performSessionCleanup: @escaping @Sendable () async throws -> Void) async throws { + try await signOut(allowDuringProfileDeletion: false, performSessionCleanup: performSessionCleanup) + } + + private func signOut( + allowDuringProfileDeletion: Bool, performSessionCleanup: @escaping @Sendable () async throws -> Void + ) async throws { let retainedProfile = hasReadOnlyProfileIdentity ? readOnlyProfile : nil - Self.beginSessionMutation() + try Self.beginSessionMutation(allowDuringProfileDeletion: allowDuringProfileDeletion) if let retainedProfile { readOnlySignOutRevision = Self.sessionRevision readOnlyProfile = (retainedProfile.publicKey, retainedProfile.profile, Self.sessionRevision) @@ -1653,6 +1709,11 @@ class PubkyProfileManager: ObservableObject { return SignedInSession(publicKey: publicKey, revision: Self.sessionRevision) } + func requireCurrentSession(_ session: SignedInSession) throws { + try Task.checkCancellation() + guard currentSession == session else { throw PubkyServiceError.identityChanged } + } + var automaticPaykitSession: SignedInSession? { guard authState == .authenticated, !isDeletingProfile else { return nil } return currentSession @@ -1782,7 +1843,7 @@ class PubkyProfileManager: ObservableObject { try await PubkyService.signIn(secretKeyHex: $0) } ) async throws { - await beginSessionMutation() + try await beginSessionMutation() do { do { try await forgetSessionAccess() diff --git a/Bitkit/Services/ContactPaymentsService.swift b/Bitkit/Services/ContactPaymentsService.swift index 6b5a7baff..1c6c21870 100644 --- a/Bitkit/Services/ContactPaymentsService.swift +++ b/Bitkit/Services/ContactPaymentsService.swift @@ -259,10 +259,12 @@ enum ContactPaymentsService { } @MainActor - static func reconcilePendingEndpoints(_ reconcile: () async -> Void) async { + static func reconcilePendingEndpoints(reconcilePrivate: () async -> Void = {}, _ reconcile: () async -> Void) async { guard !isOperationActive, !Task.isCancelled else { return } isOperationActive = true defer { releaseOperation() } + await reconcilePrivate() + guard await !PrivatePaykitService.shared.isDeletingProfile, !Task.isCancelled else { return } await reconcile() } diff --git a/Bitkit/Services/PrivatePaykitService+Contacts.swift b/Bitkit/Services/PrivatePaykitService+Contacts.swift index 8b3348470..84311dbe1 100644 --- a/Bitkit/Services/PrivatePaykitService+Contacts.swift +++ b/Bitkit/Services/PrivatePaykitService+Contacts.swift @@ -344,19 +344,27 @@ extension PrivatePaykitService { ) } - func removePublishedEndpoints(for publicKeys: [String]? = nil, isSessionCurrent: (@MainActor () -> Bool)? = nil) async throws { - try await removePublishedEndpoints(for: publicKeys, isSessionCurrent: isSessionCurrent, operations: EndpointCleanupOperations( - linkedPeers: { try await PaykitSdkService.shared.linkedPeers() }, - clearPaymentLists: { try await PaykitSdkService.shared.clearPrivatePaymentLists(to: $0, isSessionCurrent: isSessionCurrent) }, - drainMessages: { await self.drainPendingPrivateMessages(reason: "cleanup", advancing: $0) }, - pendingDrainKeys: { await self.pendingPrivateMessageDrainKeys($0) }, - syncApp: { try await PublicPaykitService.syncPaykitApp() } - )) + func removePublishedEndpoints( + for publicKeys: [String]? = nil, isSessionCurrent: (@MainActor () -> Bool)? = nil, deferAppSync: Bool = false + ) async throws { + try await removePublishedEndpoints( + for: publicKeys, + isSessionCurrent: isSessionCurrent, + deferAppSync: deferAppSync, + operations: EndpointCleanupOperations( + linkedPeers: { try await PaykitSdkService.shared.linkedPeers() }, + clearPaymentLists: { try await PaykitSdkService.shared.clearPrivatePaymentLists(to: $0, isSessionCurrent: isSessionCurrent) }, + drainMessages: { await self.drainPendingPrivateMessages(reason: "cleanup", advancing: $0) }, + pendingDrainKeys: { await self.pendingPrivateMessageDrainKeys($0) }, + syncApp: { try await PublicPaykitService.syncPaykitApp() } + ) + ) } func removePublishedEndpoints( for publicKeys: [String]? = nil, isSessionCurrent: (@MainActor () -> Bool)? = nil, + deferAppSync: Bool = false, operations: EndpointCleanupOperations ) async throws { let revision = publicKeys != nil && isSessionCurrent != nil ? savedContactsRevision : nil @@ -376,7 +384,8 @@ extension PrivatePaykitService { throw PubkyServiceError.sessionNotActive } try await removePublishedEndpointsLocked( - for: publicKeys, isSessionCurrent: isSessionCurrent, revision: revision, generation: generation, operations: operations + for: publicKeys, isSessionCurrent: isSessionCurrent, revision: revision, generation: generation, + deferAppSync: deferAppSync, operations: operations ) } } catch { @@ -391,7 +400,7 @@ extension PrivatePaykitService { private func removePublishedEndpointsLocked( for publicKeys: [String]?, isSessionCurrent: (@MainActor () -> Bool)?, revision: Int?, generation: Int, - operations: EndpointCleanupOperations + deferAppSync: Bool, operations: EndpointCleanupOperations ) async throws { let peers = try await operations.linkedPeers() let linkedPublicKeys = Set(peers @@ -491,7 +500,12 @@ extension PrivatePaykitService { if let firstError { throw firstError } - try await operations.syncApp() + if deferAppSync { + // Final profile cleanup synchronizes the registry; retain repair if deletion stops before it. + PublicPaykitService.setCleanupPending(true) + } else { + try await operations.syncApp() + } } func privatePaymentListCleanupKeys(_ publicKeys: [String], linkedPublicKeys: Set) -> [String] { diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index b83a45334..a5e73e52a 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -346,8 +346,8 @@ enum PubkyService { try await PaykitSdkService.shared.uploadAvatar(bytes: bytes, contentType: contentType, expectedIdentity: expectedIdentity) } - static func deletePaykitProfile() async throws { - try await PaykitSdkService.shared.deletePaykitProfile() + static func deletePaykitProfile(expectedIdentity: String? = nil) async throws { + try await PaykitSdkService.shared.deletePaykitProfile(expectedIdentity: expectedIdentity) } // MARK: - Contacts @@ -804,8 +804,11 @@ actor PaykitSdkService { return record.uri } - func deletePaykitProfile() async throws { + func deletePaykitProfile(expectedIdentity: String? = nil) async throws { try await withStateRevisionTracking { sdk in + if let expectedIdentity { + try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) + } guard let publicKey = try await sdk.identityStatus()?.publicKey else { throw PubkyServiceError.sessionNotActive } guard let current = try await sdk.fetchPaykitProfile(publicKey: publicKey) else { return } try await sdk.deletePaykitProfile(expectedRevision: current.revision) diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index eb583459b..a90fe26cf 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -146,21 +146,31 @@ struct EditProfileView: View { private func deleteProfile() async { guard !isDeleting else { return } + guard let session = pubkyProfile.currentSession else { + showDeleteFailureOptions = true + return + } isDeleting = true - await pubkyProfile.performProfileDeletion { - await deleteProfileWithSessionRetry() + defer { isDeleting = false } + do { + try await pubkyProfile.performProfileDeletion { + await deleteProfileWithSessionRetry(session: session) + } + } catch { + guard !Task.isCancelled, pubkyProfile.currentSession == session else { return } + showDeleteFailureOptions = true } - isDeleting = false } - private func deleteProfileWithSessionRetry() async { + private func deleteProfileWithSessionRetry(session: PubkyProfileManager.SignedInSession) async { do { - try await performDeleteProfile() + try await performDeleteProfile(session: session) } catch { - if await pubkyProfile.refreshSessionIfPossible(after: error) { + guard !Task.isCancelled, pubkyProfile.publicKey == session.publicKey else { return } + if pubkyProfile.currentSession == session, await pubkyProfile.refreshSessionIfPossible(after: error) { do { - try await performDeleteProfile() + try await performDeleteProfile(session: session) return } catch { Logger.error("Failed to delete profile after session refresh: \(error)", context: "EditProfileView") @@ -169,14 +179,22 @@ struct EditProfileView: View { Logger.error("Failed to delete profile: \(error)", context: "EditProfileView") } - showDeleteFailureOptions = true + if !Task.isCancelled, pubkyProfile.publicKey == session.publicKey { + showDeleteFailureOptions = true + } } } - private func performDeleteProfile() async throws { - await PubkyProfileManager.removePrivatePaykitEndpointsBestEffort(context: "EditProfileView.deleteProfile") + private func performDeleteProfile(session: PubkyProfileManager.SignedInSession) async throws { + try pubkyProfile.requireCurrentSession(session) + await PubkyProfileManager.removePrivatePaykitEndpointsBestEffort( + context: "EditProfileView.deleteProfile", deferAppSync: true, + isSessionCurrent: { pubkyProfile.currentSession == session } + ) + try pubkyProfile.requireCurrentSession(session) await contactsManager.deleteAllContactsBestEffort() - try await pubkyProfile.deleteProfile(cleanPrivatePaykitEndpoints: false) + try pubkyProfile.requireCurrentSession(session) + try await pubkyProfile.deleteProfile(cleanPrivatePaykitEndpoints: false, expectedSession: session) navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] } diff --git a/BitkitTests/ContactPaymentsServiceTests.swift b/BitkitTests/ContactPaymentsServiceTests.swift index 6a106c77b..9aa56091e 100644 --- a/BitkitTests/ContactPaymentsServiceTests.swift +++ b/BitkitTests/ContactPaymentsServiceTests.swift @@ -594,6 +594,41 @@ final class ContactPaymentsServiceTests: XCTestCase { } } + func testForegroundReconciliationRetainsRegistryRepairDuringDeletionOrCancellation() async { + snapshotAppDefaultsDomain() + for interruption in ["deletion", "deletionAfterPrivate", "cancelAfterPrivate"] { + PublicPaykitService.setCleanupPending(true) + if interruption == "deletion" { + await PrivatePaykitService.shared.beginProfileDeletion() + } + var registryUpdates = 0 + let reconciliation = Task { + await ContactPaymentsService.reconcilePendingEndpoints(reconcilePrivate: { + if interruption == "deletionAfterPrivate" { + await PrivatePaykitService.shared.beginProfileDeletion() + } + if interruption == "cancelAfterPrivate" { + withUnsafeCurrentTask { $0?.cancel() } + } + }) { + registryUpdates += 1 + PublicPaykitService.setCleanupPending(false) + } + } + await reconciliation.value + await PrivatePaykitService.shared.endProfileDeletion() + XCTAssertEqual(registryUpdates, 0, interruption) + XCTAssertTrue(PublicPaykitService.isCleanupPending, interruption) + + await ContactPaymentsService.reconcilePendingEndpoints { + registryUpdates += 1 + PublicPaykitService.setCleanupPending(false) + } + XCTAssertEqual(registryUpdates, 1, interruption) + XCTAssertFalse(PublicPaykitService.isCleanupPending, interruption) + } + } + func testEnablingWaitsForSuccessfulOrFailedDisable() async throws { let ownerKey = try useLocalPubkySecretKey() for (disableUI, removalFails) in [(false, false), (false, true), (true, false), (true, true)] { diff --git a/BitkitTests/PaykitSdkIdentityCheckTests.swift b/BitkitTests/PaykitSdkIdentityCheckTests.swift index 39ff9fbaa..6683dbb1f 100644 --- a/BitkitTests/PaykitSdkIdentityCheckTests.swift +++ b/BitkitTests/PaykitSdkIdentityCheckTests.swift @@ -141,6 +141,31 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { XCTAssertEqual(sdk.writes, ["upload"]) } + func testProfileDeletionChecksItsOwnerBeforeReadingOrRemovingTheProfile() async throws { + for signedIn in [identityA, identityB, nil] { + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = signedIn + sdk.profileExists = true + let service = PaykitSdkService(sdkFactory: { sdk }) + do { + try await service.deletePaykitProfile(expectedIdentity: bareIdentityA) + XCTAssertEqual(signedIn, identityA) + } catch PubkyServiceError.identityChanged { + XCTAssertNotEqual(signedIn, identityA) + } + XCTAssertEqual(sdk.profileReads, signedIn == identityA ? 1 : 0) + XCTAssertEqual(sdk.writes, signedIn == identityA ? ["delete-profile:test"] : []) + if signedIn == identityA { + XCTAssertEqual(sdk.identitiesAtWrites, [identityA]) + } + } + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = identityA + let service = PaykitSdkService(sdkFactory: { sdk }) + try await service.deletePaykitProfile(expectedIdentity: identityA) + XCTAssertTrue(sdk.writes.isEmpty, "Already-missing profile cleanup is idempotent") + } + /// Without a live session, a profile or contact avatar upload for the signed-in identity fails with the SDK's own /// error, as it did before uploads were bound to an identity, so Edit Profile does not show a payment request error. /// The payment request upload still reports `requestUnavailable`. @@ -221,6 +246,14 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { } return false }), + ("profile deletion", { service in + try await service.deletePaykitProfile(expectedIdentity: identityA) + }, { error in + if case .identityChanged? = error as? PubkyServiceError { + return true + } + return false + }), ] for testCase in writes { let sdk = IdentitySwitchingSdk(noPointer: .init()) @@ -401,6 +434,8 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { private var recordedWrites: [String] = [] private var recordedIdentitiesAtWrites: [String?] = [] private var recordedContactReads = 0 + private var recordedProfileReads = 0 + private var storedProfileExists = false let contactRecordsGate = IdentityCheckGate() let contactRecordsStarted = IdentityCheckCallLog() let observationGate = IdentityCheckGate() @@ -431,6 +466,15 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { lock.withLock { recordedContactReads } } + var profileReads: Int { + lock.withLock { recordedProfileReads } + } + + var profileExists: Bool { + get { lock.withLock { storedProfileExists } } + set { lock.withLock { storedProfileExists = newValue } } + } + private func recordWrite(_ write: String) { lock.withLock { recordedWrites.append(write) @@ -502,7 +546,16 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { } override func fetchPaykitProfile(publicKey _: String) async throws -> PaykitProfileRecord? { - nil + lock.withLock { recordedProfileReads += 1 } + guard profileExists else { return nil } + return PaykitProfileRecord( + publicKey: identity ?? "", profile: testProfile, path: "/pub/paykit/profile.json", + revision: "test", updatedAt: "2026-10-02T00:00:00Z" + ) + } + + override func deletePaykitProfile(expectedRevision: String) async throws { + recordWrite("delete-profile:\(expectedRevision)") } override func publishPaykitProfile(profile: PaykitProfile, expectedRevision _: String?) async throws -> PaykitProfileRecord { diff --git a/BitkitTests/PrivatePaykitServiceTests.swift b/BitkitTests/PrivatePaykitServiceTests.swift index b3173485c..3740ad349 100644 --- a/BitkitTests/PrivatePaykitServiceTests.swift +++ b/BitkitTests/PrivatePaykitServiceTests.swift @@ -2089,7 +2089,7 @@ final class PrivatePaykitServiceTests: XCTestCase { func testGuardedCleanupChecksMembershipAndGenerationAfterAdmittedWithdrawal() async throws { let key = "pubky" + String(repeating: "y", count: 52) - for change in ["none", "readd", "identity"] { + for (change, deferAppSync) in ["none", "readd", "identity"].flatMap({ change in [false, true].map { (change, $0) } }) { let service = PrivatePaykitService() var state = PrivatePaykitService.ContactState() state.hasPublishedPrivatePaymentList = true @@ -2106,7 +2106,7 @@ final class PrivatePaykitServiceTests: XCTestCase { }, drainMessages: { _ in }, pendingDrainKeys: { _ in [] }, syncApp: {} ) let cleanup = Task { - try await service.removePublishedEndpoints(for: [key], isSessionCurrent: { true }, operations: operations) + try await service.removePublishedEndpoints(for: [key], isSessionCurrent: { true }, deferAppSync: deferAppSync, operations: operations) } await fulfillment(of: [admitted], timeout: 2) if change == "readd" { @@ -2177,10 +2177,53 @@ final class PrivatePaykitServiceTests: XCTestCase { XCTAssertTrue(retained?.hasPublishedPrivatePaymentList == true) } + func testDeferredCleanupLeavesDurableRegistryReconciliation() async throws { + let key = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + for sharingEnabled in [false, true] { + for deferAppSync in [false, true] { + UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey) + UserDefaults.standard.set(sharingEnabled, forKey: PrivatePaykitService.publishingEnabledKey) + PublicPaykitService.setCleanupPending(false) + let service = PrivatePaykitService() + var contact = PrivatePaykitService.ContactState() + contact.hasPublishedPrivatePaymentList = true + await service.setTestContactState(contact, publicKey: key) + PrivatePaykitService.markDeletedContactCleanupPending([key]) + var withdrawals = 0 + var registryUpdates = 0 + try await service.removePublishedEndpoints(deferAppSync: deferAppSync, operations: .init( + linkedPeers: { [] }, + clearPaymentLists: { keys in + XCTAssertEqual(keys, [key]) + withdrawals += 1 + return .init( + queued: [], cleared: [.init(counterparty: key, outboundMessageId: 1, error: nil)], + failedToQueue: [], failedToDeliver: [] + ) + }, + drainMessages: { _ in XCTFail("Delivered withdrawal needs no drain") }, + pendingDrainKeys: { _ in [] }, + syncApp: { registryUpdates += 1 } + )) + XCTAssertEqual(withdrawals, 1) + XCTAssertEqual(registryUpdates, deferAppSync ? 0 : 1) + XCTAssertEqual(PublicPaykitService.isCleanupPending, deferAppSync) + XCTAssertFalse(PrivatePaykitService.pendingDeletedContactCleanupKeys().contains(key)) + let restored = PrivatePaykitService() + let retained = await restored.testContactState(publicKey: key) + XCTAssertFalse(retained?.hasPublishedPrivatePaymentList == true) + XCTAssertEqual(PublicPaykitService.isCleanupPending, deferAppSync) + } + } + } + func testCleanupFailuresKeepRegistryReconciliationPending() async throws { let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" UserDefaults.standard.set(false, forKey: PrivatePaykitService.publishingEnabledKey) - for failureStage in ["lookup", "withdraw", "queue", "pending", "registry"] { + let cases = ["lookup", "withdraw", "queue", "pending", "cancel", "registry"].flatMap { stage in + (stage == "registry" ? [false] : [false, true]).map { (stage, $0) } + } + for (failureStage, deferAppSync) in cases { let service = PrivatePaykitService() var contactState = PrivatePaykitService.ContactState() contactState.hasPublishedPrivatePaymentList = true @@ -2197,6 +2240,9 @@ final class PrivatePaykitServiceTests: XCTestCase { return [] }, clearPaymentLists: { _ in + if shouldFail, failureStage == "cancel" { + throw CancellationError() + } if shouldFail, failureStage == "withdraw" { throw PrivatePaykitError.privateUnavailable } @@ -2226,7 +2272,7 @@ final class PrivatePaykitServiceTests: XCTestCase { ) do { - try await service.removePublishedEndpoints(for: [publicKey], operations: operations) + try await service.removePublishedEndpoints(for: [publicKey], deferAppSync: deferAppSync, operations: operations) XCTFail("Expected \(failureStage) failure") } catch { XCTAssertTrue(PublicPaykitService.isCleanupPending) @@ -2322,7 +2368,7 @@ final class PrivatePaykitServiceTests: XCTestCase { func testBatchCleanupRetainsOnlyFailedContacts() async throws { let failed = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" let successful = "pubky5rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" - for failQueue in [true, false] { + for (failQueue, deferAppSync) in [true, false].flatMap({ failQueue in [false, true].map { (failQueue, $0) } }) { let service = PrivatePaykitService() var contact = PrivatePaykitService.ContactState() contact.hasPublishedPrivatePaymentList = true @@ -2332,7 +2378,7 @@ final class PrivatePaykitServiceTests: XCTestCase { PrivatePaykitService.markDeletedContactCleanupPending([failed, successful]) var batches = 0 do { - try await service.removePublishedEndpoints(for: [failed, successful], operations: .init( + try await service.removePublishedEndpoints(for: [failed, successful], deferAppSync: deferAppSync, operations: .init( linkedPeers: { [] }, clearPaymentLists: { keys in batches += 1 diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index cf72f0465..26c79aeb9 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -57,20 +57,260 @@ final class PubkyProfileManagerTests: XCTestCase { } @MainActor - func testEndingDeletionDoesNotRestoreAnEndedOrChangedSession() async { + func testEndingDeletionDoesNotRestoreAnEndedOrChangedSession() async throws { let manager = PubkyProfileManager() manager.publicKey = "pubky" + String(repeating: "y", count: 52) manager.authState = .authenticated let original = manager.automaticPaykitSession - await manager.performProfileDeletion { + try await manager.performProfileDeletion { manager.publicKey = "pubky" + String(repeating: "o", count: 52) XCTAssertNil(manager.automaticPaykitSession) } XCTAssertNotEqual(manager.automaticPaykitSession, original) - await manager.performProfileDeletion { manager.authState = .idle } + try await manager.performProfileDeletion { manager.authState = .idle } XCTAssertNil(manager.automaticPaykitSession) } + @MainActor + func testProfileDeletionRefusesIdentityChangesBeforeTheyTouchSessionState() async throws { + try await withEmptyIdentityStorage { + UserDefaults.standard.set(false, forKey: "pubky_profile_setup_pending") + for mutation in ["adoption", "creation", "disconnect", "restore"] { + let manager = PubkyProfileManager() + manager.publicKey = "pubky" + String(repeating: "y", count: 52) + manager.authState = .authenticated + let session = try XCTUnwrap(manager.currentSession) + + try await manager.performProfileDeletion { + try Task.checkCancellation() + do { + switch mutation { + case "adoption": + _ = try await manager.adoptRingIdentity( + pubky: String(repeating: "o", count: 52), + loadSecret: { _, _ in + XCTFail("Deletion must reject adoption before reading another identity") + return nil + } + ) + case "creation": + try await manager.createIdentity(name: "Replacement", bio: "", links: [], loadStoredSecretKey: { + XCTFail("Deletion must reject creation before loading credentials") + throw CancellationError() + }) + case "disconnect": + try await manager.signOut(performSessionCleanup: { + XCTFail("An unrelated disconnect must not enter deletion's session") + throw CancellationError() + }) + default: + try await PubkyProfileManager.restoreSessionBackupState( + nil, + deleteKeychainValue: { _ in XCTFail("Deletion must retain its credentials") }, + removeOwnSharedRecords: { XCTFail("Deletion must retain its shared identity") }, + forgetSessionAccess: { XCTFail("Deletion must reject restore before ending its session") } + ) + } + XCTFail("Identity mutation must be rejected: \(mutation)") + } catch { + guard case PubkyServiceError.identityChanged = error else { + XCTFail("Unexpected error for \(mutation): \(error)") + return + } + } + XCTAssertEqual(manager.currentSession, session, mutation) + } + XCTAssertEqual(manager.currentSession, session, mutation) + XCTAssertFalse(manager.isDeletingProfile, mutation) + } + } + } + + @MainActor + func testProfileDeletionDoesNotOverlapSessionInitialization() async throws { + try await withEmptyIdentityStorage { + let manager = PubkyProfileManager() + let started = expectation(description: "Session initialization started") + let (resume, continuation) = AsyncStream.makeStream() + defer { continuation.finish() } + let initialization = Task { + await manager.initialize { + started.fulfill() + for await _ in resume {} + return .noSession + } + } + await fulfillment(of: [started], timeout: 2) + do { + try await manager.performProfileDeletion { XCTFail("Deletion must wait for session initialization") } + XCTFail("Expected deletion to be refused") + } catch PubkyServiceError.identityChanged {} + continuation.finish() + await initialization.value + try await manager.performProfileDeletion { + await manager.initialize { + XCTFail("Session initialization must wait for deletion") + return .noSession + } + } + let resumed = expectation(description: "Session initialization resumed") + await manager.initialize { + resumed.fulfill() + return .noSession + } + await fulfillment(of: [resumed], timeout: 2) + } + } + + @MainActor + func testPrivateCleanupRetainsTheCurrentOwnersRepairMarker() async throws { + snapshotAppDefaultsDomain() + for initiallyPending in [false, true] { + for outcome in ["success", "failure", "staleSuccess", "staleFailure", "notAdmitted", "cancelled"] { + var isCurrent = outcome != "notAdmitted" + UserDefaults.standard.set(initiallyPending, forKey: PrivatePaykitService.cleanupPendingKey) + do { + try await PubkyProfileManager.removePrivatePaykitEndpoints(context: "test", isSessionCurrent: { isCurrent }) { + XCTAssertNotEqual(outcome, "notAdmitted") + if outcome.hasPrefix("stale") { + isCurrent = false + } + if outcome == "failure" || outcome == "staleFailure" { + throw PublicPaykitError.publicationFailed + } + if outcome == "cancelled" { + throw CancellationError() + } + } + XCTAssertEqual(outcome, "success") + } catch { + XCTAssertNotEqual(outcome, "success") + } + let expected = !isCurrent ? initiallyPending : outcome != "success" + XCTAssertEqual(UserDefaults.standard.bool(forKey: PrivatePaykitService.cleanupPendingKey), expected, outcome) + } + } + } + + @MainActor + func testDefaultProfileDeletionKeepsOwnershipUntilItsAdmittedWriteDrains() async throws { + try await withEmptyIdentityStorage { + for outcome in ["failure", "cancellation", "ownerChanged"] { + let original = "pubky" + String(repeating: "y", count: 52) + let started = expectation(description: "Profile delete admitted") + let (resume, continuation) = AsyncStream.makeStream() + defer { continuation.finish() } + let manager = PubkyProfileManager(profileDeleter: { expectedIdentity in + XCTAssertEqual(expectedIdentity, original) + started.fulfill() + for await _ in resume {} + if outcome == "failure" { + throw PublicPaykitError.publicationFailed + } + }) + manager.publicKey = original + manager.authState = .authenticated + let session = try XCTUnwrap(manager.currentSession) + UserDefaults.standard.set(true, forKey: PublicPaykitService.publishingEnabledKey) + UserDefaults.standard.set(true, forKey: PrivatePaykitService.publishingEnabledKey) + let deletion = Task { try await manager.deleteProfile(cleanPrivatePaykitEndpoints: false) } + await fulfillment(of: [started], timeout: 5) + XCTAssertTrue(manager.isDeletingProfile) + let overlapRejected = expectation(description: "Overlapping deletion refused") + let overlappingDeletion = Task { + do { + try await manager.deleteProfile(cleanPrivatePaykitEndpoints: false) + XCTFail("A separate deletion must not share another call's admission") + } catch PubkyServiceError.identityChanged { + } catch { + XCTFail("Unexpected overlapping-deletion error: \(error)") + } + overlapRejected.fulfill() + } + await fulfillment(of: [overlapRejected], timeout: 2) + if outcome == "cancellation" { + deletion.cancel() + } + if outcome == "ownerChanged" { + manager.publicKey = "pubky" + String(repeating: "o", count: 52) + } + do { + _ = try await manager.adoptRingIdentity(pubky: original, loadSecret: { _, _ in + XCTFail("Replacement must wait for admitted deletion to drain") + return nil + }) + XCTFail("Expected adoption to be refused") + } catch PubkyServiceError.identityChanged { + } catch { + XCTFail("Unexpected adoption error during deletion: \(error)") + } + XCTAssertTrue(manager.isDeletingProfile, "Cancellation must not release admitted work") + continuation.finish() + await overlappingDeletion.value + do { + try await deletion.value + XCTFail("Expected deletion to stop before clearing sharing or signing out") + } catch { + switch outcome { + case "failure": XCTAssertTrue(error is PublicPaykitError) + case "cancellation": XCTAssertTrue(error is CancellationError) + default: + guard case PubkyServiceError.identityChanged = error else { + return XCTFail("Expected stale deletion to be refused, got \(error)") + } + } + } + XCTAssertTrue(UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey)) + XCTAssertTrue(UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey)) + XCTAssertFalse(manager.isDeletingProfile) + if outcome != "ownerChanged" { + XCTAssertEqual(manager.currentSession, session) + } + var readSecret = false + do { + _ = try await manager.adoptRingIdentity(pubky: original, loadSecret: { _, _ in + readSecret = true + return nil + }) + XCTFail("The fixture has no Ring credential") + } catch PubkyServiceError.authFailed {} + XCTAssertTrue(readSecret, "Identity admission resumes after deletion drains") + } + } + } + + @MainActor + func testDeletionSessionRejectsAReplacementAndANewSessionForTheSameKey() async throws { + try await withEmptyIdentityStorage { + let secret = String(repeating: "01", count: 32) + let original = try PubkyProfileManager.publicKeyFromSecretKey(secret) + let manager = PubkyProfileManager(profileDeleter: { _ in + XCTFail("A stale deletion must not reach profile storage") + }) + manager.publicKey = original + manager.authState = .authenticated + let session = try XCTUnwrap(manager.currentSession) + try manager.requireCurrentSession(session) + manager.publicKey = "pubky" + String(repeating: "o", count: 52) + XCTAssertThrowsError(try manager.requireCurrentSession(session)) + do { + try await manager.deleteProfile(cleanPrivatePaykitEndpoints: false, expectedSession: session) + XCTFail("Deletion must reject a replacement identity") + } catch PubkyServiceError.identityChanged {} + manager.publicKey = original + _ = try await manager.adoptRingIdentity( + pubky: original, loadSecret: { _, _ in secret }, signIn: { _ in }, fetchProfile: { _ in nil } + ) + XCTAssertEqual(manager.publicKey, original) + XCTAssertNotEqual(manager.currentSession, session) + XCTAssertThrowsError(try manager.requireCurrentSession(session)) + do { + try await manager.deleteProfile(cleanPrivatePaykitEndpoints: false, expectedSession: session) + XCTFail("Deletion must reject a new session for the same key") + } catch PubkyServiceError.identityChanged {} + } + } + @MainActor func testNavigationLookupReadsStoredIdentityWithoutCachedMetadata() async throws { let key = "5jsjx1o6fzu6aeeo697r3i5rx15zq41kikcye8wtwdqm4nb4tryo" diff --git a/BitkitTests/ReceiveInvoiceRefreshTests.swift b/BitkitTests/ReceiveInvoiceRefreshTests.swift index c9878ea41..def92296f 100644 --- a/BitkitTests/ReceiveInvoiceRefreshTests.swift +++ b/BitkitTests/ReceiveInvoiceRefreshTests.swift @@ -466,7 +466,7 @@ final class ReceiveInvoiceRefreshTests: XCTestCase { ) } await fulfillment(of: [creation.started], timeout: 5) - await profile.performProfileDeletion { + try await profile.performProfileDeletion { XCTAssertEqual(profile.currentSession, session) creation.release() do { diff --git a/changelog.d/next/979.changed.md b/changelog.d/next/979.changed.md new file mode 100644 index 000000000..a12c38327 --- /dev/null +++ b/changelog.d/next/979.changed.md @@ -0,0 +1 @@ +Profile deletion avoids an intermediate Paykit capability update, retains failed-operation recovery, and keeps cleanup bound to the identity being deleted. diff --git a/journeys/profile/README.md b/journeys/profile/README.md index 585925c34..3a6df3c72 100644 --- a/journeys/profile/README.md +++ b/journeys/profile/README.md @@ -2,8 +2,14 @@ `delete-profile.xml` verifies visible progress and completion when deleting a disposable profile. Use a test identity that may be deleted. +With network fault injection, interrupt profile deletion after private endpoint withdrawal but before profile deletion or final App Registry synchronization. Verify the existing cleanup marker survives restart, normal reconciliation uses the current sharing preference, and failed withdrawal or registry publication remains pending until a successful retry. Successful deletion must still remove public endpoints, synchronize the App Registry with private sharing disabled, and sign out. Ordinary disconnect, contact deletion and sharing-setting changes must continue to synchronize immediately. Use the same disposable identity and record confirmation-to-onboarding time before and after; do not shorten uncertain-write safety waits or treat a small request reduction as a general latency guarantee. + +While deletion is paused after private withdrawal, trigger foreground or network-restoration reconciliation. Verify it does not start public reconciliation or acknowledge the pending marker until deletion ends; already admitted work may complete. + During profile deletion, automatic contact refresh, request intake and endpoint publication must not start new work. Already admitted operations and explicit deletion cleanup still finish. After a failed deletion, normal automatic work must resume for the retained identity. Verifying SDK admission and a controlled cleanup failure requires operation gates beyond the journey runner capabilities; this remains a manual check rather than a timed deletion guarantee. +Hold private withdrawal or an admitted profile delete, then attempt Ring adoption, identity creation, Disconnect or session-backup restoration. Each must be refused before changing credentials or the active session; cancellation must not release that protection before admitted deletion work drains. After failure, verify identity operations and a new deletion attempt work again. Separately revoke the original session with a controlled hook, release cleanup, and verify the old attempt does not clear another owner's repair markers, contacts, profile or session. Repeat with a new session for the same key and inspect remote profile state, not only navigation. These checks require session-replacement and operation gates beyond the journey runner capabilities. + With a contact link held during deletion, let deletion succeed and verify that the retained link opens the public contact profile while signed out, without a QR error or a private contact-list read. For failed deletion, hold contact preparation until after cleanup fails, then release it and verify that the retained link retries once preparation is available without a repeated error loop. Controlling this ordering requires preparation and cleanup-failure gates beyond the journey runner capabilities. If public invoice replacement is deferred during deletion, make deletion fail and the first resumed publication fail transiently. Verify that public publication resumes without waiting for contact refresh to complete, remains pending after the publication error, and is retried by normal foreground maintenance without another foreground event or payment. After sign-out, identity replacement, cancellation or disabled public sharing, an older failed refresh must not mark new publication work pending. Controlled payment events and publication failures require gates beyond the journey runner capabilities. diff --git a/journeys/profile/delete-profile.xml b/journeys/profile/delete-profile.xml index 1bece0e9c..8c37b8ff8 100644 --- a/journeys/profile/delete-profile.xml +++ b/journeys/profile/delete-profile.xml @@ -3,6 +3,7 @@ Precondition: an onboarded disposable test wallet with Paykit enabled and a profile created through Bitkit. The profile and its contacts will be deleted; do not use a personal identity. Record the sequence so progress can be checked even when deletion completes quickly. + Start with zero contacts and record the deletion time without a fixed latency target. Repeat with 62 imported contacts, once after preparation settles and once immediately after import. Measure confirmation to onboarding and check logs for any uncertain-write safety wait. Cleanup may still wait for admitted operations and remote safety checks; no fixed duration is expected.