Skip to content

Fix cross-account credential contamination: per-account TPPUserAccount instances#822

Merged
mauricecarrier7 merged 6 commits intomodernize/whole-shotfrom
fix/per-account-user-credentials
Apr 15, 2026
Merged

Fix cross-account credential contamination: per-account TPPUserAccount instances#822
mauricecarrier7 merged 6 commits intomodernize/whole-shotfrom
fix/per-account-user-credentials

Conversation

@mauricecarrier7
Copy link
Copy Markdown
Contributor

@mauricecarrier7 mauricecarrier7 commented Apr 9, 2026

Summary

  • Eliminates 6-year-old TOCTOU race in TPPUserAccount singleton that caused credential cross-contamination between library accounts during account switches (PP-4020 F-034, blocker)
  • Replaces mutable-singleton architecture with per-account instances cached in AccountsManager — each instance has immutable keychain keys, making credential cross-contamination structurally impossible
  • Migrates all ~65 production call sites across 37 files from sharedAccount() to instance-based access
  • Includes bandaid fix (atomic credentialSnapshot reads + sign-in modal deduplication guard) for defense-in-depth

Root cause

TPPUserAccount was a singleton that mutated libraryUUID to switch which keychain keys it read/wrote. Between sharedAccount(libraryUUID:) returning and .username/.pin being read, another thread could switch the UUID — sending Account B's credentials to Account A's token endpoint, or writing them into Account A's keychain slot.

Architecture change

  • TPPUserAccount(libraryUUID:) — new initializer with immutable boundLibraryUUID and its own accountInfoQueue
  • AccountsManager.userAccount(for:) — caches per-library instances, returns stable identity
  • TPPUserAccountResolving protocol for DI
  • Instance-level credentialSnapshot() (no UUID switching needed)
  • SignInModalPresenter.isPresenting guard prevents modal stacking from concurrent 401s

What's deferred

Legacy singleton init() retained as internal for test mock compatibility (TPPUserAccountMock subclasses it). Full singleton removal requires refactoring ~30 test files — separate PR.

Test plan

  • 4,502 unit tests pass (0 failures)
  • Build succeeds on Palace scheme
  • ADV-1: Rapid account switching under network load on local device — verify no credential bleed
  • ADV-2: Corrupt token → verify single sign-in modal (no stacking)
  • ADV-3: Sign out under stale credentials completes without loop

Regression context

PP-4020 findings F-033 (reclassified from regression to pre-existing) and F-034 (blocker, fixed). Full technical analysis in ~/Desktop/regression-PP-4020/findings.md.

🤖 Generated with Claude Code


PP-4020 Traceability

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 9, 2026

🧪 Unit Test Results

📊 View Full Interactive Report

❌ 5 TESTS FAILED

5433 tests | 5391 passed | 5 failed | 37 skipped | ⏱️ 5m 0s | 📊 99.2% | 📈 38.6% coverage

Tests by Class

