From 8d4ed2b4a23d8e4753e4e439a6e4a686546d57a8 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 25 Oct 2026 05:36:28 +0100 Subject: [PATCH 1/5] fix: stop profile deletion after incomplete cleanup --- Bitkit/Managers/ContactsManager.swift | 28 +------------ Bitkit/Managers/PubkyProfileManager.swift | 2 +- Bitkit/Views/Profile/EditProfileView.swift | 23 ++++++++--- BitkitTests/ContactsManagerTests.swift | 22 ++++++++++ BitkitTests/PubkyProfileManagerTests.swift | 43 ++++++++++++++++++++ changelog.d/next/profile-cleanup.fixed.md | 1 + journeys/profile/profile-cleanup-failure.xml | 18 ++++++++ 7 files changed, 103 insertions(+), 34 deletions(-) create mode 100644 changelog.d/next/profile-cleanup.fixed.md create mode 100644 journeys/profile/profile-cleanup-failure.xml diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index 4d3e7a5ad..3c2a5b595 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -969,22 +969,7 @@ class ContactsManager: ObservableObject { stopPendingProfileLookups() let contactRecords = contactRecords let removeContactRecords = removeContactRecords - let records: [ContactRecord] - do { - records = try await Task.detached { - try await contactRecords() - }.value - } catch { - guard Self.isMissingContactsDataError(error) else { - throw error - } - - Self.clearContactProfileOverrides() - await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: []) - contacts.removeAll() - Logger.info("Contacts storage missing, treating delete-all as empty", context: "ContactsManager") - return - } + let records = try await Task.detached { try await contactRecords() }.value let keys = records.compactMap { PubkyPublicKeyFormat.normalized($0.publicKey) } let removed = try await Task.detached { try await removeContactRecords(keys) }.value @@ -1003,17 +988,6 @@ class ContactsManager: ObservableObject { Logger.info("Deleted all contacts", context: "ContactsManager") } - func deleteAllContactsBestEffort() async { - do { - try await deleteAllContacts() - } catch { - Logger.warn("Continuing after contact cleanup failed: \(error)", context: "ContactsManager") - Self.clearContactProfileOverrides() - await PrivatePaykitService.shared.pruneUnsavedContactState(savedPublicKeys: []) - contacts.removeAll() - } - } - // MARK: - Remote Contact Discovery /// Looks every follow's profile up on the interactive read lane: the user waits on the choice screen until the import diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 5591d441a..458ef6f6e 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -1062,7 +1062,7 @@ class PubkyProfileManager: ObservableObject { private func deleteProfile(for session: SignedInSession, cleanPrivatePaykitEndpoints: Bool) async throws { try requireCurrentSession(session) if cleanPrivatePaykitEndpoints { - await Self.removePrivatePaykitEndpointsBestEffort( + try await Self.removePrivatePaykitEndpoints( context: "PubkyProfileManager.deleteProfile", deferAppSync: true, isSessionCurrent: { self.currentSession == session } ) diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index e9dbe433e..092006c0f 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -174,16 +174,27 @@ struct EditProfileView: View { } private func performDeleteProfile(session: PubkyProfileManager.SignedInSession) async throws { + try await Self.performDeleteProfile(session: session, pubkyProfile: pubkyProfile, contactsManager: contactsManager) { + try await PubkyProfileManager.removePrivatePaykitEndpoints( + context: "EditProfileView.deleteProfile", deferAppSync: true, + isSessionCurrent: { pubkyProfile.currentSession == session } + ) + } + navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] + } + + static func performDeleteProfile( + session: PubkyProfileManager.SignedInSession, + pubkyProfile: PubkyProfileManager, + contactsManager: ContactsManager, + removePrivateEndpoints: () async throws -> Void + ) async throws { try pubkyProfile.requireCurrentSession(session) - await PubkyProfileManager.removePrivatePaykitEndpointsBestEffort( - context: "EditProfileView.deleteProfile", deferAppSync: true, - isSessionCurrent: { pubkyProfile.currentSession == session } - ) + try await removePrivateEndpoints() try pubkyProfile.requireCurrentSession(session) - await contactsManager.deleteAllContactsBestEffort() + try await contactsManager.deleteAllContacts() try pubkyProfile.requireCurrentSession(session) try await pubkyProfile.deleteProfile(cleanPrivatePaykitEndpoints: false, expectedSession: session) - navigation.path = [app.hasSeenProfileIntro ? .pubkyChoice : .profileIntro] } private func disconnectAfterFailedDelete() async { diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index b53ddaf16..0323836fa 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -2243,6 +2243,28 @@ final class ContactsManagerTests: XCTestCase { XCTAssertTrue(manager.contacts.isEmpty) } + func testDeleteAllContactsPreservesEvidenceWhenTheContactReadFails() async throws { + snapshotAppDefaults("pubkyContactProfileOverrides") + let contact = makeContact(publicKey: contactProfileKey) + ContactsManager.restoreContactProfileOverrides([contact.publicKey: PubkyProfileData.from(profile: contact.profile)]) + let unavailable = AppError(message: "App Error", debugMessage: "Fetch failed: 404 missing session") + let manager = ContactsManager(contactRecords: { throw unavailable }, removeContactRecords: { _ in + XCTFail("An unavailable read must not authorize a removal") + return [] + }) + manager.contacts = [contact] + + do { + try await manager.deleteAllContacts() + XCTFail("An unavailable read is not an empty contact list") + } catch { + XCTAssertEqual(error.localizedDescription, unavailable.localizedDescription) + } + + XCTAssertEqual(manager.contacts, [contact]) + XCTAssertNotNil(ContactsManager.backupContactProfileOverrides()?[contact.publicKey]) + } + func testDeleteAllContactsPreservesContactsNotRemovedByTheBulkOperation() async throws { snapshotAppDefaults("pubkyContactProfileOverrides") let removed = contactRecord(key: contactProfileKey, name: "Removed") diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 14f4dfe65..bfadce3de 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -11,6 +11,49 @@ final class PubkyProfileManagerTests: XCTestCase { case signOut } + @MainActor + func testProfileRemovalStopsBeforeDeletingWhenCleanupIsIncomplete() async throws { + snapshotAppDefaultsDomain() + for failure in ["private endpoints", "contacts"] { + let key = "pubky" + String(repeating: "y", count: 52) + let originalError = KeychainError.failedToDelete + let manager = PubkyProfileManager(profileDeleter: { _ in + XCTFail("Incomplete cleanup must not delete the profile") + throw PublicPaykitError.publicationFailed + }) + manager.publicKey = key + manager.authState = .authenticated + let session = try XCTUnwrap(manager.currentSession) + let contact = Bitkit.PubkyProfile.placeholder(publicKey: key) + ContactsManager.restoreContactProfileOverrides([key: PubkyProfileData.from(profile: contact)]) + let contacts = ContactsManager(contactRecords: { + XCTAssertEqual(failure, "contacts", "Private withdrawal failure must stop before contact removal") + throw originalError + }) + contacts.contacts = [PubkyContact(publicKey: key, profile: contact)] + + do { + try await manager.performProfileDeletion { + try await EditProfileView.performDeleteProfile( + session: session, pubkyProfile: manager, contactsManager: contacts + ) { + if failure == "private endpoints" { + throw originalError + } + } + } + XCTFail("Incomplete cleanup must fail") + } catch { + XCTAssertTrue(error is KeychainError, "Preserve the cleanup failure, got \(error)") + } + + XCTAssertEqual(contacts.contacts.map(\.publicKey), [key]) + XCTAssertEqual(Set(ContactsManager.backupContactProfileOverrides()?.keys ?? [:].keys), [key]) + XCTAssertEqual(manager.currentSession, session) + XCTAssertFalse(manager.isDeletingProfile) + } + } + @MainActor func testProfileDeletionPausesAutomaticWorkWithoutEndingTheSession() async throws { for outcome in ["success", "failure", "cancellation"] { diff --git a/changelog.d/next/profile-cleanup.fixed.md b/changelog.d/next/profile-cleanup.fixed.md new file mode 100644 index 000000000..e4eaf87e3 --- /dev/null +++ b/changelog.d/next/profile-cleanup.fixed.md @@ -0,0 +1 @@ +Preserve contact data and stop profile deletion when payment-link or contact cleanup is incomplete. diff --git a/journeys/profile/profile-cleanup-failure.xml b/journeys/profile/profile-cleanup-failure.xml new file mode 100644 index 000000000..ab494d5d1 --- /dev/null +++ b/journeys/profile/profile-cleanup-failure.xml @@ -0,0 +1,18 @@ + + + Requires a disposable test identity with saved contacts and a controlled homeserver proxy. + Never use a personal profile. Inject a private-endpoint withdrawal failure, then in a fresh + fixture inject an unknown contact-removal response or a partial removal retaining an active peer. + These controls are not available in standard staging. This journey covers stopping on incomplete + cleanup, not cross-device conditional deletion or safe manual replay of an uncertain operation. + + + Open Edit Profile and confirm deletion with the cleanup fault enabled + Verify failure options appear without a success notification or onboarding navigation + Verify a failed private withdrawal is followed by no contact or profile deletion + Verify unknown contact removal keeps local contact data and prevents profile deletion + Verify partial contact removal preserves contacts and overrides not acknowledged as removed + Cancel the failure dialog and verify the app starts no second deletion + Restart the app and verify no profile deletion is automatically replayed + + From 76e0f8034705cae973b57665a90f324a3629f736 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 25 Oct 2026 05:37:07 +0100 Subject: [PATCH 2/5] chore: rename changelog fragment --- changelog.d/next/{profile-cleanup.fixed.md => 1124.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{profile-cleanup.fixed.md => 1124.fixed.md} (100%) diff --git a/changelog.d/next/profile-cleanup.fixed.md b/changelog.d/next/1124.fixed.md similarity index 100% rename from changelog.d/next/profile-cleanup.fixed.md rename to changelog.d/next/1124.fixed.md From a0626189c02388c9c7725893aecb263ebb829fce Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 25 Oct 2026 06:33:07 +0100 Subject: [PATCH 3/5] fix: preserve sessions after uncertain profile deletion --- Bitkit/Managers/PubkyProfileManager.swift | 39 ++------------ BitkitTests/PubkyProfileManagerTests.swift | 55 +++++++++++++------- changelog.d/next/1124.fixed.md | 2 +- journeys/profile/profile-cleanup-failure.xml | 2 + 4 files changed, 43 insertions(+), 55 deletions(-) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 458ef6f6e..54fb3f711 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -1069,17 +1069,9 @@ class PubkyProfileManager: ObservableObject { } try requireCurrentSession(session) let delete = profileDeleter - do { - try await Task.detached { - try await delete(session.publicKey) - }.value - } catch { - guard Self.isMissingBitkitProfileStorageError(error) else { - throw error - } - - Logger.info("Bitkit profile storage already missing, continuing sign out", context: "PubkyProfileManager") - } + try await Task.detached { + try await delete(session.publicKey) + }.value try requireCurrentSession(session) invalidateProfileLoads() @@ -1949,31 +1941,6 @@ class PubkyProfileManager: ObservableObject { return normalized } - nonisolated static func isMissingBitkitProfileStorageError(_ error: Error) -> Bool { - if case .profileNotFound = error as? PubkyServiceError { - return true - } - - let errorText = [ - (error as? AppError)?.debugMessage, - error.localizedDescription, - String(describing: error), - ] - .compactMap { $0?.lowercased() } - - if errorText.contains(where: { $0.contains("404 not found") || $0.contains("directory not found") }) { - return true - } - - let nsError = error as NSError - if nsError.domain == NSCocoaErrorDomain { - let cocoaCode = CocoaError.Code(rawValue: nsError.code) - return cocoaCode == .fileNoSuchFile || cocoaCode == .fileReadNoSuchFile - } - - return false - } - nonisolated static func resolveSessionInitialization( savedSessionSecret: String?, storedSecretKeyHex: String?, diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index bfadce3de..c15f25c33 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -322,6 +322,43 @@ final class PubkyProfileManagerTests: XCTestCase { } } + @MainActor + func testProfileDeletionRequiresAcknowledgedCompletionBeforeSignOut() async throws { + try await withEmptyIdentityStorage { + let errors: [NSError?] = [ + nil, + NSError(domain: "ProfileDeleteFixture", code: 404, + userInfo: [NSLocalizedDescriptionKey: "Delete failed: HTTP 404 Not Found at session endpoint"]), + NSError(domain: NSCocoaErrorDomain, code: CocoaError.fileReadNoSuchFile.rawValue), + ] + for failure in errors { + let manager = PubkyProfileManager(profileDeleter: { _ in + if let failure { + throw failure + } + }) + manager.publicKey = "pubky" + String(repeating: "y", count: 52) + manager.authState = .authenticated + let session = try XCTUnwrap(manager.currentSession) + UserDefaults.standard.set(true, forKey: PublicPaykitService.publishingEnabledKey) + UserDefaults.standard.set(true, forKey: PrivatePaykitService.publishingEnabledKey) + + do { + try await manager.deleteProfile(cleanPrivatePaykitEndpoints: false) + XCTAssertNil(failure, "An unavailable profile does not confirm its deletion") + } catch { + XCTAssertEqual((error as NSError).domain, failure?.domain) + XCTAssertEqual((error as NSError).code, failure?.code) + } + + XCTAssertEqual(manager.currentSession, failure == nil ? nil : session) + XCTAssertEqual(UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey), failure != nil) + XCTAssertEqual(UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey), failure != nil) + XCTAssertFalse(manager.isDeletingProfile) + } + } + } + @MainActor func testDeletionSessionRejectsAReplacementAndANewSessionForTheSameKey() async throws { try await withEmptyIdentityStorage { @@ -3484,24 +3521,6 @@ final class PubkyProfileManagerTests: XCTestCase { XCTAssertNil(resolved) } - func testIsMissingBitkitProfileStorageErrorRecognizes404DeleteFailure() { - let error = AppError( - message: "App Error", - debugMessage: #"BitkitCore.PubkyError.WriteFailed(reason: "delete failed: Request failed: Server responded with an error: 404 Not Found - Not Found")"# - ) - - XCTAssertTrue(PubkyProfileManager.isMissingBitkitProfileStorageError(error)) - } - - func testIsMissingBitkitProfileStorageErrorRejectsNonMissingErrors() { - let error = AppError( - message: "App Error", - debugMessage: #"BitkitCore.PubkyError.AuthFailed(reason: "Request failed: HTTP transport error")"# - ) - - XCTAssertFalse(PubkyProfileManager.isMissingBitkitProfileStorageError(error)) - } - // MARK: - Session backup state func testSnapshotSessionBackupStatePrefersLocalSeedOverSessionSecret() throws { diff --git a/changelog.d/next/1124.fixed.md b/changelog.d/next/1124.fixed.md index e4eaf87e3..f05f0a6f8 100644 --- a/changelog.d/next/1124.fixed.md +++ b/changelog.d/next/1124.fixed.md @@ -1 +1 @@ -Preserve contact data and stop profile deletion when payment-link or contact cleanup is incomplete. +Preserve session and contact data after failed profile deletion or incomplete payment-link cleanup. diff --git a/journeys/profile/profile-cleanup-failure.xml b/journeys/profile/profile-cleanup-failure.xml index ab494d5d1..f9fd8c2e2 100644 --- a/journeys/profile/profile-cleanup-failure.xml +++ b/journeys/profile/profile-cleanup-failure.xml @@ -3,6 +3,7 @@ Requires a disposable test identity with saved contacts and a controlled homeserver proxy. Never use a personal profile. Inject a private-endpoint withdrawal failure, then in a fresh fixture inject an unknown contact-removal response or a partial removal retaining an active peer. + Also inject a profile-deletion error whose message contains 404 Not Found, without confirming deletion. These controls are not available in standard staging. This journey covers stopping on incomplete cleanup, not cross-device conditional deletion or safe manual replay of an uncertain operation. @@ -12,6 +13,7 @@ Verify a failed private withdrawal is followed by no contact or profile deletion Verify unknown contact removal keeps local contact data and prevents profile deletion Verify partial contact removal preserves contacts and overrides not acknowledged as removed + Verify an uncertain profile-deletion error preserves the session and sharing settings without success feedback Cancel the failure dialog and verify the app starts no second deletion Restart the app and verify no profile deletion is automatically replayed From a2b218613ecdeb0a45cd9364d723204147b27044 Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 29 Oct 2026 03:48:46 +0200 Subject: [PATCH 4/5] fix: refuse profile removal with active subscriptions --- Bitkit/Managers/ContactsManager.swift | 2 +- Bitkit/Managers/PubkyProfileManager.swift | 13 ++- .../Localization/ar.lproj/Localizable.strings | 1 + .../Localization/ca.lproj/Localizable.strings | 1 + .../Localization/cs.lproj/Localizable.strings | 1 + .../Localization/de.lproj/Localizable.strings | 1 + .../Localization/el.lproj/Localizable.strings | 1 + .../Localization/en.lproj/Localizable.strings | 1 + .../es-419.lproj/Localizable.strings | 1 + .../Localization/es.lproj/Localizable.strings | 1 + .../Localization/fr.lproj/Localizable.strings | 1 + .../Localization/it.lproj/Localizable.strings | 1 + .../Localization/nl.lproj/Localizable.strings | 1 + .../Localization/pl.lproj/Localizable.strings | 1 + .../pt-BR.lproj/Localizable.strings | 1 + .../Localization/pt.lproj/Localizable.strings | 1 + .../Localization/ru.lproj/Localizable.strings | 1 + Bitkit/Services/PubkyService.swift | 34 ++++++++ Bitkit/Views/Profile/EditProfileView.swift | 5 +- BitkitTests/PaykitSdkIdentityCheckTests.swift | 85 +++++++++++++++++++ BitkitTests/PubkyProfileManagerTests.swift | 48 ++++++++++- changelog.d/next/1124.fixed.md | 2 +- journeys/profile/profile-cleanup-failure.xml | 9 +- 23 files changed, 204 insertions(+), 9 deletions(-) diff --git a/Bitkit/Managers/ContactsManager.swift b/Bitkit/Managers/ContactsManager.swift index a87e33f26..fde215be3 100644 --- a/Bitkit/Managers/ContactsManager.swift +++ b/Bitkit/Managers/ContactsManager.swift @@ -193,7 +193,7 @@ class ContactsManager: ObservableObject { _ = try await PubkyService.removeContact(publicKey: $0) }, removeContactRecords: @escaping @Sendable (_ publicKeys: [String]) async throws -> [ContactRecord] = { - try await PubkyService.removeContacts(publicKeys: $0) + try await PubkyService.removeContactsForProfile(publicKeys: $0) }, forgetRemovedContacts: @escaping @Sendable (_ publicKeys: [String]) async -> Void = { await PrivatePaykitService.shared.removeSavedContacts(publicKeys: $0) diff --git a/Bitkit/Managers/PubkyProfileManager.swift b/Bitkit/Managers/PubkyProfileManager.swift index 54fb3f711..5573bc900 100644 --- a/Bitkit/Managers/PubkyProfileManager.swift +++ b/Bitkit/Managers/PubkyProfileManager.swift @@ -121,6 +121,7 @@ class PubkyProfileManager: ObservableObject { private let remoteProfileResolver: RemoteProfileResolver private let profilePublisher: ProfilePublisher private let avatarUploader: AvatarUploader + private let profileDeletionValidator: ProfileDeleter 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. @@ -157,6 +158,9 @@ class PubkyProfileManager: ObservableObject { avatarUploader: @escaping AvatarUploader = { try await PubkyService.uploadProfileAvatar(bytes: $0, contentType: "image/jpeg", expectedIdentity: $1) }, + profileDeletionValidator: @escaping ProfileDeleter = { + try await PubkyService.requireNoActiveSubscriptions(expectedIdentity: $0) + }, profileDeleter: @escaping ProfileDeleter = { try await PubkyService.deletePaykitProfile(expectedIdentity: $0) } @@ -164,6 +168,7 @@ class PubkyProfileManager: ObservableObject { self.remoteProfileResolver = remoteProfileResolver self.profilePublisher = profilePublisher self.avatarUploader = avatarUploader + self.profileDeletionValidator = profileDeletionValidator self.profileDeleter = profileDeleter cachedName = UserDefaults.standard.string(forKey: Self.cachedNameKey) cachedImageUri = UserDefaults.standard.string(forKey: Self.cachedImageUriKey) @@ -1045,6 +1050,12 @@ class PubkyProfileManager: ObservableObject { await PrivatePaykitService.shared.endProfileDeletion() } + func validateProfileDeletion(for session: SignedInSession) async throws { + try requireCurrentSession(session) + try await profileDeletionValidator(session.publicKey) + try requireCurrentSession(session) + } + func deleteProfile(cleanPrivatePaykitEndpoints: Bool = true, expectedSession: SignedInSession? = nil) async throws { guard let session = expectedSession ?? currentSession else { throw PubkyServiceError.sessionNotActive } try requireCurrentSession(session) @@ -1060,7 +1071,7 @@ class PubkyProfileManager: ObservableObject { } private func deleteProfile(for session: SignedInSession, cleanPrivatePaykitEndpoints: Bool) async throws { - try requireCurrentSession(session) + try await validateProfileDeletion(for: session) if cleanPrivatePaykitEndpoints { try await Self.removePrivatePaykitEndpoints( context: "PubkyProfileManager.deleteProfile", deferAppSync: true, diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 6961ba37d..ce6d0097b 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -946,6 +946,7 @@ "profile__edit_delete_section" = "حذف"; "profile__edit_unlink_section" = "إلغاء الربط"; "profile__edit_public_note" = "يرجى ملاحظة أن معلومات الملف الشخصي مخزنة في ملفات عامة."; +"profile__delete_active_subscription" = "أنه الاشتراكات النشطة قبل إزالة هذا الملف الشخصي."; "profile__delete_title" = "حذف الملف الشخصي؟"; "profile__delete_description" = "يؤدي حذف ملفك الشخصي إلى إزالته من Pubky. قد تحتفظ جهات الاتصال بمعلومات الملف الشخصي والدفع التي شاركتها معهم سابقًا."; "profile__delete_confirm" = "نعم، احذف"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 651052fb4..d068ddbe8 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1062,6 +1062,7 @@ "profile__edit_delete_section" = "ELIMINAR"; "profile__edit_unlink_section" = "DESVINCULAR"; "profile__edit_public_note" = "Tingues en compte que la informació del perfil es desa en fitxers públics."; +"profile__delete_active_subscription" = "Finalitza les subscripcions actives abans d’eliminar aquest perfil."; "profile__delete_title" = "Eliminar el perfil?"; "profile__delete_description" = "Eliminar el teu perfil l'esborra de Pubky. És possible que els contactes conservin la informació de perfil i de pagament que s'hi hagi compartit abans."; "profile__delete_confirm" = "Sí, elimina"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 274ca916d..b200d5304 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1167,6 +1167,7 @@ "profile__edit_delete_section" = "SMAZAT"; "profile__edit_unlink_section" = "ZRUŠIT PROPOJENÍ"; "profile__edit_public_note" = "Upozorňujeme, že informace o profilu jsou uloženy ve veřejných souborech."; +"profile__delete_active_subscription" = "Před odstraněním tohoto profilu ukončete aktivní předplatná."; "profile__delete_title" = "Smazat profil?"; "profile__delete_description" = "Smazáním profilu ho odstraníte z Pubky. Kontakty si mohou ponechat informace o profilu a platbách, které jste s nimi dříve sdíleli."; "profile__delete_confirm" = "Ano, smazat"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 01009c4ce..79514b702 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1165,6 +1165,7 @@ "profile__edit_delete_section" = "LÖSCHEN"; "profile__edit_unlink_section" = "VERKNÜPFUNG AUFHEBEN"; "profile__edit_public_note" = "Bitte beachte, dass Profilinformationen in öffentlichen Dateien gespeichert werden."; +"profile__delete_active_subscription" = "Beende aktive Abonnements, bevor du dieses Profil entfernst."; "profile__delete_title" = "Profil löschen?"; "profile__delete_description" = "Durch das Löschen deines Profils wird es von Pubky entfernt. Kontakte können zuvor mit ihnen geteilte Profil- und Zahlungsinformationen weiterhin behalten."; "profile__delete_confirm" = "Ja, löschen"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 5a70bbd05..eb4e33674 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -963,6 +963,7 @@ "profile__edit_delete_section" = "ΔΙΑΓΡΑΦΗ"; "profile__edit_unlink_section" = "ΑΠΟΣΥΝΔΕΣΗ"; "profile__edit_public_note" = "Τα στοιχεία του προφίλ αποθηκεύονται σε δημόσια αρχεία."; +"profile__delete_active_subscription" = "Τερμάτισε τις ενεργές συνδρομές πριν αφαιρέσεις αυτό το προφίλ."; "profile__delete_title" = "Διαγραφή Προφίλ;"; "profile__delete_description" = "Η διαγραφή του προφίλ σας το αφαιρεί από το Pubky. Οι επαφές μπορεί να διατηρούν ακόμα πληροφορίες προφίλ και πληρωμών που τους κοινοποιήθηκαν παλαιότερα."; "profile__delete_confirm" = "Ναι, Διαγραφή"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index cec4fe8cf..2c3388dc1 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1180,6 +1180,7 @@ "profile__edit_delete_section" = "DELETE"; "profile__edit_unlink_section" = "UNLINK"; "profile__edit_public_note" = "Please note profile information is stored in public files."; +"profile__delete_active_subscription" = "End active subscriptions before removing this profile."; "profile__delete_title" = "Delete Profile?"; "profile__delete_description" = "Deleting your profile removes it from Pubky. Contacts may still retain profile and payment information previously shared with them."; "profile__delete_confirm" = "Yes, Delete"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 97b79caa8..09d802a6e 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1170,6 +1170,7 @@ "profile__edit_delete_section" = "ELIMINAR"; "profile__edit_unlink_section" = "DESVINCULAR"; "profile__edit_public_note" = "Tenga en cuenta que la información del perfil se guarda en archivos públicos."; +"profile__delete_active_subscription" = "Finaliza las suscripciones activas antes de eliminar este perfil."; "profile__delete_title" = "¿Eliminar perfil?"; "profile__delete_description" = "Al eliminar su perfil, se borra de Pubky. Es posible que sus contactos conserven la información de perfil y de pago que compartió con ellos anteriormente."; "profile__delete_confirm" = "Sí, eliminar"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index fe2d64fa1..1d2ee2fdc 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1079,6 +1079,7 @@ "profile__edit_delete_section" = "ELIMINAR"; "profile__edit_unlink_section" = "DESVINCULAR"; "profile__edit_public_note" = "Tenga en cuenta que la información del perfil se guarda en archivos públicos."; +"profile__delete_active_subscription" = "Finaliza las suscripciones activas antes de eliminar este perfil."; "profile__delete_title" = "¿Eliminar el perfil?"; "profile__delete_description" = "Al eliminar su perfil, se elimina de Pubky. Es posible que sus contactos conserven la información de perfil y de pago que compartió con ellos anteriormente."; "profile__delete_confirm" = "Sí, eliminar"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 1d45f068a..ab085fc06 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1178,6 +1178,7 @@ "profile__edit_delete_section" = "SUPPRIMER"; "profile__edit_unlink_section" = "DISSOCIER"; "profile__edit_public_note" = "Veuillez noter que les informations de profil sont stockées dans des fichiers publics."; +"profile__delete_active_subscription" = "Mettez fin aux abonnements actifs avant de supprimer ce profil."; "profile__delete_title" = "Supprimer le profil ?"; "profile__delete_description" = "La suppression de votre profil le retire de Pubky. Vos contacts peuvent encore conserver les informations de profil et de paiement que vous leur avez partagées."; "profile__delete_confirm" = "Oui, supprimer"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 24dc9c6aa..d47a4a60f 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1144,6 +1144,7 @@ "profile__edit_delete_section" = "ELIMINA"; "profile__edit_unlink_section" = "SCOLLEGA"; "profile__edit_public_note" = "Tieni presente che le informazioni del profilo sono salvate in file pubblici."; +"profile__delete_active_subscription" = "Termina gli abbonamenti attivi prima di rimuovere questo profilo."; "profile__delete_title" = "Eliminare il Profilo?"; "profile__delete_description" = "Eliminando il profilo lo rimuovi da Pubky. I contatti potrebbero conservare le informazioni di profilo e di pagamento condivise in precedenza con loro."; "profile__delete_confirm" = "Sì, Elimina"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index e37f02521..65c63cc45 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1173,6 +1173,7 @@ "profile__edit_delete_section" = "VERWIJDEREN"; "profile__edit_unlink_section" = "ONTKOPPELEN"; "profile__edit_public_note" = "Let op: profielgegevens worden opgeslagen in openbare bestanden."; +"profile__delete_active_subscription" = "Beëindig actieve abonnementen voordat je dit profiel verwijdert."; "profile__delete_title" = "Profiel verwijderen?"; "profile__delete_description" = "Als u uw profiel verwijdert, wordt het van Pubky verwijderd. Contacten kunnen eerder gedeelde profiel- en betaalgegevens nog bewaren."; "profile__delete_confirm" = "Ja, verwijderen"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index e5e7d923f..2320c034d 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1177,6 +1177,7 @@ "profile__edit_delete_section" = "USUWANIE"; "profile__edit_unlink_section" = "ODŁĄCZANIE"; "profile__edit_public_note" = "Pamiętaj, że dane profilu są przechowywane w publicznych plikach."; +"profile__delete_active_subscription" = "Zakończ aktywne subskrypcje przed usunięciem tego profilu."; "profile__delete_title" = "Usunąć profil?"; "profile__delete_description" = "Usunięcie profilu usuwa go z Pubky. Kontakty mogą nadal przechowywać udostępnione im wcześniej dane profilu i płatności."; "profile__delete_confirm" = "Tak, usuń"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 3fa83ecf1..c7a060fe1 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1178,6 +1178,7 @@ "profile__edit_delete_section" = "EXCLUIR"; "profile__edit_unlink_section" = "DESVINCULAR"; "profile__edit_public_note" = "Observe que as informações do perfil são armazenadas em arquivos públicos."; +"profile__delete_active_subscription" = "Encerre as assinaturas ativas antes de remover este perfil."; "profile__delete_title" = "Excluir Perfil?"; "profile__delete_description" = "Excluir seu perfil o remove do Pubky. Os contatos ainda podem manter informações de perfil e de pagamento compartilhadas anteriormente com eles."; "profile__delete_confirm" = "Sim, Excluir"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 3d4393d85..951c73863 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -938,6 +938,7 @@ "profile__edit_delete_section" = "ELIMINAR"; "profile__edit_unlink_section" = "DESASSOCIAR"; "profile__edit_public_note" = "Tenha em atenção que as informações do perfil são guardadas em ficheiros públicos."; +"profile__delete_active_subscription" = "Termina as subscrições ativas antes de remover este perfil."; "profile__delete_title" = "Eliminar perfil?"; "profile__delete_description" = "Eliminar o seu perfil remove-o do Pubky. Os contactos podem manter as informações de perfil e pagamento partilhadas anteriormente com eles."; "profile__delete_confirm" = "Sim, Excluir"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index c35cebdc4..9d9a10028 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1163,6 +1163,7 @@ "profile__edit_delete_section" = "УДАЛЕНИЕ"; "profile__edit_unlink_section" = "ОТВЯЗКА"; "profile__edit_public_note" = "Обратите внимание: данные профиля хранятся в публичных файлах."; +"profile__delete_active_subscription" = "Завершите активные подписки перед удалением этого профиля."; "profile__delete_title" = "Удалить Профиль?"; "profile__delete_description" = "Удаление профиля удалит его из Pubky. У контактов могут остаться данные профиля и платёжные данные, которыми вы делились ранее."; "profile__delete_confirm" = "Да, Удалить"; diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 3813f6b70..5e3ee269b 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -346,6 +346,10 @@ enum PubkyService { try await PaykitSdkService.shared.uploadAvatar(bytes: bytes, contentType: contentType, expectedIdentity: expectedIdentity) } + static func requireNoActiveSubscriptions(expectedIdentity: String) async throws { + try await PaykitSdkService.shared.requireNoActiveSubscriptions(expectedIdentity: expectedIdentity) + } + static func deletePaykitProfile(expectedIdentity: String? = nil) async throws { try await PaykitSdkService.shared.deletePaykitProfile(expectedIdentity: expectedIdentity) } @@ -383,6 +387,10 @@ enum PubkyService { try await PaykitSdkService.shared.removeContact(publicKey: publicKey) } + static func removeContactsForProfile(publicKeys: [String]) async throws -> [Paykit.ContactRecord] { + try await PaykitSdkService.shared.removeContactsForProfile(publicKeys: publicKeys) + } + static func removeContacts(publicKeys: [String]) async throws -> [Paykit.ContactRecord] { try await PaykitSdkService.shared.removeContacts(publicKeys: publicKeys) } @@ -804,11 +812,29 @@ actor PaykitSdkService { return record.uri } + func requireNoActiveSubscriptions(expectedIdentity: String) async throws { + try await withStateRevisionTracking { sdk in + try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) + try await Self.requireNoActiveSubscriptions(in: sdk) + } + } + + private static func requireNoActiveSubscriptions(in sdk: PaykitSdk) async throws { + let requests = try await sdk.paymentRequests() + try Task.checkCancellation() + let now = Date() + guard !requests.contains(where: { + $0.state == .activeRecurring && + ($0.terms?.recurrence?.endsAt.flatMap(PaykitPaymentRequest.parseDate).map { $0 > now } ?? true) + }) else { throw PubkyServiceError.activeSubscription(endsAt: nil) } + } + func deletePaykitProfile(expectedIdentity: String? = nil) async throws { try await withStateRevisionTracking { sdk in if let expectedIdentity { try await Self.requireSignedInIdentity(expectedIdentity, in: sdk) } + try await Self.requireNoActiveSubscriptions(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) @@ -929,6 +955,14 @@ actor PaykitSdkService { } } + func removeContactsForProfile(publicKeys: [String]) async throws -> [Paykit.ContactRecord] { + try await withStateRevisionTracking { sdk in + try await Self.requireNoActiveSubscriptions(in: sdk) + guard !publicKeys.isEmpty else { return [] } + return try await sdk.removeContactsAndBlockPeers(publicKeys: publicKeys) + } + } + func removeContacts(publicKeys: [String]) async throws -> [Paykit.ContactRecord] { guard !publicKeys.isEmpty else { return [] } return try await withStateRevisionTracking { sdk in diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index 092006c0f..0db9252a9 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -160,6 +160,9 @@ struct EditProfileView: View { try await pubkyProfile.performProfileDeletion { do { try await performDeleteProfile(session: session) + } catch PubkyServiceError.activeSubscription { + guard !Task.isCancelled, pubkyProfile.currentSession == session else { return } + app.toast(type: .error, title: t("profile__delete_active_subscription")) } catch { guard !Task.isCancelled, pubkyProfile.publicKey == session.publicKey else { return } Logger.error("Failed to delete profile: \(error)", context: "EditProfileView") @@ -189,7 +192,7 @@ struct EditProfileView: View { contactsManager: ContactsManager, removePrivateEndpoints: () async throws -> Void ) async throws { - try pubkyProfile.requireCurrentSession(session) + try await pubkyProfile.validateProfileDeletion(for: session) try await removePrivateEndpoints() try pubkyProfile.requireCurrentSession(session) try await contactsManager.deleteAllContacts() diff --git a/BitkitTests/PaykitSdkIdentityCheckTests.swift b/BitkitTests/PaykitSdkIdentityCheckTests.swift index fcd762027..670794574 100644 --- a/BitkitTests/PaykitSdkIdentityCheckTests.swift +++ b/BitkitTests/PaykitSdkIdentityCheckTests.swift @@ -440,6 +440,80 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { XCTAssertEqual(sdk.writes, ["upload"]) } + func testProfileDeletionRefusesActiveSubscriptionsInEitherRoleWithoutContacts() async throws { + for role in [PaymentRequestLocalRole.payer, .payee] { + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = identityA + sdk.profileExists = true + var request = try paymentRequestRecord(appId: "another-app") + request.localRole = role + request.state = .activeRecurring + sdk.rawPaymentRequests = { [request] } + let service = PaykitSdkService(sdkFactory: { sdk }) + + do { + try await service.deletePaykitProfile(expectedIdentity: identityA) + XCTFail("An active subscription must prevent profile deletion even without a contact") + } catch PubkyServiceError.activeSubscription {} + XCTAssertTrue(sdk.writes.isEmpty) + } + } + + func testProfileCleanupChecksAllSubscriptionsBeforeRemovingAnyContact() async throws { + for endsAt in [nil, "invalid", "9999-01-01T00:00:00Z", "2000-01-01T00:00:00Z"] as [String?] { + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = identityA + sdk.profileExists = true + var request = try paymentRequestRecord(appId: "another-app") + request.state = .activeRecurring + request.terms?.recurrence = PaymentRequestRecurrence( + every: 1, unit: "month", startsAt: "1999-01-01T00:00:00Z", anchor: "1999-01-01T00:00:00Z", endsAt: endsAt + ) + sdk.rawPaymentRequests = { [request] } + let service = PaykitSdkService(sdkFactory: { sdk }) + let blocked = endsAt != "2000-01-01T00:00:00Z" + let operations: [() async throws -> Void] = [ + { try await service.requireNoActiveSubscriptions(expectedIdentity: identityA) }, + { _ = try await service.removeContactsForProfile(publicKeys: [identityB]) }, + { _ = try await service.removeContactsForProfile(publicKeys: []) }, + { try await service.deletePaykitProfile(expectedIdentity: identityA) }, + ] + for operation in operations { + do { + try await operation() + XCTAssertFalse(blocked) + } catch PubkyServiceError.activeSubscription { + XCTAssertTrue(blocked) + } + } + XCTAssertEqual(sdk.writes, blocked ? [] : ["remove-contacts:1", "delete-profile:test"]) + } + } + + func testSubscriptionReadFailureAndCancellationNeverAuthorizeProfileCleanup() async throws { + for failure: Error in [sharedStateBusy, CancellationError()] { + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = identityA + sdk.profileExists = true + sdk.rawPaymentRequests = { throw failure } + let service = PaykitSdkService(sdkFactory: { sdk }) + let operations: [() async throws -> Void] = [ + { try await service.requireNoActiveSubscriptions(expectedIdentity: identityA) }, + { _ = try await service.removeContactsForProfile(publicKeys: [contactKey]) }, + { try await service.deletePaykitProfile(expectedIdentity: identityA) }, + ] + for operation in operations { + do { + try await operation() + XCTFail("An unavailable subscription read must stop cleanup") + } catch { + XCTAssertEqual(error.localizedDescription, failure.localizedDescription) + } + } + XCTAssertTrue(sdk.writes.isEmpty) + } + } + func testProfileDeletionChecksItsOwnerBeforeReadingOrRemovingTheProfile() async throws { for signedIn in [identityA, identityB, nil] { let sdk = IdentitySwitchingSdk(noPointer: .init()) @@ -766,6 +840,8 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { PaymentRequestRead(identityStatus: nil, paymentRequests: []) } + var rawPaymentRequests: () async throws -> [PaymentRequestRecord] = { [] } + var schedulingRead: () async throws -> Paykit.PrivateMessageSchedulingSnapshot = { Paykit.PrivateMessageSchedulingSnapshot(linkedPeers: [], pendingOutboundCounterparties: []) } @@ -826,6 +902,10 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { } } + override func paymentRequests() async throws -> [PaymentRequestRecord] { + try await rawPaymentRequests() + } + override func readPaymentRequests(counterparty: String, expectedIdentity: String?) async throws -> PaymentRequestRead { try await paymentRequestRead(counterparty, expectedIdentity) } @@ -890,6 +970,11 @@ private final class IdentitySwitchingSdk: PaykitSdk, @unchecked Sendable { return [] } + override func removeContactsAndBlockPeers(publicKeys: [String]) async throws -> [ContactRecord] { + recordWrite("remove-contacts:\(publicKeys.count)") + return publicKeys.map { Self.record(publicKey: $0, label: nil) } + } + override func contactRecord(publicKey: String) async throws -> ContactRecord? { lock.withLock { recordedContactReads += 1 } return Self.record(publicKey: publicKey, label: "Contact") diff --git a/BitkitTests/PubkyProfileManagerTests.swift b/BitkitTests/PubkyProfileManagerTests.swift index 48157df7f..2dba17bcb 100644 --- a/BitkitTests/PubkyProfileManagerTests.swift +++ b/BitkitTests/PubkyProfileManagerTests.swift @@ -11,13 +11,53 @@ final class PubkyProfileManagerTests: XCTestCase { case signOut } + @MainActor + func testActiveSubscriptionStopsProfileRemovalBeforePrivateOrContactCleanup() async throws { + try await withEmptyIdentityStorage { + for entry in ["screen", "manager"] { + let key = "pubky" + String(repeating: "y", count: 52) + let manager = PubkyProfileManager(profileDeletionValidator: { expectedIdentity in + XCTAssertEqual(expectedIdentity, key) + throw PubkyServiceError.activeSubscription(endsAt: nil) + }, profileDeleter: { _ in + XCTFail("An active subscription must preserve the profile") + }) + manager.publicKey = key + manager.authState = .authenticated + let session = try XCTUnwrap(manager.currentSession) + let contacts = ContactsManager(contactRecords: { + XCTFail("An active subscription must stop before contact cleanup") + return [] + }) + UserDefaults.standard.set(true, forKey: PublicPaykitService.publishingEnabledKey) + UserDefaults.standard.set(true, forKey: PrivatePaykitService.publishingEnabledKey) + do { + if entry == "screen" { + try await manager.performProfileDeletion { + try await EditProfileView.performDeleteProfile( + session: session, pubkyProfile: manager, contactsManager: contacts + ) { XCTFail("An active subscription must preserve private endpoints") } + } + } else { + try await manager.deleteProfile() + } + XCTFail("Expected subscription refusal") + } catch PubkyServiceError.activeSubscription {} + XCTAssertEqual(manager.currentSession, session) + XCTAssertFalse(manager.isDeletingProfile) + XCTAssertTrue(UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey)) + XCTAssertTrue(UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey)) + } + } + } + @MainActor func testProfileRemovalStopsBeforeDeletingWhenCleanupIsIncomplete() async throws { snapshotAppDefaultsDomain() for failure in ["private endpoints", "contacts"] { let key = "pubky" + String(repeating: "y", count: 52) let originalError = KeychainError.failedToDelete - let manager = PubkyProfileManager(profileDeleter: { _ in + let manager = PubkyProfileManager(profileDeletionValidator: { _ in }, profileDeleter: { _ in XCTFail("Incomplete cleanup must not delete the profile") throw PublicPaykitError.publicationFailed }) @@ -243,7 +283,7 @@ final class PubkyProfileManagerTests: XCTestCase { let started = expectation(description: "Profile delete admitted") let (resume, continuation) = AsyncStream.makeStream() defer { continuation.finish() } - let manager = PubkyProfileManager(profileDeleter: { expectedIdentity in + let manager = PubkyProfileManager(profileDeletionValidator: { _ in }, profileDeleter: { expectedIdentity in XCTAssertEqual(expectedIdentity, original) started.fulfill() for await _ in resume {} @@ -332,7 +372,7 @@ final class PubkyProfileManagerTests: XCTestCase { NSError(domain: NSCocoaErrorDomain, code: CocoaError.fileReadNoSuchFile.rawValue), ] for failure in errors { - let manager = PubkyProfileManager(profileDeleter: { _ in + let manager = PubkyProfileManager(profileDeletionValidator: { _ in }, profileDeleter: { _ in if let failure { throw failure } @@ -364,7 +404,7 @@ final class PubkyProfileManagerTests: XCTestCase { try await withEmptyIdentityStorage { let secret = String(repeating: "01", count: 32) let original = try PubkyProfileManager.publicKeyFromSecretKey(secret) - let manager = PubkyProfileManager(profileDeleter: { _ in + let manager = PubkyProfileManager(profileDeletionValidator: { _ in }, profileDeleter: { _ in XCTFail("A stale deletion must not reach profile storage") }) manager.publicKey = original diff --git a/changelog.d/next/1124.fixed.md b/changelog.d/next/1124.fixed.md index f05f0a6f8..4998652b6 100644 --- a/changelog.d/next/1124.fixed.md +++ b/changelog.d/next/1124.fixed.md @@ -1 +1 @@ -Preserve session and contact data after failed profile deletion or incomplete payment-link cleanup. +Preserve session and contact data after incomplete profile cleanup, and require ending active subscriptions before removing a profile. diff --git a/journeys/profile/profile-cleanup-failure.xml b/journeys/profile/profile-cleanup-failure.xml index f9fd8c2e2..5b1f66be9 100644 --- a/journeys/profile/profile-cleanup-failure.xml +++ b/journeys/profile/profile-cleanup-failure.xml @@ -2,12 +2,19 @@ Requires a disposable test identity with saved contacts and a controlled homeserver proxy. Never use a personal profile. Inject a private-endpoint withdrawal failure, then in a fresh - fixture inject an unknown contact-removal response or a partial removal retaining an active peer. + fixture inject an unknown contact-removal response or a partial removal acknowledging only some contacts. + Separately prepare an active recurring subscription in each role (payer and payee), with an + unrelated saved contact, and record endpoint/contact state before confirming removal. Verify + the subscription warning appears before cleanup; cancel or expire the subscription in the + disposable fixture and confirm removal again to verify it can proceed. Also inject a profile-deletion error whose message contains 404 Not Found, without confirming deletion. These controls are not available in standard staging. This journey covers stopping on incomplete cleanup, not cross-device conditional deletion or safe manual replay of an uncertain operation. + With an active subscription, confirm profile deletion or unlink and verify the localized instruction to end active subscriptions + Verify there is no generic Retry dialog, endpoint withdrawal, contact removal or profile deletion, including for the unrelated contact + After ending the subscription, explicitly confirm removal again and verify acknowledged cleanup succeeds Open Edit Profile and confirm deletion with the cleanup fault enabled Verify failure options appear without a success notification or onboarding navigation Verify a failed private withdrawal is followed by no contact or profile deletion From 6665a4431d04eb00f286a81e90b38d3e3f9e6d7f Mon Sep 17 00:00:00 2001 From: benk10 Date: Thu, 29 Oct 2026 16:07:55 +0200 Subject: [PATCH 5/5] Explain finite subscription expiration before profile removal --- .../Localization/ar.lproj/Localizable.strings | 2 +- .../Localization/ca.lproj/Localizable.strings | 2 +- .../Localization/cs.lproj/Localizable.strings | 2 +- .../Localization/de.lproj/Localizable.strings | 2 +- .../Localization/el.lproj/Localizable.strings | 2 +- .../Localization/en.lproj/Localizable.strings | 2 +- .../es-419.lproj/Localizable.strings | 2 +- .../Localization/es.lproj/Localizable.strings | 2 +- .../Localization/fr.lproj/Localizable.strings | 2 +- .../Localization/it.lproj/Localizable.strings | 2 +- .../Localization/nl.lproj/Localizable.strings | 2 +- .../Localization/pl.lproj/Localizable.strings | 2 +- .../pt-BR.lproj/Localizable.strings | 2 +- .../Localization/pt.lproj/Localizable.strings | 2 +- .../Localization/ru.lproj/Localizable.strings | 2 +- Bitkit/Services/PubkyService.swift | 10 ++++-- Bitkit/Views/Profile/EditProfileView.swift | 7 ++-- BitkitTests/PaykitSdkIdentityCheckTests.swift | 36 ++++++++++++++++++- changelog.d/next/1124.fixed.md | 2 +- journeys/profile/profile-cleanup-failure.xml | 2 +- 20 files changed, 65 insertions(+), 22 deletions(-) diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index ce6d0097b..f186485ef 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -946,7 +946,7 @@ "profile__edit_delete_section" = "حذف"; "profile__edit_unlink_section" = "إلغاء الربط"; "profile__edit_public_note" = "يرجى ملاحظة أن معلومات الملف الشخصي مخزنة في ملفات عامة."; -"profile__delete_active_subscription" = "أنه الاشتراكات النشطة قبل إزالة هذا الملف الشخصي."; +"profile__delete_active_subscription" = "تمنع الاشتراكات النشطة إزالة هذا الملف الشخصي."; "profile__delete_title" = "حذف الملف الشخصي؟"; "profile__delete_description" = "يؤدي حذف ملفك الشخصي إلى إزالته من Pubky. قد تحتفظ جهات الاتصال بمعلومات الملف الشخصي والدفع التي شاركتها معهم سابقًا."; "profile__delete_confirm" = "نعم، احذف"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index d068ddbe8..72adfbb41 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1062,7 +1062,7 @@ "profile__edit_delete_section" = "ELIMINAR"; "profile__edit_unlink_section" = "DESVINCULAR"; "profile__edit_public_note" = "Tingues en compte que la informació del perfil es desa en fitxers públics."; -"profile__delete_active_subscription" = "Finalitza les subscripcions actives abans d’eliminar aquest perfil."; +"profile__delete_active_subscription" = "Les subscripcions actives impedeixen eliminar aquest perfil."; "profile__delete_title" = "Eliminar el perfil?"; "profile__delete_description" = "Eliminar el teu perfil l'esborra de Pubky. És possible que els contactes conservin la informació de perfil i de pagament que s'hi hagi compartit abans."; "profile__delete_confirm" = "Sí, elimina"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index b200d5304..78fe68c6a 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1167,7 +1167,7 @@ "profile__edit_delete_section" = "SMAZAT"; "profile__edit_unlink_section" = "ZRUŠIT PROPOJENÍ"; "profile__edit_public_note" = "Upozorňujeme, že informace o profilu jsou uloženy ve veřejných souborech."; -"profile__delete_active_subscription" = "Před odstraněním tohoto profilu ukončete aktivní předplatná."; +"profile__delete_active_subscription" = "Aktivní předplatná brání odstranění tohoto profilu."; "profile__delete_title" = "Smazat profil?"; "profile__delete_description" = "Smazáním profilu ho odstraníte z Pubky. Kontakty si mohou ponechat informace o profilu a platbách, které jste s nimi dříve sdíleli."; "profile__delete_confirm" = "Ano, smazat"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 79514b702..6c1a7e836 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1165,7 +1165,7 @@ "profile__edit_delete_section" = "LÖSCHEN"; "profile__edit_unlink_section" = "VERKNÜPFUNG AUFHEBEN"; "profile__edit_public_note" = "Bitte beachte, dass Profilinformationen in öffentlichen Dateien gespeichert werden."; -"profile__delete_active_subscription" = "Beende aktive Abonnements, bevor du dieses Profil entfernst."; +"profile__delete_active_subscription" = "Aktive Abonnements verhindern das Entfernen dieses Profils."; "profile__delete_title" = "Profil löschen?"; "profile__delete_description" = "Durch das Löschen deines Profils wird es von Pubky entfernt. Kontakte können zuvor mit ihnen geteilte Profil- und Zahlungsinformationen weiterhin behalten."; "profile__delete_confirm" = "Ja, löschen"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index eb4e33674..7740f95c2 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -963,7 +963,7 @@ "profile__edit_delete_section" = "ΔΙΑΓΡΑΦΗ"; "profile__edit_unlink_section" = "ΑΠΟΣΥΝΔΕΣΗ"; "profile__edit_public_note" = "Τα στοιχεία του προφίλ αποθηκεύονται σε δημόσια αρχεία."; -"profile__delete_active_subscription" = "Τερμάτισε τις ενεργές συνδρομές πριν αφαιρέσεις αυτό το προφίλ."; +"profile__delete_active_subscription" = "Οι ενεργές συνδρομές εμποδίζουν την αφαίρεση αυτού του προφίλ."; "profile__delete_title" = "Διαγραφή Προφίλ;"; "profile__delete_description" = "Η διαγραφή του προφίλ σας το αφαιρεί από το Pubky. Οι επαφές μπορεί να διατηρούν ακόμα πληροφορίες προφίλ και πληρωμών που τους κοινοποιήθηκαν παλαιότερα."; "profile__delete_confirm" = "Ναι, Διαγραφή"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 2c3388dc1..6cba87f8a 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1180,7 +1180,7 @@ "profile__edit_delete_section" = "DELETE"; "profile__edit_unlink_section" = "UNLINK"; "profile__edit_public_note" = "Please note profile information is stored in public files."; -"profile__delete_active_subscription" = "End active subscriptions before removing this profile."; +"profile__delete_active_subscription" = "Active subscriptions prevent removing this profile."; "profile__delete_title" = "Delete Profile?"; "profile__delete_description" = "Deleting your profile removes it from Pubky. Contacts may still retain profile and payment information previously shared with them."; "profile__delete_confirm" = "Yes, Delete"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 09d802a6e..d3136f913 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1170,7 +1170,7 @@ "profile__edit_delete_section" = "ELIMINAR"; "profile__edit_unlink_section" = "DESVINCULAR"; "profile__edit_public_note" = "Tenga en cuenta que la información del perfil se guarda en archivos públicos."; -"profile__delete_active_subscription" = "Finaliza las suscripciones activas antes de eliminar este perfil."; +"profile__delete_active_subscription" = "Las suscripciones activas impiden eliminar este perfil."; "profile__delete_title" = "¿Eliminar perfil?"; "profile__delete_description" = "Al eliminar su perfil, se borra de Pubky. Es posible que sus contactos conserven la información de perfil y de pago que compartió con ellos anteriormente."; "profile__delete_confirm" = "Sí, eliminar"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 1d2ee2fdc..56f20db88 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1079,7 +1079,7 @@ "profile__edit_delete_section" = "ELIMINAR"; "profile__edit_unlink_section" = "DESVINCULAR"; "profile__edit_public_note" = "Tenga en cuenta que la información del perfil se guarda en archivos públicos."; -"profile__delete_active_subscription" = "Finaliza las suscripciones activas antes de eliminar este perfil."; +"profile__delete_active_subscription" = "Las suscripciones activas impiden eliminar este perfil."; "profile__delete_title" = "¿Eliminar el perfil?"; "profile__delete_description" = "Al eliminar su perfil, se elimina de Pubky. Es posible que sus contactos conserven la información de perfil y de pago que compartió con ellos anteriormente."; "profile__delete_confirm" = "Sí, eliminar"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index ab085fc06..0ddcc5f3b 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1178,7 +1178,7 @@ "profile__edit_delete_section" = "SUPPRIMER"; "profile__edit_unlink_section" = "DISSOCIER"; "profile__edit_public_note" = "Veuillez noter que les informations de profil sont stockées dans des fichiers publics."; -"profile__delete_active_subscription" = "Mettez fin aux abonnements actifs avant de supprimer ce profil."; +"profile__delete_active_subscription" = "Les abonnements actifs empêchent la suppression de ce profil."; "profile__delete_title" = "Supprimer le profil ?"; "profile__delete_description" = "La suppression de votre profil le retire de Pubky. Vos contacts peuvent encore conserver les informations de profil et de paiement que vous leur avez partagées."; "profile__delete_confirm" = "Oui, supprimer"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index d47a4a60f..d497b9752 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1144,7 +1144,7 @@ "profile__edit_delete_section" = "ELIMINA"; "profile__edit_unlink_section" = "SCOLLEGA"; "profile__edit_public_note" = "Tieni presente che le informazioni del profilo sono salvate in file pubblici."; -"profile__delete_active_subscription" = "Termina gli abbonamenti attivi prima di rimuovere questo profilo."; +"profile__delete_active_subscription" = "Gli abbonamenti attivi impediscono la rimozione di questo profilo."; "profile__delete_title" = "Eliminare il Profilo?"; "profile__delete_description" = "Eliminando il profilo lo rimuovi da Pubky. I contatti potrebbero conservare le informazioni di profilo e di pagamento condivise in precedenza con loro."; "profile__delete_confirm" = "Sì, Elimina"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 65c63cc45..6e1a387e6 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1173,7 +1173,7 @@ "profile__edit_delete_section" = "VERWIJDEREN"; "profile__edit_unlink_section" = "ONTKOPPELEN"; "profile__edit_public_note" = "Let op: profielgegevens worden opgeslagen in openbare bestanden."; -"profile__delete_active_subscription" = "Beëindig actieve abonnementen voordat je dit profiel verwijdert."; +"profile__delete_active_subscription" = "Actieve abonnementen verhinderen het verwijderen van dit profiel."; "profile__delete_title" = "Profiel verwijderen?"; "profile__delete_description" = "Als u uw profiel verwijdert, wordt het van Pubky verwijderd. Contacten kunnen eerder gedeelde profiel- en betaalgegevens nog bewaren."; "profile__delete_confirm" = "Ja, verwijderen"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 2320c034d..6fb626ca8 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1177,7 +1177,7 @@ "profile__edit_delete_section" = "USUWANIE"; "profile__edit_unlink_section" = "ODŁĄCZANIE"; "profile__edit_public_note" = "Pamiętaj, że dane profilu są przechowywane w publicznych plikach."; -"profile__delete_active_subscription" = "Zakończ aktywne subskrypcje przed usunięciem tego profilu."; +"profile__delete_active_subscription" = "Aktywne subskrypcje uniemożliwiają usunięcie tego profilu."; "profile__delete_title" = "Usunąć profil?"; "profile__delete_description" = "Usunięcie profilu usuwa go z Pubky. Kontakty mogą nadal przechowywać udostępnione im wcześniej dane profilu i płatności."; "profile__delete_confirm" = "Tak, usuń"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index c7a060fe1..028091cbf 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1178,7 +1178,7 @@ "profile__edit_delete_section" = "EXCLUIR"; "profile__edit_unlink_section" = "DESVINCULAR"; "profile__edit_public_note" = "Observe que as informações do perfil são armazenadas em arquivos públicos."; -"profile__delete_active_subscription" = "Encerre as assinaturas ativas antes de remover este perfil."; +"profile__delete_active_subscription" = "As assinaturas ativas impedem a remoção deste perfil."; "profile__delete_title" = "Excluir Perfil?"; "profile__delete_description" = "Excluir seu perfil o remove do Pubky. Os contatos ainda podem manter informações de perfil e de pagamento compartilhadas anteriormente com eles."; "profile__delete_confirm" = "Sim, Excluir"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 951c73863..da0cf21be 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -938,7 +938,7 @@ "profile__edit_delete_section" = "ELIMINAR"; "profile__edit_unlink_section" = "DESASSOCIAR"; "profile__edit_public_note" = "Tenha em atenção que as informações do perfil são guardadas em ficheiros públicos."; -"profile__delete_active_subscription" = "Termina as subscrições ativas antes de remover este perfil."; +"profile__delete_active_subscription" = "As subscrições ativas impedem a remoção deste perfil."; "profile__delete_title" = "Eliminar perfil?"; "profile__delete_description" = "Eliminar o seu perfil remove-o do Pubky. Os contactos podem manter as informações de perfil e pagamento partilhadas anteriormente com eles."; "profile__delete_confirm" = "Sim, Excluir"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index 9d9a10028..ce06e85ae 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1163,7 +1163,7 @@ "profile__edit_delete_section" = "УДАЛЕНИЕ"; "profile__edit_unlink_section" = "ОТВЯЗКА"; "profile__edit_public_note" = "Обратите внимание: данные профиля хранятся в публичных файлах."; -"profile__delete_active_subscription" = "Завершите активные подписки перед удалением этого профиля."; +"profile__delete_active_subscription" = "Активные подписки не позволяют удалить этот профиль."; "profile__delete_title" = "Удалить Профиль?"; "profile__delete_description" = "Удаление профиля удалит его из Pubky. У контактов могут остаться данные профиля и платёжные данные, которыми вы делились ранее."; "profile__delete_confirm" = "Да, Удалить"; diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index 5e3ee269b..801744879 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -823,10 +823,16 @@ actor PaykitSdkService { let requests = try await sdk.paymentRequests() try Task.checkCancellation() let now = Date() - guard !requests.contains(where: { + let activeSubscriptions = requests.filter { $0.state == .activeRecurring && ($0.terms?.recurrence?.endsAt.flatMap(PaykitPaymentRequest.parseDate).map { $0 > now } ?? true) - }) else { throw PubkyServiceError.activeSubscription(endsAt: nil) } + } + guard !activeSubscriptions.isEmpty else { return } + let endDates = activeSubscriptions.compactMap { + $0.terms?.recurrence?.endsAt.flatMap(PaykitPaymentRequest.parseDate) + } + let latestEndDate = endDates.count == activeSubscriptions.count ? endDates.max() : nil + throw PubkyServiceError.activeSubscription(endsAt: latestEndDate) } func deletePaykitProfile(expectedIdentity: String? = nil) async throws { diff --git a/Bitkit/Views/Profile/EditProfileView.swift b/Bitkit/Views/Profile/EditProfileView.swift index 0db9252a9..d85df61f5 100644 --- a/Bitkit/Views/Profile/EditProfileView.swift +++ b/Bitkit/Views/Profile/EditProfileView.swift @@ -160,9 +160,12 @@ struct EditProfileView: View { try await pubkyProfile.performProfileDeletion { do { try await performDeleteProfile(session: session) - } catch PubkyServiceError.activeSubscription { + } catch let PubkyServiceError.activeSubscription(endsAt) { guard !Task.isCancelled, pubkyProfile.currentSession == session else { return } - app.toast(type: .error, title: t("profile__delete_active_subscription")) + let description = endsAt.map { + t("subscriptions__expires_date", variables: ["date": $0.formatted(date: .long, time: .omitted)]) + } + app.toast(type: .error, title: t("profile__delete_active_subscription"), description: description) } catch { guard !Task.isCancelled, pubkyProfile.publicKey == session.publicKey else { return } Logger.error("Failed to delete profile: \(error)", context: "EditProfileView") diff --git a/BitkitTests/PaykitSdkIdentityCheckTests.swift b/BitkitTests/PaykitSdkIdentityCheckTests.swift index 670794574..ca2720e91 100644 --- a/BitkitTests/PaykitSdkIdentityCheckTests.swift +++ b/BitkitTests/PaykitSdkIdentityCheckTests.swift @@ -482,14 +482,48 @@ final class PaykitSdkIdentityCheckTests: XCTestCase { do { try await operation() XCTAssertFalse(blocked) - } catch PubkyServiceError.activeSubscription { + } catch let PubkyServiceError.activeSubscription(actualEnd) { XCTAssertTrue(blocked) + XCTAssertEqual(actualEnd, endsAt.flatMap(PaykitPaymentRequest.parseDate)) } } XCTAssertEqual(sdk.writes, blocked ? [] : ["remove-contacts:1", "delete-profile:test"]) } } + func testProfileRemovalOnlyReportsAnEndWhenEveryBlockingSubscriptionHasOne() async throws { + let earlier = "2999-01-01T00:00:00Z" + let later = "3000-01-01T00:00:00Z" + let cases: [(ends: [String?], expected: String?)] = [ + ([earlier, later], later), + ([later, earlier], later), + ([earlier, nil], nil), + (["invalid", later], nil), + (["2000-01-01T00:00:00Z", later], later), + ] + for testCase in cases { + let sdk = IdentitySwitchingSdk(noPointer: .init()) + sdk.identity = identityA + let requests = try testCase.ends.map { endsAt in + var request = try paymentRequestRecord(appId: "another-app") + request.state = .activeRecurring + request.terms?.recurrence = PaymentRequestRecurrence( + every: 1, unit: "month", startsAt: "1999-01-01T00:00:00Z", anchor: "1999-01-01T00:00:00Z", endsAt: endsAt + ) + return request + } + sdk.rawPaymentRequests = { requests } + let service = PaykitSdkService(sdkFactory: { sdk }) + do { + try await service.requireNoActiveSubscriptions(expectedIdentity: identityA) + XCTFail("Every case contains a blocking subscription") + } catch let PubkyServiceError.activeSubscription(endsAt) { + XCTAssertEqual(endsAt, testCase.expected.flatMap(PaykitPaymentRequest.parseDate)) + } + XCTAssertTrue(sdk.writes.isEmpty) + } + } + func testSubscriptionReadFailureAndCancellationNeverAuthorizeProfileCleanup() async throws { for failure: Error in [sharedStateBusy, CancellationError()] { let sdk = IdentitySwitchingSdk(noPointer: .init()) diff --git a/changelog.d/next/1124.fixed.md b/changelog.d/next/1124.fixed.md index 4998652b6..64a4e1001 100644 --- a/changelog.d/next/1124.fixed.md +++ b/changelog.d/next/1124.fixed.md @@ -1 +1 @@ -Preserve session and contact data after incomplete profile cleanup, and require ending active subscriptions before removing a profile. +Preserve session and contact data after incomplete profile cleanup, and explain when active subscriptions prevent profile removal. diff --git a/journeys/profile/profile-cleanup-failure.xml b/journeys/profile/profile-cleanup-failure.xml index 5b1f66be9..9b7a6747e 100644 --- a/journeys/profile/profile-cleanup-failure.xml +++ b/journeys/profile/profile-cleanup-failure.xml @@ -12,7 +12,7 @@ cleanup, not cross-device conditional deletion or safe manual replay of an uncertain operation. - With an active subscription, confirm profile deletion or unlink and verify the localized instruction to end active subscriptions + With an active subscription, confirm profile deletion or unlink and verify the localized refusal; show the latest expiry only when all blocking subscriptions have known finite ends, and no date for open-ended or unknown terms Verify there is no generic Retry dialog, endpoint withdrawal, contact removal or profile deletion, including for the unrelated contact After ending the subscription, explicitly confirm removal again and verify acknowledged cleanup succeeds Open Edit Profile and confirm deletion with the cleanup fault enabled