Class Tests Passed Failed Duration
✅ AccessLintComplianceTests 11 11 0 48ms
✅ AccessibilityAnnouncementCenterTests 20 20 0 1.70s
✅ AccessibilityLabelTests 9 9 0 28ms
✅ AccessibilityPreferencesTests 26 26 0 123ms
✅ AccessibilityServiceTests 11 11 0 61ms
✅ AccountAuthDocCarryoverTests 5 5 0 520ms
✅ AccountAwareNetworkTests 10 10 0 124ms
✅ AccountDetailCredentialStateTests 8 8 0 47ms
✅ AccountDetailPINVisibilityTests 25 25 0 81ms
✅ AccountDetailViewModelGapTests 1 1 0 136ms
✅ AccountDetailViewModelTests 19 19 0 70ms
✅ AccountDetailsURLTests 17 17 0 87ms
✅ AccountModelGapTests 9 9 0 893ms
✅ AccountModelTests 16 16 0 82ms
✅ AccountProfileDocumentTests 2 2 0 7ms
✅ AccountSwitchCleanupTests 8 8 0 93ms
✅ AccountSwitchIntegrationTests 8 8 0 223ms
✅ AccountsManagerCacheTests 15 15 0 434ms
✅ AccountsManagerGapTests 3 3 0 16ms
✅ AccountsManagerTests 52 52 0 5.62s
✅ AdobeCertificateGapTests 7 7 0 24ms
✅ AdobeDRMErrorGapTests 3 3 0 14ms
✅ AdobeDRMServiceGapTests 2 2 0 4ms
✅ AlertModelCoverageTests 6 6 0 14ms
✅ AlertModelRetryTests 15 15 0 72ms
✅ AlertModelTests 2 2 0 15ms
✅ AlertUtilsTests 24 24 0 219ms
✅ AnnotationContractTests 3 3 0 24ms
✅ AnnotationDeviceIDTests 3 3 0 5ms
✅ AnnouncementTests 3 3 0 9ms
✅ AppHealthViewModelTests 8 8 0 1.31s
✅ AppLaunchTrackerExtendedTests 17 17 0 426ms
✅ AppLaunchTrackerTests 10 10 0 345ms
✅ AppRouteTests 5 5 0 31ms
✅ AppTabRouterCoverageTests 5 5 0 14ms
✅ AppTabRouterGapTests 4 4 0 8ms
✅ ArrayExtensionsTests 8 8 0 26ms
✅ AudioBookmarkGapTests 6 6 0 30ms
✅ AudioInterruptionLogicTests 6 6 0 16ms
✅ AudiobookAccessibilityTests 7 7 0 14ms
✅ AudiobookBackgroundAudioTests 2 2 0 52ms
✅ AudiobookBookmarkBusinessLogicTests 17 17 0 4.41s
✅ AudiobookDataManagerEmptyQueueTests 1 1 0 9ms
✅ AudiobookDataManagerErrorHandlingTests 5 5 0 11.12s
✅ AudiobookDataManagerModelsTests 20 20 0 64ms
✅ AudiobookDataManagerNetworkSyncTests 5 5 0 5.66s
✅ AudiobookDataManagerSaveTests 4 4 0 1.47s
✅ AudiobookDataManagerStoreRecoveryTests 5 5 0 2.10s
✅ AudiobookEventsCoverageTests 1 1 0 13ms
✅ AudiobookEventsTests 1 1 0 9ms
✅ AudiobookFileLoggerTests 9 9 0 72ms
✅ AudiobookPlaybackStateTests 3 3 0 44ms
✅ AudiobookPlaybackTests 26 26 0 454ms
✅ AudiobookPlayerSnapshotTests 2 2 0 11ms
✅ AudiobookSAMLReauthTests 6 6 0 48ms
✅ AudiobookSessionErrorDescriptionTests 4 4 0 24ms
✅ AudiobookSessionErrorExtTests 4 4 0 9ms
✅ AudiobookSessionErrorTests 8 8 0 32ms
✅ AudiobookSessionManagerTests 3 3 0 66ms
✅ AudiobookSessionStateTests 6 6 0 21ms
✅ AudiobookSessionStateTransitionTests 18 18 0 120ms
✅ AudiobookSleepTimerIntegrationTests 5 5 0 179ms
✅ AudiobookStorageLocationTests 3 3 0 13ms
✅ AudiobookTOCTests 18 18 0 267ms
✅ AudiobookTimeEntryTests 6 6 0 25ms
✅ AudiobookTimeTrackerEdgeTests 8 8 0 116ms
✅ AudiobookTimeTrackerLifecycleTests 5 5 0 1.11s
✅ AudiobookTimeTrackerTests 9 9 0 90ms
✅ AudiobookTrackCompletionTests 2 2 0 99ms
✅ AudiobookTypeRoutingTests 5 5 0 56ms
✅ AudiobookmarkTests 4 4 0 34ms
✅ AuthDocumentContractTests 2 2 0 9ms
✅ AuthErrorCategoryTests 12 12 0 95ms
✅ AuthFlowSecurityTests 7 3 0 73ms
✅ AuthTypeTests 7 7 0 17ms
✅ AuthenticationTests 16 16 0 73ms
✅ BackgroundDownloadHandlerTests 28 28 0 124ms
✅ BackgroundListenerTests 2 2 0 37ms
✅ BadgeDefinitionTests 33 33 0 6.71s
✅ BadgeServiceTests 16 16 0 108ms
✅ BadgesViewModelTests 14 14 0 542ms
✅ BasicAuthEmptyCredentialTests 4 4 0 31ms
✅ BearerTokenFulfillFlowTests 4 4 0 36ms
✅ BearerTokenRefreshTests 4 4 0 6ms
✅ BearerTokenResponseDetectionTests 7 7 0 27ms
✅ BookAvailabilityFormatterTests 18 18 0 1.03s
✅ BookButtonMapperHoldReadyTests 12 12 0 41ms
✅ BookButtonMapperTests 14 14 0 65ms
✅ BookButtonMapperViewModelTests 18 18 0 72ms
✅ BookButtonStateTests 8 8 0 87ms
✅ BookButtonTypeTests 13 13 0 56ms
✅ BookCellModelActionTests 15 15 0 101ms
✅ BookCellModelCacheInvalidationTests 8 8 0 115ms
✅ BookCellModelCachePrefetchSafetyTests 9 9 0 181ms
✅ BookCellModelCacheTests 25 25 0 507ms
✅ BookCellModelComputedPropertyTests 19 19 0 247ms
✅ BookCellModelRegistryBindingTests 4 4 0 349ms
✅ BookCellModelStateTests 16 16 0 547ms
✅ BookCellStateComprehensiveTests 14 14 0 46ms
✅ BookDetailSnapshotTests 17 17 0 90ms
✅ BookDetailViewModelTests 81 81 0 1.19s
✅ BookPreviewTests 4 4 0 27ms
✅ BookRegistryStoreTests 29 29 0 2.12s
✅ BookRegistrySyncTests 26 19 0 1.65s
✅ BookStateIntegrationTests 8 8 0 76ms
✅ BookmarkBusinessLogicExtendedTests 10 10 0 1.30s
✅ BookmarkDeletionLogTests 3 3 0 691ms
✅ BookmarkDeviceIdMatchingTests 3 3 0 4.25s
✅ BookmarkExistenceTests 4 4 0 661ms
✅ BookmarkManagerTests 26 26 0 10.91s
✅ BookmarkReauthenticationTests 1 1 0 106ms
✅ BookmarkSortingTests 1 1 0 102ms
✅ BookmarkSyncTests 3 3 0 362ms
✅ BorrowErrorMessageTests 13 13 0 58ms
✅ ButtonStateTests 22 22 0 163ms
✅ ButtonStyleTypeTests 2 2 0 4ms
✅ C64ConversionTests 6 6 0 14ms
✅ CarPlayChapterListTests 3 3 0 8ms
✅ CarPlayIntegrationTests 2 2 0 26ms
✅ CarPlayLibraryRefreshTests 3 3 0 10ms
✅ CarPlayNowPlayingTemplateTests 4 4 0 525ms
✅ CarPlayOpenAppAlertTests 4 4 0 6ms
✅ CarPlayPlaybackErrorTests 8 8 0 24ms
✅ CarPlayTests 13 13 0 57ms
✅ CarPlayTimeTrackingTests 3 3 0 73ms
✅ CatalogAPIEntryPointTests 1 1 0 32ms
✅ CatalogAccessibilityTests 8 8 0 25ms
✅ CatalogCacheMetadataTests 20 20 0 122ms
✅ CatalogFeedModelTests 4 4 0 17ms
✅ CatalogFilterGroupModelTests 17 17 0 42ms
✅ CatalogFilterGroupTests 3 3 0 9ms
✅ CatalogFilterModelTests 17 17 0 46ms
✅ CatalogFilterServiceTests 29 29 0 87ms
✅ CatalogFilterTests 3 3 0 10ms
✅ CatalogLaneModelStructTests 18 18 0 301ms
✅ CatalogLaneModelTests 4 4 0 15ms
✅ CatalogLaneMoreFilterStateTests 8 8 0 30ms
✅ CatalogLaneMoreViewModelTests 33 33 0 145ms
✅ CatalogLaneRowViewAccessibilityTests 11 11 0 48ms
✅ CatalogLaneSortingTests 4 4 0 119ms
✅ CatalogLoadIntegrationTests 6 6 0 185ms
✅ CatalogRepositoryCoreTests 9 9 0 277ms
✅ CatalogRepositoryTests 19 19 0 151ms
✅ CatalogSearchViewModelRegistryUpdateTests 5 5 0 57ms
✅ CatalogSearchViewModelTests 59 59 0 6.48s
✅ CatalogSnapshotTests 10 10 0 247ms
✅ CatalogSortServiceTests 14 14 0 124ms
✅ CatalogViewModelIntegrationTests 11 11 0 60ms
✅ CatalogViewModelOptimisticLoadingTests 5 5 0 69ms
✅ ChaosFaultInjectionTests 5 5 0 63ms
✅ CirculationAnalyticsTests 4 4 0 25ms
✅ ColorExtensionTests 5 5 0 11ms
✅ ConcurrentBookStateTests 3 3 0 29ms
✅ ConcurrentDownloadStateTests 3 3 0 60ms
✅ ConcurrentTokenRefreshTests 2 2 0 60ms
✅ ContinuousPlaybackTrackingTests 3 3 0 428ms
✅ CredentialEdgeCaseTests 6 6 0 11ms
✅ CredentialPrivacyTests 5 4 0 17ms
✅ CrossDomain401Tests 8 8 0 28ms
✅ CrossFormatMappingTests 14 14 0 42ms
✅ DPLAErrorTests 3 3 0 9ms
✅ DRMAdversarialTests 6 1 0 79ms
✅ DRMFulfilledPublicationTests 6 6 0 22ms
✅ DataBase64Tests 5 5 0 9ms
✅ DataReceptionComparisonTests 2 2 0 33ms
✅ DateExtensionTests 9 9 0 20ms
✅ DateFormattingTests 4 4 0 109ms
✅ Date_NYPLAdditionsTests 7 7 0 529ms
✅ DebugSettingsTests 27 27 0 116ms
✅ DefaultCatalogAPITests 31 31 0 310ms
✅ DeriveInitialStateTests 4 4 0 30ms
✅ DeviceLogCollectorGapTests 2 2 0 6.22s
✅ DeviceLogCollectorTests 9 9 0 14.53s
✅ DeviceOrientationTests 7 7 0 42ms
✅ DeviceSpecificErrorMonitorTests 11 11 0 28ms
✅ DictionaryExtensionsTests 5 5 0 11ms
✅ DiskBudgetTests 2 2 0 7ms
✅ DownloadCoordinatorIntegrationTests 10 10 0 476ms
✅ DownloadCoordinatorTests 11 11 0 73ms
✅ DownloadDiskSpaceTests 2 2 0 5ms
✅ DownloadErrorInfoTests 3 3 0 13ms
✅ DownloadErrorRecoveryPolicyTests 18 18 0 1.21s
✅ DownloadErrorRecoveryTests 3 3 0 22ms
✅ DownloadInfoTests 5 5 0 19ms
✅ DownloadOnlyOnWiFiTests 12 12 0 48ms
✅ DownloadPersistenceStoreTests 5 5 0 50ms
✅ DownloadProgressPublisherCoreTests 19 19 0 1.64s
✅ DownloadProgressPublisherTests 2 2 0 10ms
✅ DownloadQueueIntegrationTests 3 3 0 51ms
✅ DownloadRedirectTests 7 7 0 16ms
✅ DownloadSlotManagementTests 5 5 0 20ms
✅ DownloadStateMachineIntegrationTests 9 9 0 40ms
✅ DownloadStateMachineTests 5 5 0 37ms
✅ DownloadStateManagerTests 14 14 0 200ms
❌ DownloadWatchdogTests 3 2 1 32ms
✅ EPUBModuleTests 4 4 0 18ms
✅ EPUBPositionTests 8 8 0 73ms
✅ EPUBSearchViewModelTests 18 18 0 120ms
✅ EPUBToolbarToggleTests 14 14 0 50ms
✅ EmailAddressTests 16 16 0 59ms
✅ EpubSampleFactoryTests 11 11 0 103ms
✅ ErrorActivityTrackerTests 12 12 0 38ms
✅ ErrorDetailTests 12 12 0 605ms
✅ ErrorDetailViewControllerGapTests 3 3 0 239ms
✅ ErrorDetailViewControllerTests 18 18 0 501ms
✅ ErrorLogExporterTests 5 5 0 11.28s
✅ ExpiredLoanStringsTests 5 5 0 13ms
✅ FacetEnumTests 3 3 0 7ms
✅ FacetToolbarAccessibilityTests 5 5 0 25ms
✅ FacetViewModelLogoDelegateTests 4 4 0 44ms
✅ FacetViewModelTests 18 18 0 120ms
✅ FacetsSelectorSnapshotTests 9 9 0 93ms
✅ FetchManifestWithBearerTokenLCPSafetyTests 1 1 0 14ms
✅ FetchManifestWithBearerTokenTests 9 9 0 134ms
✅ FetchOpenAccessManifestLCPSafetyTests 4 4 0 10ms
✅ FileURLGenerationTests 3 3 0 39ms
✅ FloatTPPAdditionsTests 11 11 0 23ms
✅ FocusIndicationTests 7 7 0 26ms
✅ FontManagerTests 17 17 0 59ms
✅ GeneralCacheTests 18 18 0 85ms
✅ GroupEnumTests 1 1 0 14ms
✅ HTMLTextViewTests 70 70 0 18.51s
✅ HoldNotificationClassificationTests 3 3 0 24ms
✅ HoldsBadgeCountTests 9 9 0 52ms
✅ HoldsBookViewModelTests 8 8 0 49ms
✅ HoldsSnapshotTests 7 7 0 43ms
✅ HoldsSyncFailureTests 8 8 0 52ms
✅ HoldsViewModelTests 23 23 0 1.48s
✅ ImageCacheTypeTests 1 1 0 4ms
✅ IntExtensionsTests 6 6 0 40ms
✅ KeyboardNavigationFKATests 11 11 0 342ms
✅ KeyboardNavigationHandlerTests 16 16 0 57ms
✅ KeyboardVoiceOverTests 7 7 0 99ms
✅ LCPAudiobookURLSchemeTests 4 4 0 8ms
✅ LCPAudiobooksTests 18 18 0 122ms
✅ LCPLibraryServiceTests 20 20 0 2.01s
✅ LCPLicenseDocumentDetectionTests 5 5 0 15ms
✅ LCPLicenseFilePathTests 3 3 0 14ms
✅ LCPOrphanedDownloadRegistryTests 4 4 0 20ms
✅ LCPPDFManifestTests 3 3 0 11ms
✅ LCPPDFsTests 13 13 0 132ms
✅ LCPSessionIdentifierTests 3 3 0 86ms
✅ LatestAudiobookLocationTests 3 3 0 16ms
✅ LicensesServiceTests 10 10 0 29ms
✅ LogTests 14 14 0 338ms
✅ LoginKeyboardTests 8 8 0 87ms
✅ MainActorHelpersTests 22 22 0 789ms
✅ MappedCatalogModelTests 11 11 0 596ms
✅ MappedCatalogTests 3 3 0 12ms
✅ MyBooksDownloadCenterAdeptGapTests 3 3 0 231ms
✅ MyBooksSimplifiedBearerTokenTests 17 17 0 3.38s
✅ MyBooksViewModelBooksPublisherTests 3 3 0 47ms
✅ MyBooksViewModelConcurrencyTests 4 4 0 81ms
✅ MyBooksViewModelDownloadStateTests 3 3 0 16ms
✅ MyBooksViewModelEmptyArrayTests 3 3 0 9ms
✅ MyBooksViewModelEmptyStateTests 4 4 0 50ms
✅ MyBooksViewModelExtendedTests 15 15 0 67ms
✅ MyBooksViewModelFacetIntegrationTests 4 4 0 32ms
✅ MyBooksViewModelFacetPublisherTests 3 3 0 9ms
✅ MyBooksViewModelFilterSortInteractionTests 2 2 0 28ms
✅ MyBooksViewModelFilterTests 9 9 0 98ms
✅ MyBooksViewModelGuardConditionsTests 2 2 0 40ms
✅ MyBooksViewModelLargeDatasetTests 2 2 0 306ms
✅ MyBooksViewModelLoadAccountTests 2 2 0 281ms
✅ MyBooksViewModelLoginStateTests 4 4 0 534ms
✅ MyBooksViewModelMultipleAuthorSortingTests 3 3 0 36ms
✅ MyBooksViewModelNotificationTests 4 4 0 1.03s
✅ MyBooksViewModelOfflineFilteringTests 3 3 0 22ms
✅ MyBooksViewModelPublisherTests 7 7 0 18ms
✅ MyBooksViewModelSearchEdgeCaseTests 6 6 0 43ms
✅ MyBooksViewModelSearchQueryTests 3 3 0 9ms
✅ MyBooksViewModelSortPersistenceTests 3 3 0 18ms
✅ MyBooksViewModelSortingIntegrationTests 5 5 0 46ms
✅ MyBooksViewModelSortingTests 6 6 0 45ms
✅ MyBooksViewModelStateTransitionTests 3 3 0 516ms
✅ MyBooksViewModelUIBindingTests 3 3 0 7ms
✅ NSErrorAdditionsTests 7 7 0 14ms
✅ NSNotificationTPPTests 3 3 0 22ms
✅ NavigationCoordinatorTests 17 17 0 62ms
✅ NavigationFreezePreventionTests 5 5 0 17ms
❌ NetworkExecutorCredentialGuardTests 9 7 2 94ms
✅ NetworkExecutorResponseRegressionTests 4 4 0 24ms
✅ NetworkExecutorTaskTypeTests 3 3 0 30ms
✅ NetworkOfflineDetectionTests 3 3 0 11ms
✅ NetworkQueueTests 11 11 0 52ms
✅ NetworkRequestQueueTests 2 2 0 553ms
✅ NetworkRetryLogicTests 7 7 0 28ms
✅ NetworkTimeoutTests 3 3 0 6ms
✅ NotificationServiceTests 19 19 0 203ms
✅ NotificationServiceTokenTests 9 9 0 25ms
✅ NotificationSyncThrottleTests 7 7 0 27ms
✅ NotificationTokenDataTests 4 4 0 12ms
✅ NowPlayingCoordinatorTests 19 19 0 702ms
✅ OAuthSAMLRedirectRegressionTests 4 4 0 510ms
✅ OIDCAuthDocumentParsingTests 4 4 0 404ms
✅ OIDCAuthTypeTests 5 5 0 8ms
✅ OIDCAuthenticationPropertyTests 8 8 0 855ms
✅ OIDCCallbackEdgeCaseTests 9 9 0 1.10s
✅ OIDCCallbackHandlingTests 5 5 0 677ms
✅ OIDCCallbackSchemeTests 3 3 0 10ms
✅ OIDCExplicitLogoutTests 5 0 0 514ms
✅ OIDCIsolationRegressionTests 6 6 0 4.48s
✅ OIDCLoginRoutingTests 3 3 0 411ms
✅ OIDCMakeRequestTests 3 3 0 341ms
✅ OIDCNSCodingTests 1 1 0 103ms
✅ OIDCNetworkLayer401Tests 5 5 0 540ms
✅ OIDCReauthOnExpiredTokenTests 5 5 0 556ms
✅ OIDCRedirectURIConstructionTests 6 6 0 603ms
✅ OIDCRegressionTests 9 9 0 913ms
✅ OIDCSelectedAuthenticationTests 2 2 0 260ms
✅ OIDCSignOutRegressionTests 5 5 0 715ms
✅ OIDCTokenRefreshRegressionTests 6 6 0 620ms
✅ OIDCUpdateUserAccountTests 5 5 0 568ms
✅ OIDCViewModelRegressionTests 1 1 0 99ms
✅ OIDCViewModelSignInTests 2 2 0 5ms
✅ OPDS2AuthenticationDocumentTests 18 18 0 120ms
✅ OPDS2AvailabilityTests 4 4 0 16ms
✅ OPDS2BookBridgeTests 44 44 0 211ms
✅ OPDS2CatalogWiringTests 14 14 0 109ms
✅ OPDS2CatalogsFeedTests 3 3 0 270ms
✅ OPDS2ContributorTests 2 2 0 4ms
✅ OPDS2FeedContractTests 5 5 0 77ms
✅ OPDS2FeedParsingTests 11 11 0 474ms
✅ OPDS2FeedTests 13 13 0 65ms
✅ OPDS2FullMetadataTests 4 4 0 14ms
✅ OPDS2FullPublicationTests 13 13 0 65ms
✅ OPDS2IntegrationTests 18 18 0 165ms
✅ OPDS2LinkArrayTests 5 5 0 18ms
✅ OPDS2LinkComputedPropertyTests 20 20 0 78ms
✅ OPDS2LinkRelTests 1 1 0 6ms
✅ OPDS2LinkTests 2 2 0 262ms
✅ OPDS2PublicationExtendedTests 46 46 0 531ms
✅ OPDS2PublicationImageTests 6 6 0 12ms
✅ OPDS2PublicationTests 2 2 0 220ms
✅ OPDS2SamlIDPTests 6 6 0 23ms
✅ OPDS2SubjectTests 2 2 0 6ms
✅ OPDS2SupportingTypesTests 5 5 0 40ms
✅ OPDSAcquisitionPathExpandedTests 19 19 0 405ms
✅ OPDSFeedCacheTests 14 14 0 187ms
✅ OPDSFeedParsingTests 2 2 0 159ms
✅ OPDSFeedServiceTests 5 5 0 69ms
✅ OPDSFormatTests 13 13 0 29ms
✅ OPDSParserCoreTests 4 4 0 15ms
✅ OPDSParserTests 4 4 0 8ms
✅ OPDSParsingTests 47 47 0 1.83s
✅ OfflineActionTests 29 29 0 204ms
✅ OfflineQueueServiceExtendedTests 13 13 0 5.18s
✅ OfflineQueueServiceTests 17 17 0 7.13s
✅ PDFExtensionsTests 20 20 0 647ms
✅ PDFReaderTests 12 12 0 43ms
✅ PDFViewsSnapshotTests 15 15 0 124ms
✅ PP3596RegressionTests 3 3 0 55ms
❌ Palace 2 1 1 <1ms
✅ PalaceCheckPropertyTests 8 8 0 233ms
✅ PalaceErrorCategoryTests 17 17 0 42ms
✅ PalaceErrorExtendedTests 29 29 0 87ms
✅ PalaceErrorTests 11 11 0 100ms
✅ ParserFuzzTests 4 4 0 45.70s
✅ PerformanceMonitorTests 14 14 0 166ms
✅ PerformanceReportTests 14 14 0 55ms
✅ PersistentLoggerTests 7 7 0 65ms
✅ PlaybackBootstrapperTests 12 12 0 896ms
✅ PlaybackRateTests 18 18 0 174ms
✅ PlaybackTrackingRegressionTests 5 5 0 109ms
✅ PositionPersistenceLogicTests 6 6 0 38ms
✅ PositionPersistenceTests 2 2 0 5ms
✅ PositionSyncServiceTests 13 13 0 323ms
✅ PositionSyncTests 5 5 0 29ms
✅ PositionThrottlingTests 1 1 0 11ms
✅ PostUpdateMigrationTests 5 5 0 61ms
✅ ProblemDocumentContractTests 4 4 0 11ms
✅ ProblemDocumentLoanExpiryTests 5 5 0 9ms
✅ ProblemDocumentTests 12 12 0 26ms
✅ ProblemReportEmailTests 16 16 0 44ms
✅ ReachabilityTests 6 6 0 342ms
✅ ReaderAccessibilityTests 7 7 0 12ms
✅ ReaderErrorTests 5 5 0 9ms
✅ ReaderServiceSyncTests 3 3 0 29ms
✅ ReaderThemeTests 24 24 0 72ms
✅ ReadingPositionTests 22 22 0 63ms
✅ ReadingSessionTrackerTests 13 13 0 847ms
✅ ReadingStatsServiceTests 12 12 0 92ms
✅ ReadingStatsStoreTests 9 9 0 78ms
✅ RedirectHandlingIntegrationTests 4 4 0 12ms
✅ RemoteFeatureFlagsGapTests 4 4 0 25ms
✅ RemoteFeatureFlagsTests 9 9 0 73ms
✅ ReservationsSnapshotTests 9 9 0 66ms
✅ RetryClassificationTests 17 17 0 55ms
✅ ReturnFlowTests 1 1 0 5ms
✅ RightsManagementDetectionTests 5 5 0 11ms
✅ SAMLCookieSyncTests 5 5 0 70ms
✅ SAMLHelperTests 5 5 0 27ms
✅ SAMLPlusBiblioBoardExpirationTests 8 8 0 1.58s
✅ SEMigrationsTests 6 6 0 39ms
✅ SafeDictionaryTests 21 21 0 84ms
✅ SamplePlayerErrorTests 5 5 0 11ms
✅ SampleTypeTests 8 8 0 14ms
✅ SceneDelegateTests 1 1 0 5ms
✅ SearchAccessibilityTests 6 6 0 40ms
✅ SearchFlowIntegrationTests 8 8 0 105ms
✅ SearchSnapshotTests 5 5 0 59ms
✅ SettingsSnapshotTests 9 9 0 133ms
✅ SettingsViewModelComputedPropertyTests 6 6 0 12ms
✅ SettingsViewModelEdgeCaseTests 7 7 0 167ms
✅ SettingsViewModelGapTests 1 1 0 3ms
✅ SettingsViewModelSyncTests 14 14 0 107ms
✅ SettingsViewModelTests 33 33 0 458ms
✅ SignOutCacheClearingTests 3 3 0 6ms
✅ StatsViewModelTests 10 10 0 248ms
✅ StatusAnnouncementTests 22 22 0 84ms
✅ StopPositionSaveTests 2 2 0 4ms
✅ StringExtensionTests 8 8 0 18ms
✅ StringExtensionsTests 10 10 0 62ms
✅ StringHTMLEntitiesTests 10 10 0 34ms
✅ StringNYPLAdditionsTests 4 4 0 22ms
✅ String_NYPLAdditionsTests 5 5 0 10ms
✅ SyncConflictResolutionTests 3 3 0 5ms
✅ SyncDeletionGuardTests 5 5 0 96ms
✅ SyncDeletionRatioTests 6 6 0 18ms
✅ SyncPermissionTests 5 5 0 273ms
✅ TPPAccountAuthStateEnumTests 5 5 0 25ms
✅ TPPAccountListDataSourceTests 3 3 0 10ms
✅ TPPAdobeActivationSkipTests 6 6 0 728ms
✅ TPPAgeCheckTests 6 6 0 1.58s
✅ TPPAlertUtilsTests 45 45 0 1.44s
✅ TPPAnnotationsHermeticTests 15 15 0 117ms
✅ TPPAnnotationsTests 29 29 0 2.93s
✅ TPPAnnouncementManagerTests 3 3 0 9ms
✅ TPPBackgroundExecutorTests 5 3 0 2.55s
✅ TPPBadgeImageGapTests 2 2 0 13ms
✅ TPPBasicAuthTests 11 11 0 40ms
✅ TPPBookAccessibilityLabelTests 8 8 0 33ms
✅ TPPBookAuthorCoverageTests 3 3 0 11ms
✅ TPPBookAuthorTests 6 6 0 24ms
✅ TPPBookBearerTokenTests 9 8 0 157ms
✅ TPPBookContentMetadataFilesHelperTests 9 9 0 513ms
✅ TPPBookContentTypeConverterTests 4 4 0 7ms
✅ TPPBookContentTypeExtendedTests 4 4 0 8ms
✅ TPPBookContentTypeTests 14 14 0 48ms
✅ TPPBookCoverRegistryTests 14 14 0 527ms
✅ TPPBookCreationTests 3 3 0 29ms
✅ TPPBookExtensionsTests 22 22 0 107ms
✅ TPPBookLocationCoverageTests 7 7 0 32ms
✅ TPPBookLocationEdgeCaseTests 27 27 0 88ms
✅ TPPBookLocationKeyTests 3 3 0 6ms
✅ TPPBookLocationTests 11 11 0 81ms
✅ TPPBookModelGapTests 4 4 0 89ms
✅ TPPBookRegistryBookRetrievalTests 7 7 0 59ms
✅ TPPBookRegistryBookmarkTests 7 6 0 69ms
✅ TPPBookRegistryCorruptedDataTests 5 5 0 63ms
✅ TPPBookRegistryDataTests 4 4 0 7ms
✅ TPPBookRegistryFulfillmentIdTests 4 4 0 12ms
✅ TPPBookRegistryLoadReentrancyTests 2 2 0 5ms
✅ TPPBookRegistryLocationTests 4 4 0 38ms
✅ TPPBookRegistryProcessingTests 2 2 0 4ms
✅ TPPBookRegistryPublisherTests 6 6 0 133ms
✅ TPPBookRegistryRecordPersistenceTests 3 3 0 53ms
✅ TPPBookRegistryRecordTests 10 10 0 58ms
✅ TPPBookRegistryStateManagementTests 11 11 0 85ms
✅ TPPBookRegistryThreadSafetyTests 3 3 0 281ms
✅ TPPBookRegistryUpdateAndRemoveTests 1 1 0 6ms
✅ TPPBookRequiresAdobeDRMTests 6 6 0 992ms
✅ TPPBookSerializationTests 9 9 0 79ms
✅ TPPBookStateInitializationTests 4 4 0 13ms
✅ TPPBookStateTests 4 4 0 14ms
✅ TPPBookTests 75 74 0 222ms
✅ TPPBookmarkDeletionLogTests 11 11 0 97ms
✅ TPPBookmarkFactoryInitTests 2 2 0 11ms
✅ TPPBookmarkFactoryServerAnnotationEdgeCaseTests 5 5 0 37ms
✅ TPPBookmarkFactoryTests 15 15 0 188ms
✅ TPPBookmarkR3ConversionTests 5 5 0 22ms
✅ TPPBookmarkR3LocationTests 13 13 0 504ms
✅ TPPBookmarkSpecTests 1 1 0 10ms
✅ TPPCachingTests 3 3 0 14ms
✅ TPPCapturedCredentialsTests 5 5 0 437ms
✅ TPPConfigurationTests 23 23 0 440ms
✅ TPPContentTypeTests 7 7 0 48ms
✅ TPPCredentialConcurrencyTests 3 3 0 12ms
✅ TPPCredentialPersistenceTests 6 6 0 777ms
✅ TPPCredentialSnapshotTests 8 8 0 16ms
✅ TPPCredentialsCoverageTests 9 9 0 46ms
✅ TPPCredentialsTests 26 26 0 215ms
✅ TPPCrossLibrarySignOutTests 6 6 0 639ms
✅ TPPDRMFailureCredentialPreservationTests 4 4 0 458ms
✅ TPPEncryptedPDFDataProviderTests 3 3 0 20ms
✅ TPPErrorLoggerTests 27 27 0 166ms
✅ TPPIdleSignOutRegressionTests 13 13 0 1.50s
✅ TPPJWKConversionTest 1 1 0 92ms
✅ TPPKeychainManagerTests 5 5 0 45ms
✅ TPPKeychainStoredVariableTests 9 9 0 36ms
✅ TPPKeychainSwiftLegacyTests 1 0 0 6ms
✅ TPPKeychainSwiftTests 8 0 0 173ms
✅ TPPLastReadPositionPosterTests 6 6 0 46ms
✅ TPPLastReadPositionSynchronizerIntegrationTests 5 5 0 22ms
✅ TPPLastReadPositionSynchronizerTests 23 23 0 50ms
✅ TPPLastReadPositionSynchronizer_BehaviorDocumentationTests 5 5 0 13ms
✅ TPPLastReadPositionSynchronizer_BookLocationTests 9 9 0 21ms
✅ TPPLastReadPositionSynchronizer_ConcurrencyTests 3 3 0 26ms
✅ TPPLastReadPositionSynchronizer_ReadiumBookmarkTests 9 9 0 25ms
✅ TPPLastReadPositionSynchronizer_SyncLogicTests 10 10 0 28ms
✅ TPPLoginNoActivationTests 3 3 0 276ms
✅ TPPMainThreadCheckerTests 4 4 0 32ms
✅ TPPMigrationManagerTests 15 15 0 76ms
✅ TPPNetworkExecutorAPITests 14 14 0 92ms
✅ TPPNetworkExecutorStubbedTests 17 17 0 1.14s
✅ TPPNetworkExecutorTests 3 3 0 10ms
✅ TPPNetworkResponderTests 12 12 0 34ms
✅ TPPOPDSAcquisitionPathTests 2 2 0 15ms
✅ TPPOPDSEntryTests 7 7 0 47ms
✅ TPPOPDSFeedTests 7 7 0 610ms
✅ TPPOPDSGroupSwiftTests 3 3 0 9ms
✅ TPPOPDSLinkTests 7 7 0 41ms
✅ TPPOpenSearchDescriptionExpandedTests 10 10 0 93ms
✅ TPPOpenSearchDescriptionTests 1 1 0 4ms
✅ TPPPDFDocumentMetadataTests 15 15 0 157ms
✅ TPPPDFDocumentTests 13 13 0 211ms
✅ TPPPDFLocationCoverageTests 7 7 0 18ms
✅ TPPPDFLocationTests 10 10 0 40ms
✅ TPPPDFPageBookmarkTests 9 9 0 25ms
✅ TPPPDFPageTests 5 5 0 12ms
✅ TPPPDFReaderModeTests 6 6 0 25ms
✅ TPPPerAccountIsolationTests 10 10 0 1.31s
✅ TPPProblemDocumentCacheManagerTests 12 12 0 199ms
✅ TPPProblemDocumentTests 21 21 0 59ms
✅ TPPReaderAppearanceTests 4 4 0 50ms
✅ TPPReaderBookmarksBusinessLogicTests 15 15 0 1.86s
✅ TPPReaderFontTests 4 4 0 15ms
✅ TPPReaderPreferencesLoadTests 3 3 0 16ms
✅ TPPReaderSettingsTests 28 28 0 95ms
✅ TPPReaderTOCBusinessLogicTests 15 15 0 3.18s
✅ TPPReaderTOCFlattenTests 2 2 0 1.01s
✅ TPPReadiumBookmarkLocationMatchingTests 5 5 0 35ms
✅ TPPReadiumBookmarkTests 18 18 0 59ms
✅ TPPReauthenticatorMockTests 2 2 0 11ms
✅ TPPReauthenticatorTests 4 4 0 21ms
✅ TPPReturnPromptHelperTests 5 5 0 42ms
✅ TPPSAMLReauthFlowTests 2 2 0 245ms
✅ TPPSAMLSignInTests 27 26 0 4.77s
✅ TPPSessionTests 8 8 0 880ms
✅ TPPSettingsTests 14 14 0 43ms
✅ TPPSignInAdobeSkipTests 14 14 0 1.60s
✅ TPPSignInAuthStateTransitionTests 3 3 0 2.17s
✅ TPPSignInBusinessLogicExtendedTests 42 42 0 4.09s
✅ TPPSignInBusinessLogicTests 6 6 0 632ms
✅ TPPSignInErrorHandlingTests 2 2 0 239ms
✅ TPPSignInProfileDocEdgeCaseTests 3 3 0 324ms
✅ TPPSignedInStateProviderTests 3 3 0 13ms
✅ TPPUserAccountAuthStateTests 7 7 0 36ms
✅ TPPUserAccountGapTests 4 4 0 10ms
✅ TPPUserFriendlyErrorTests 11 11 0 51ms
✅ TPPUserNotificationsTests 10 10 0 150ms
✅ TPPXMLSwiftTests 17 17 0 89ms
✅ TPPXMLTests 3 3 0 32ms
✅ TimeEntryTests 3 3 0 5ms
❌ TokenRefreshIntegrationTests 3 2 1 2.31s
✅ TokenRefreshInterceptorTests 17 17 0 1.53s
✅ TokenRefreshTests 25 25 0 304ms
✅ TokenRequestCredentialGuardTests 13 13 0 142ms
✅ TokenRequestTests 9 9 0 747ms
✅ TokenResponseTests 21 21 0 45ms
✅ TypographyPresetTests 21 21 0 128ms
✅ TypographyServiceTests 31 31 0 889ms
✅ TypographySettingsViewModelTests 27 27 0 505ms
✅ UIColor_NYPLAdditionsTests 1 1 0 2ms
✅ URLExtensionTests 16 16 0 35ms
✅ URLExtensionsTests 6 6 0 10ms
✅ URLRequestExtensionsCoverageTests 3 3 0 7ms
✅ URLRequestExtensionsTests 11 11 0 45ms
✅ URLRequestNYPLAdditionsTests 18 18 0 63ms
✅ URLRequest_NYPLTests 1 1 0 3ms
✅ URLResponseAuthenticationTests 10 10 0 22ms
✅ URLResponseNYPLTests 14 14 0 66ms
✅ URLSessionCredentialStorageTests 4 4 0 39ms
✅ URLTypeTests 2 2 0 24ms
✅ URLValidationTests 5 5 0 19ms
✅ UserAccountPublisherAuthStateTests 6 6 0 92ms
✅ UserAccountPublisherTests 11 11 0 414ms
✅ UserAccountValidationTests 5 5 0 36ms
✅ UserProfileDocumentTests 7 7 0 22ms
✅ UserRetryTrackerTests 10 10 0 33ms
Failed Tests (click to expand)
Palace.PalaceTests
TokenRefreshIntegrationTests.testExecuteTokenRefresh_EmptyPassword_NeverHitsNetwork
NetworkExecutorCredentialGuardTests.testExecuteTokenRefresh_EmptyPassword_FailsViaTokenRequestGuard
NetworkExecutorCredentialGuardTests.testExecuteTokenRefresh_EmptyUsername_FailsViaTokenRequestGuard
DownloadWatchdogTests.testDefaultConfiguration

📊 Testing Coverage Breakdown

Unit Test Line Coverage: 38.6%

Target Lines Covered
Palace.app 38.6%

E2E Journey Coverage (SpecterQA): 23/25 journeys passing

Journey inventory
Journey Replay
✅ app-launch recorded
⬜ audiobook-playback pending
✅ book-detail recorded
✅ book-transactions recorded
✅ borrow-book recorded
✅ catalog-browsing recorded
✅ catalog-filter recorded
✅ concurrent-borrow recorded
⬜ credential-isolation pending
✅ epub-reading recorded
✅ feed-refresh recorded
✅ library-picker recorded
✅ opds2-feed-parsing recorded
⬜ persistence-reading-position pending
⬜ place-hold pending
✅ reservations-empty recorded
✅ return-book recorded
⬜ return-loan pending
✅ search-flow recorded
✅ settings-screen recorded
✅ sign-out recorded
⬜ sleep-timer pending
✅ smoke-test recorded
✅ switch-library recorded
✅ tab-navigation recorded

Why two metrics? Unit tests cover business logic, parsing, and state management. The remaining ~63% is primarily SwiftUI/UIKit views, DRM integration, Firebase/analytics, and AppDelegate lifecycle — code that requires a running app. SpecterQA E2E journeys cover those paths by driving the real app on a simulator.


🔗 Interactive HTML Report | CI Run Details

📦 Downloadable Artifacts
Artifact Description
test-report 📄 Markdown + HTML reports
test-data 📊 JSON data for tooling
test-results 🔍 Full xcresult (open in Xcode)

@mauricecarrier7 mauricecarrier7 self-assigned this Apr 9, 2026
mauricecarrier7 added a commit that referenced this pull request Apr 13, 2026
Two related fixes addressing fallout from PR #822's per-account
TPPUserAccount migration. Both codify invariants that prevent the
download / borrow flow from misclassifying benign failures as
credentials problems and reflexively presenting a useless sign-in
modal that traps the user.

1) MyBooksDownloadCenter.userAccount becomes a computed property

The download center is a singleton (`@objc static let shared`).
Before this change its `userAccount` property was set ONCE at init
time from `AccountsManager.shared.currentUserAccount`. After the
per-account migration, that captures whichever account happened to
be current at app-launch time and never refreshes — so library
switches and fresh sign-ins are invisible to the download center.
Symptom: tap Download on a freshly-borrowed book → download center
checks `userAccount.hasCredentials()` against a stale instance,
sees `false`, fires the credentials path even though the user is
signed in.

Fix: replace the stored property with a computed property that
always reads `accountsManager.currentUserAccount`. The init param
is preserved as a test-only override (`injectedUserAccount`); no
production caller passes it.

2) handleBorrowAuthErrorIfNeeded refuses to call a borrow failure
   "auth-related" when the user already has an active loan

`processRegularDownload` (line 559) auto-re-borrows whenever the
registry's copy of a book still carries a `.borrow` primary
acquisition. For some catalogs the loans-feed entry retains the
borrow relation even after the loan is created, so the auto-re-
borrow fires for already-borrowed books. The server responds with
401 (loan-already-exists / cannot-issue-loan), which the network
responder maps to `TPPErrorCode.invalidCredentials`. Without a
guard, `handleBorrowAuthErrorIfNeeded` then concludes credentials
are bad, calls `markCredentialsStale()`, and presents the sign-in
modal — but the modal shows the user's *signed-in* AccountDetail
view (Sign out button visible, no input fields) titled "Sign in",
which is confusing and unfixable from the UI.

Fix: add a guard at the top of the auth-error decision path. If
the registry already has the book in any active-loan state
(`.downloadNeeded`, `.downloading`, `.downloadSuccessful`,
`.downloadFailed`, `.holding`, `.SAMLStarted`, `.used`,
`.returning`) AND credentials are present, the failure CANNOT be
a credentials problem — return `false` so the caller surfaces a
real error instead of a fake auth modal.

3) Architectural-invariant comment refinement on the SQ-005 anonymous
   guard in SignInModalPresenter — clarifies why the central choke
   point is the right place for the !needsAuth early-return.

These guards do NOT block legitimate re-auth flows. Genuine session
expiry (SAML cookies, OAuth tokens, OIDC) is detected by separate
problem-document type checks and explicit `markCredentialsStale()`
calls upstream, neither of which is short-circuited by the new
guards.

Replay artifact:
- .specterqa/replays/sign-in-a1qa-success.yaml — verifies SQ-001 fix
  end-to-end: PIN field visible, form submits, A1QA sign-in succeeds
  with the rotated credentials (01230000000237 / Lyrtest123).

Note: SQ-007 still has at least one un-traced call site that
presents the modal even with these guards in place; see the SQ-007
section of regression-PP-4020/specterqa-regression-findings.md for
the open investigation. The two guards committed here are the
correct systemic shape — they do not depend on the open issue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@mauricecarrier7 mauricecarrier7 force-pushed the fix/per-account-user-credentials branch from a1e818c to 8a16bc4 Compare April 14, 2026 19:58
mauricecarrier7 added a commit that referenced this pull request Apr 14, 2026
Two related fixes addressing fallout from PR #822's per-account
TPPUserAccount migration. Both codify invariants that prevent the
download / borrow flow from misclassifying benign failures as
credentials problems and reflexively presenting a useless sign-in
modal that traps the user.

1) MyBooksDownloadCenter.userAccount becomes a computed property

The download center is a singleton (`@objc static let shared`).
Before this change its `userAccount` property was set ONCE at init
time from `AccountsManager.shared.currentUserAccount`. After the
per-account migration, that captures whichever account happened to
be current at app-launch time and never refreshes — so library
switches and fresh sign-ins are invisible to the download center.
Symptom: tap Download on a freshly-borrowed book → download center
checks `userAccount.hasCredentials()` against a stale instance,
sees `false`, fires the credentials path even though the user is
signed in.

Fix: replace the stored property with a computed property that
always reads `accountsManager.currentUserAccount`. The init param
is preserved as a test-only override (`injectedUserAccount`); no
production caller passes it.

2) handleBorrowAuthErrorIfNeeded refuses to call a borrow failure
   "auth-related" when the user already has an active loan

`processRegularDownload` (line 559) auto-re-borrows whenever the
registry's copy of a book still carries a `.borrow` primary
acquisition. For some catalogs the loans-feed entry retains the
borrow relation even after the loan is created, so the auto-re-
borrow fires for already-borrowed books. The server responds with
401 (loan-already-exists / cannot-issue-loan), which the network
responder maps to `TPPErrorCode.invalidCredentials`. Without a
guard, `handleBorrowAuthErrorIfNeeded` then concludes credentials
are bad, calls `markCredentialsStale()`, and presents the sign-in
modal — but the modal shows the user's *signed-in* AccountDetail
view (Sign out button visible, no input fields) titled "Sign in",
which is confusing and unfixable from the UI.

Fix: add a guard at the top of the auth-error decision path. If
the registry already has the book in any active-loan state
(`.downloadNeeded`, `.downloading`, `.downloadSuccessful`,
`.downloadFailed`, `.holding`, `.SAMLStarted`, `.used`,
`.returning`) AND credentials are present, the failure CANNOT be
a credentials problem — return `false` so the caller surfaces a
real error instead of a fake auth modal.

3) Architectural-invariant comment refinement on the SQ-005 anonymous
   guard in SignInModalPresenter — clarifies why the central choke
   point is the right place for the !needsAuth early-return.

These guards do NOT block legitimate re-auth flows. Genuine session
expiry (SAML cookies, OAuth tokens, OIDC) is detected by separate
problem-document type checks and explicit `markCredentialsStale()`
calls upstream, neither of which is short-circuited by the new
guards.

Replay artifact:
- .specterqa/replays/sign-in-a1qa-success.yaml — verifies SQ-001 fix
  end-to-end: PIN field visible, form submits, A1QA sign-in succeeds
  with the rotated credentials (01230000000237 / Lyrtest123).

Note: SQ-007 still has at least one un-traced call site that
presents the modal even with these guards in place; see the SQ-007
section of regression-PP-4020/specterqa-regression-findings.md for
the open investigation. The two guards committed here are the
correct systemic shape — they do not depend on the open issue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
mauricecarrier7 and others added 6 commits April 15, 2026 13:52
…via AccountsManager

Systemic fix for the TOCTOU credential race (F-034). The singleton
pattern mutated a shared `libraryUUID` to switch keychain keys at
runtime — any concurrent access during an account switch could read
or write the wrong account's credentials.

Architecture change:
- TPPUserAccount gains init(libraryUUID:) with immutable
  boundLibraryUUID and its own accountInfoQueue per instance
- AccountsManager gains userAccounts cache, userAccount(for:),
  and currentUserAccount — conforming to new
  TPPUserAccountResolving protocol
- Instance-level credentialSnapshot() reads from self (no UUID
  switching needed)

Production code migration (37 files):
- All ~65 call sites of sharedAccount() / sharedAccount(libraryUUID:)
  / credentialSnapshot(for:) replaced with instance-based access via
  AccountsManager.shared.currentUserAccount or .userAccount(for:)
- Network layer uses per-account instances resolved at request time
- atomicUpdate(for:) calls replaced with direct method calls on
  per-account instances (no UUID switching needed)

Legacy singleton retained for test compatibility — TPPUserAccountMock
subclasses it. Full singleton removal deferred to followup (requires
mock refactoring across ~30 test files).

4,502 tests pass, 0 failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…n gate)

Tests validate the systemic fix for credential cross-contamination:
- Instance cache returns same/different instances correctly
- boundLibraryUUID is immutable on per-account instances
- Writing credentials to Account A does not affect Account B
- Both accounts hold independent credentials simultaneously
- Token credentials are isolated between accounts
- credentialSnapshot() returns correct per-account data
- removeAll() on one account does not affect the other
- Concurrent writes to different accounts never cross-contaminate
- Concurrent snapshots always return the correct account's data

These tests serve as the regression gate: if anyone reintroduces
shared mutable state in TPPUserAccount, the concurrent tests fail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…t one

The grep piped to tail -1 was grabbing the TenPrintCover bundle (6 tests)
instead of the PalaceTests bundle (4,502 tests). Now uses the "All tests"
summary line, with a fallback that sums across all bundles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
gate-check command queries pipeline.gates and evidence endpoints,
verifies all gates passed and test count meets minimum threshold.
Exits 0 (allow) or 1 (block).

forgeos-gate-hook.sh finds the changeset for the current branch and
runs gate-check. Wired into Claude Code hooks to run before gh pr
create. PR creation is blocked unless:
- A ForgeOS changeset exists for the branch
- All gates (review, testing, release) are passed
- Test evidence shows >= FORGEOS_MIN_TESTS passing (default 100)

This makes ForgeOS governance blocking, not ceremonial.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…d add error origin tagging

Cherry-picked from release/2.2.5 into modernize/whole-shot:

PP-4045 (BLOCKER): Libraries without PIN requirements got "Invalid
Credentials" on sign-in. The guard in TokenRequest rejected empty
passwords, but empty password is valid Basic Auth for pinless
libraries (RFC 7617). Fix: only guard against empty username.

PP-4033: Cancel Hold button did nothing -- no error, no feedback.
The revoke endpoint error handler only handled two specific error
types; all others fell through silently. Fix: catch-all else branch
that announces failure to the user.

Error origin tagging: Every non-fatal error logged to Crashlytics
now includes error_origin (client/server/network/vendor) for
dashboard filtering. Auto-classified from error codes, HTTP status,
and URL error domains. Addresses F-META-1.

Tests: 13 credential guard tests pass including new pinless login
test that verifies Basic Auth "barcode:" format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Regression plan: 23 journeys mapped to PP-4020 manual test areas,
covering 35% of test matrix automatically.

New journeys: place-hold (C3/C4/PP-4033), return-loan (C6),
credential-isolation (M3/F-034/ADV-1), persistence-reading-position
(P1/P2).

Accessibility: add .accessibilityLabel to PIN SecureField/TextField
in AccountDetailView. SwiftUI SecureField placeholder text is not
exposed to the XCTest accessibility tree, blocking automated sign-in.

Replays saved: catalog-browsing, tab-navigation, search-flow (all
PASS), sign-in-attempt (quarantined, SecureField invisible).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@mauricecarrier7 mauricecarrier7 force-pushed the fix/per-account-user-credentials branch from 8a16bc4 to 556e1b5 Compare April 15, 2026 17:53
@mauricecarrier7 mauricecarrier7 merged commit 1462069 into modernize/whole-shot Apr 15, 2026
mauricecarrier7 added a commit that referenced this pull request Apr 15, 2026
Two related fixes addressing fallout from PR #822's per-account
TPPUserAccount migration. Both codify invariants that prevent the
download / borrow flow from misclassifying benign failures as
credentials problems and reflexively presenting a useless sign-in
modal that traps the user.

1) MyBooksDownloadCenter.userAccount becomes a computed property

The download center is a singleton (`@objc static let shared`).
Before this change its `userAccount` property was set ONCE at init
time from `AccountsManager.shared.currentUserAccount`. After the
per-account migration, that captures whichever account happened to
be current at app-launch time and never refreshes — so library
switches and fresh sign-ins are invisible to the download center.
Symptom: tap Download on a freshly-borrowed book → download center
checks `userAccount.hasCredentials()` against a stale instance,
sees `false`, fires the credentials path even though the user is
signed in.

Fix: replace the stored property with a computed property that
always reads `accountsManager.currentUserAccount`. The init param
is preserved as a test-only override (`injectedUserAccount`); no
production caller passes it.

2) handleBorrowAuthErrorIfNeeded refuses to call a borrow failure
   "auth-related" when the user already has an active loan

`processRegularDownload` (line 559) auto-re-borrows whenever the
registry's copy of a book still carries a `.borrow` primary
acquisition. For some catalogs the loans-feed entry retains the
borrow relation even after the loan is created, so the auto-re-
borrow fires for already-borrowed books. The server responds with
401 (loan-already-exists / cannot-issue-loan), which the network
responder maps to `TPPErrorCode.invalidCredentials`. Without a
guard, `handleBorrowAuthErrorIfNeeded` then concludes credentials
are bad, calls `markCredentialsStale()`, and presents the sign-in
modal — but the modal shows the user's *signed-in* AccountDetail
view (Sign out button visible, no input fields) titled "Sign in",
which is confusing and unfixable from the UI.

Fix: add a guard at the top of the auth-error decision path. If
the registry already has the book in any active-loan state
(`.downloadNeeded`, `.downloading`, `.downloadSuccessful`,
`.downloadFailed`, `.holding`, `.SAMLStarted`, `.used`,
`.returning`) AND credentials are present, the failure CANNOT be
a credentials problem — return `false` so the caller surfaces a
real error instead of a fake auth modal.

3) Architectural-invariant comment refinement on the SQ-005 anonymous
   guard in SignInModalPresenter — clarifies why the central choke
   point is the right place for the !needsAuth early-return.

These guards do NOT block legitimate re-auth flows. Genuine session
expiry (SAML cookies, OAuth tokens, OIDC) is detected by separate
problem-document type checks and explicit `markCredentialsStale()`
calls upstream, neither of which is short-circuited by the new
guards.

Replay artifact:
- .specterqa/replays/sign-in-a1qa-success.yaml — verifies SQ-001 fix
  end-to-end: PIN field visible, form submits, A1QA sign-in succeeds
  with the rotated credentials (01230000000237 / Lyrtest123).

Note: SQ-007 still has at least one un-traced call site that
presents the modal even with these guards in place; see the SQ-007
section of regression-PP-4020/specterqa-regression-findings.md for
the open investigation. The two guards committed here are the
correct systemic shape — they do not depend on the open issue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
mauricecarrier7 added a commit that referenced this pull request Apr 15, 2026
…/8) (#825)

* Add per-account credential isolation tests (10 tests, F-034 regression gate)

Tests validate the systemic fix for credential cross-contamination:
- Instance cache returns same/different instances correctly
- boundLibraryUUID is immutable on per-account instances
- Writing credentials to Account A does not affect Account B
- Both accounts hold independent credentials simultaneously
- Token credentials are isolated between accounts
- credentialSnapshot() returns correct per-account data
- removeAll() on one account does not affect the other
- Concurrent writes to different accounts never cross-contaminate
- Concurrent snapshots always return the correct account's data

These tests serve as the regression gate: if anyone reintroduces
shared mutable state in TPPUserAccount, the concurrent tests fail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix Palace Bookshelf empty sign-in modal on Download (SQ-005)

Anonymous-auth libraries (Palace Bookshelf, COPPA) showed a broken
sign-in sheet with no fields and no continue button when Download
was tapped. Cancel was the only escape, blocking all reading on
Palace Bookshelf — the demo/onboarding library.

Root cause: MyBooksDownloadCenter.handleProblem (line 769) and
several similar paths fired the reauth modal whenever
!hasCredentials, without first checking whether the auth method
actually requires credentials. Anonymous accounts always have no
credentials by definition, so the condition was always true. The
reauthenticator unconditionally invoked
SignInModalPresenter.presentSignInModalForCurrentAccount, which
mounts AccountDetailView(forceReauthMode: true) — for anonymous
auth there are no fields to render, so only the navigation chrome
appeared.

Systemic fix: early-return in
SignInModalPresenter.presentSignInModalForCurrentAccount when
!userAccount.needsAuth. Single choke point protects 15+ call sites
across MyBooksDownloadCenter, BookActionHandler, BookCellModel,
HoldsViewModel, TPPNetworkExecutor, TokenRefreshInterceptor,
DLNavigator, MyBooksViewModel, and BookDetailViewModel. Callers
always get their completion fired so existing flows complete
without modification.

Latent secondary fix: UserAccountAuthHelper.needsAuth was missing
.oidc, inconsistent with AccountDetails.Authentication.needsAuth.
OIDC libraries would have silently bypassed needsAuth gates.
Aligned the two definitions.

Verified end-to-end via SpecterQA on iPhone 12 / iOS 26:
1. Switched to Palace Bookshelf
2. Tapped Borrow on Dobbs v. Jackson (DPLA Publications lane)
3. Half-sheet showed Read + Remove (auto-download succeeded)
4. Tapped Read → EPUB reader opened at "Page 1 of 488 (Cover)"
5. Swipe-left → page 2 with rendered Dobbs syllabus text

Replays for both reproduction and the fixed flow are committed
under .specterqa/replays/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* SQ-007: Two systemic guards for stale per-account credential references

Two related fixes addressing fallout from PR #822's per-account
TPPUserAccount migration. Both codify invariants that prevent the
download / borrow flow from misclassifying benign failures as
credentials problems and reflexively presenting a useless sign-in
modal that traps the user.

1) MyBooksDownloadCenter.userAccount becomes a computed property

The download center is a singleton (`@objc static let shared`).
Before this change its `userAccount` property was set ONCE at init
time from `AccountsManager.shared.currentUserAccount`. After the
per-account migration, that captures whichever account happened to
be current at app-launch time and never refreshes — so library
switches and fresh sign-ins are invisible to the download center.
Symptom: tap Download on a freshly-borrowed book → download center
checks `userAccount.hasCredentials()` against a stale instance,
sees `false`, fires the credentials path even though the user is
signed in.

Fix: replace the stored property with a computed property that
always reads `accountsManager.currentUserAccount`. The init param
is preserved as a test-only override (`injectedUserAccount`); no
production caller passes it.

2) handleBorrowAuthErrorIfNeeded refuses to call a borrow failure
   "auth-related" when the user already has an active loan

`processRegularDownload` (line 559) auto-re-borrows whenever the
registry's copy of a book still carries a `.borrow` primary
acquisition. For some catalogs the loans-feed entry retains the
borrow relation even after the loan is created, so the auto-re-
borrow fires for already-borrowed books. The server responds with
401 (loan-already-exists / cannot-issue-loan), which the network
responder maps to `TPPErrorCode.invalidCredentials`. Without a
guard, `handleBorrowAuthErrorIfNeeded` then concludes credentials
are bad, calls `markCredentialsStale()`, and presents the sign-in
modal — but the modal shows the user's *signed-in* AccountDetail
view (Sign out button visible, no input fields) titled "Sign in",
which is confusing and unfixable from the UI.

Fix: add a guard at the top of the auth-error decision path. If
the registry already has the book in any active-loan state
(`.downloadNeeded`, `.downloading`, `.downloadSuccessful`,
`.downloadFailed`, `.holding`, `.SAMLStarted`, `.used`,
`.returning`) AND credentials are present, the failure CANNOT be
a credentials problem — return `false` so the caller surfaces a
real error instead of a fake auth modal.

3) Architectural-invariant comment refinement on the SQ-005 anonymous
   guard in SignInModalPresenter — clarifies why the central choke
   point is the right place for the !needsAuth early-return.

These guards do NOT block legitimate re-auth flows. Genuine session
expiry (SAML cookies, OAuth tokens, OIDC) is detected by separate
problem-document type checks and explicit `markCredentialsStale()`
calls upstream, neither of which is short-circuited by the new
guards.

Replay artifact:
- .specterqa/replays/sign-in-a1qa-success.yaml — verifies SQ-001 fix
  end-to-end: PIN field visible, form submits, A1QA sign-in succeeds
  with the rotated credentials (01230000000237 / Lyrtest123).

Note: SQ-007 still has at least one un-traced call site that
presents the modal even with these guards in place; see the SQ-007
section of regression-PP-4020/specterqa-regression-findings.md for
the open investigation. The two guards committed here are the
correct systemic shape — they do not depend on the open issue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* SpecterQA replay: A1QA sign-in → return loan → sign-out happy path

End-to-end Tier-1 verification on the rotated A1QA credentials
(01230000000237 / Lyrtest123). Captures:

1. Sign in via Settings → Libraries → A1QA Test Library
2. My Books loads 7 loans (5 ebooks, 2 audiobooks) with metadata
3. Tap Return on "Accessibility Handbook" → confirmation alert
4. Confirm Return → server-side return succeeds, list shrinks to 6
5. Settings → Sign out → My Books empties

Validates that sign-in, return-loan, and sign-out all work
end-to-end on basic-auth libraries with the SQ-005 + SQ-007 guards
applied. Download from A1QA My Books cell remains blocked by an
un-traced SQ-007 call site — see SESSION-2026-04-11.md for the
open investigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix Cancel Hold non-functional on Manage Hold half-sheet (SQ-008)

Three related fixes for the Manage Hold half-sheet:

1) Cancel Hold confirmation alert rendered behind the sheet

Root cause: BookCellModel.showAlert (the @published property that
holds the cancel-hold confirmation AlertModel) was only bound to
NormalBookCell's .alert(item:) modifier — which sits BEHIND the
presented half-sheet. When the user tapped Cancel Hold on the
sheet, the confirmation alert appeared on the cell underneath,
invisible and non-interactive. The user saw no response to their
tap.

Fix: added showAlert to the HalfSheetProvider protocol and bound
a second .alert(item:) modifier directly on HalfSheetView. Also
added @published var showAlert to BookDetailViewModel (which also
conforms to HalfSheetProvider). The confirmation now renders ON
the sheet where the user can see and interact with it.

QA had previously reported this bug from manual testing; SpecterQA
independently reproduced it via automated regression, confirming
the issue is real and not environment-specific.

2) Problem document extraction for return/cancel-hold failures

The old code at line 1163 had `error as? Decoder` which could
never succeed — `error` is a [String: Any]? dictionary, not a
Swift Decoder. The problem document was silently dropped and the
user only saw the generic "could not be completed" message.

Fix: extract via guard let + JSONSerialization.data + fromData(),
with a nil guard to prevent SIGABRT when error is nil (which
NSJSONSerialization throws as an ObjC NSException, not a Swift
Error, so try? doesn't catch it).

3) Half-sheet layout for manage-hold state

Added bottom padding (40pt) and .large presentation detent option
for manage-hold sheets as defense-in-depth against content
overflow into the home indicator safe area.

Also removes the SQ-007 file-based trace instrumentation from
SignInModalPresenter (no longer needed — SQ-007 is fully resolved).

Verified end-to-end via SpecterQA 11.5.0:
- Reservations → Manage Hold → Cancel Hold → confirmation alert
  appears ON the sheet → Remove Hold → server error alert with
  Retry/Remove from Device/Cancel → no crash

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Migrate all legacy TPPOPDSFeed.withURL callers to OPDSFeedService

Completes the OPDS2 migration by eliminating the last four callers
of the legacy TPPOPDSFeed.withURL callback API. All now use
OPDSFeedService.shared.fetchFeed (async/await) which wraps the
same OPDS 1.x parser but provides typed PalaceError with attached
problem documents — the infrastructure built during the OPDS2 work.

Fully backwards compatible: OPDSFeedService.fetchFeed calls
TPPOPDSFeed.withURL internally. Same network path, same XML
parser, same feed types. The only change is error plumbing.

Migrated callers:

1) MyBooksDownloadCenter.returnBook — rewritten from deeply nested
   callbacks to linear async/await. Problem document now extracted
   from typed PalaceError via NSError.problemDocument instead of
   the broken `error as? Decoder` cast. All three error branches
   (loan-gone, invalid-credentials, generic) preserved with
   identical behavior but clearer structure.

2) TPPBookRegistry.sync — loans feed sync. Error document now
   extracted from PalaceError userInfo instead of the raw callback
   dictionary. Logs sync failures with error description.

3) BookRegistrySync.sync — same pattern as TPPBookRegistry.sync.

4) BookDetailViewModel.loadRelatedBooks — related books fetch. Was
   silently discarding errors (`_`), now logs them for diagnostics.

After this commit, TPPOPDSFeed.withURL is only called from
OPDSFeedService.fetchFeed (the wrapper itself) — no direct
callers remain in application code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Rename "Reservations" to "Holds" across UI copy

Tab bar, nav titles, search labels, and error messages all now
say "Holds" instead of "Reservations". This rename was previously
done on develop but was lost when this branch diverged.

API-level fields (supportsReservations) and the legacy
removeReservation action label are unchanged — those are protocol
contracts, not user-facing copy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Propagate server error details to return/cancel-hold failure UI

Two changes that close the diagnostic gap for return and
cancel-hold failures:

1) TPPOPDSFeed.withURL: synthesize error dict for non-problem-doc
   HTTP errors

When a server returns a non-2xx status without a problem document
(e.g., HTML error page, empty body, or non-OPDS JSON), the
callback previously received (nil, nil) — swallowing the HTTP
status code and response body entirely. Now synthesizes a dict
with type "http-error", the localized HTTP status text, the
numeric code, and the first 500 chars of the response body.

2) returnBook catch block: append server detail to error message

Extracts the server's reason from the problem document detail,
the NSError userInfo (where OPDSFeedService stashes
"problemDocumentDetail"), or falls back to the localized error
description. Appends it to the alert message so users and testers
see WHY the return failed, not just THAT it failed.

Verified via SpecterQA: cancel-hold on OverDrive now shows
"Invalid OPDS feed" instead of the generic "could not be
completed" — identifying that the OverDrive revoke endpoint
returns a non-OPDS response format for this account's hold state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add OPDSFeedMigrationTests: 11 tests for OPDS migration paths

Covers the key regression and invariants from the OPDS2 migration:

- Revoke tolerance: .opdsFeedInvalid IS tolerated for revoke
  operations (OverDrive returns non-OPDS XML on success), other
  parsing/auth errors are NOT tolerated
- Synthetic error dict: HTTP errors produce valid problem documents
  with status code and response body
- Error message fallback chain: detail extraction from problemDoc
  → NSError userInfo → localizedDescription
- SQ-005 auth guards: anonymous (false), basic (true), OIDC (true)
- SQ-008 protocol: HalfSheetProvider conformance with showAlert on
  both BookCellModel and BookDetailViewModel

All 11 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* F-035: Auto-refresh My Books/Holds on tab switch; add 13 regression tests

F-035 fix: TPPBookRegistry.sync() is now called when the user
switches to the My Books or Holds tab, so newly borrowed/returned
books appear without pull-to-refresh. The existing notification
observers in MyBooksViewModel already handle the sync completion.

Credential Isolation E2E Tests (5 tests):
- Sign in A → switch B → sign in B → verify A intact
- Sign in A → switch B → sign in B → sign out B → verify A intact
- Three simultaneous libraries, all isolated
- markCredentialsStale on A doesn't affect B
- 500 rapid concurrent switches, no contamination

SAML/OIDC Regression Tests (8 tests):
- needsAuth exhaustive for all AuthType values
- needsAuth consistency between two implementations (SQ-005 fix)
- Borrow reauth guard allows reauth for unregistered books (SAML)
- Borrow reauth guard blocks reauth for borrowed books (SQ-007)
- All 10 book states correctly classified as loan/non-loan
- SignInModal guard: SAML not blocked, OIDC not blocked, anonymous blocked

All 24 new tests pass (+ 11 existing OPDSFeedMigrationTests = 35 total).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
mauricecarrier7 added a commit that referenced this pull request Apr 17, 2026
Two regressions from build 459 → HEAD traced and fixed.

1) Cache coherence (Gorgon sign-out didn't refresh UI):
   TPPKeychainVariable caches the last-read value and only invalidates
   when its .key changes. PR #822 introduced per-account TPPUserAccount
   instances and AccountDetailViewModel switched from the static class
   method TPPUserAccount.credentialSnapshot(for:) to the per-instance
   credentialSnapshot(). But the sign-in/out pipeline writes through
   the singleton — the per-account instance's cache never sees the
   update, so the view model keeps returning stale "signed in" state
   and the UI never refreshes. Fix: the instance method now toggles
   libraryUUID (nil → uuid) before reading, mirroring the static
   method's behavior; the key re-bind invalidates every variable's
   cache via the didSet hook on TPPKeychainVariable.key.

2) OIDC dropped from browser-auth prompt (AccountDetailView regression):
   Commit 6c88fb1 renamed isBrowserBasedAuth to isOAuthOrSAML and
   dropped the isOidc check. For OIDC libraries that was changing
   shouldShowSignInPrompt from true to false post-sign-out, rendering
   the credential fields instead of the WebView prompt. Restored to
   the three-way check that shipped in build 459.

New regression guard: TPPCredentialSnapshotCoherenceTests.swift —
three tests that write via one TPPUserAccount instance and read via
a peer instance. These would have caught the cache coherence bug on
the day PR #822 shipped; they will catch the next one.

Full auth regression: 123/123 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mauricecarrier7 added a commit that referenced this pull request Apr 17, 2026
Finishes the per-account credential isolation migration started in PR #822,
which left the legacy singleton in place as a safety-net fallback. That
fallback was the root cause of the login-prompt-during-download race:
`TPPUserAccount.sharedAccount(libraryUUID:)` re-pointed one shared instance
at different libraries by mutating `libraryUUID`, and during the window
between "libraryUUID flipped to nil" and "keychain keys re-resolved,"
`hasCredentials()` returned false on an account that was in fact signed in.

Production call sites migrated:
- TPPAnnotations.currentID() — now reads AccountsManager.currentUserAccount.deviceID
- TPPSignInBusinessLogic.userAccount — now resolves via libraryAccountsProvider.userAccount(for:).
  Widens TPPLibraryAccountsProvider to also conform to TPPUserAccountResolving,
  so the existing injected provider can vend per-library accounts directly.
- AccountsManager.currentUserAccount — no longer falls back to the singleton
  on transient `currentAccountId == nil` windows. Caches the last-known
  account and returns a deterministic "no-account" placeholder on a truly
  fresh install, avoiding the race that produced the login modal.

TPPUserAccount changes:
- `libraryUUID` is now immutable (`let`). The didSet -> updateKeychainKeys()
  cache-invalidation trick is gone; a new `invalidateAllKeychainCaches()`
  helper calls `TPPKeychainVariable.invalidateCache()` explicitly.
- `static private let shared`, legacy `override init()`, and the mutable
  `sharedAccount(libraryUUID:)` implementation are removed.
- `credentialSnapshot()` no longer does the nil→uuid flip; it calls the
  explicit invalidator.
- Class-level `credentialSnapshot(for:)` now forwards to
  `AccountsManager.shared.userAccount(for:).credentialSnapshot()` without
  mutating any singleton state.
- `atomicUpdate(for:)` asserts the passed UUID matches the bound instance
  and runs the block under the per-account barrier queue.
- `sharedAccount()` / `sharedAccount(libraryUUID:)` remain as
  `@available(deprecated)` thin delegates — they do not mutate shared state.
  Retained purely so ~90 test call sites keep compiling; scheduled for
  removal after those tests are migrated to AccountsManager directly.

Protocol:
- `TPPUserAccountProvider.sharedAccount(libraryUUID:)` removed.

Test infrastructure:
- TPPUserAccountMock gains a `convenience init()` that routes to
  `init(libraryUUID:)` with a fixed test UUID.
- TPPLibraryAccountMock now conforms to the widened
  TPPLibraryAccountsProvider, with an injectable `userAccountResolver`
  closure so cross-library-isolation tests can route through
  `TPPMultiLibraryAccountMock` per-UUID while the default single-instance
  case is unchanged.

Verification:
- Full xctest run: 5555 tests, 0 failures, 33 skipped (unrelated to this change).
- Auth-critical suites (PerAccountIsolation, CrossLibrarySignOut,
  CredentialVisibility, AccountSwitchCleanup, SAMLSignIn, AuthFlowSecurity,
  OverdriveDeferredFulfillment) all green.

ForgeOS: cs_583f4c98 (init_67d79332). Lesson obs_a87ddd3f captures the
"incomplete-migration with safety-net fallback" anti-pattern this fixes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant